diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..649338e6 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +branch=$(git symbolic-ref --quiet HEAD) || { + printf '%s\n' 'Commit blocked: work must be on develop or a local working branch, not detached HEAD.' >&2 + exit 1 +} +case "$branch" in + refs/heads/main) + printf '%s\n' 'Commit blocked: main receives changes only through a GitHub PR from develop.' >&2 + exit 1 + ;; + refs/heads/release/*) + printf '%s\n' 'Commit blocked: release branches are Andrew-only.' >&2 + exit 1 + ;; + refs/heads/*) ;; + *) + printf '%s\n' 'Commit blocked: cannot establish a local working branch.' >&2 + exit 1 + ;; +esac diff --git a/.githooks/pre-merge-commit b/.githooks/pre-merge-commit new file mode 100755 index 00000000..649338e6 --- /dev/null +++ b/.githooks/pre-merge-commit @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +branch=$(git symbolic-ref --quiet HEAD) || { + printf '%s\n' 'Commit blocked: work must be on develop or a local working branch, not detached HEAD.' >&2 + exit 1 +} +case "$branch" in + refs/heads/main) + printf '%s\n' 'Commit blocked: main receives changes only through a GitHub PR from develop.' >&2 + exit 1 + ;; + refs/heads/release/*) + printf '%s\n' 'Commit blocked: release branches are Andrew-only.' >&2 + exit 1 + ;; + refs/heads/*) ;; + *) + printf '%s\n' 'Commit blocked: cannot establish a local working branch.' >&2 + exit 1 + ;; +esac diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..02995ddc --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +deny() { + printf '%s\n' "Push blocked: $1" >&2 + printf '%s\n' 'Only an authorized fast-forward from local develop to existing remote develop is eligible.' >&2 + printf '%s\n' 'Main requires a GitHub PR from develop. Do not bypass this guard through options, config, APIs or UI.' >&2 + exit 1 +} + +validate_oid() { + case "$1" in ''|*[!0-9a-fA-F]*) return 1 ;; esac + case ${#1} in 40|64) return 0 ;; *) return 1 ;; esac +} + +updates=0 +while read -r local_ref local_oid remote_ref remote_oid extra; do + [ -z "$extra" ] || deny 'malformed ref update.' + [ "$remote_ref" = refs/heads/develop ] || deny "$remote_ref is not develop." + [ "$local_ref" = refs/heads/develop ] || deny 'the source must be local refs/heads/develop.' + validate_oid "$local_oid" && validate_oid "$remote_oid" || deny 'malformed object ID.' + case "$local_oid" in *[!0]*) ;; *) deny 'branch deletion is forbidden.' ;; esac + case "$remote_oid" in *[!0]*) ;; *) deny 'remote branch creation needs Andrew’s explicit authorization.' ;; esac + current_oid=$(git rev-parse --verify refs/heads/develop) || deny 'local develop is missing.' + [ "$local_oid" = "$current_oid" ] || deny 'the source object is not the current local develop head.' + git cat-file -e "$remote_oid^{commit}" 2>/dev/null || deny 'remote develop commit is unavailable locally; fetch it and reassess.' + git cat-file -e "$local_oid^{commit}" 2>/dev/null || deny 'local develop is not a known commit.' + git merge-base --is-ancestor "$remote_oid" "$local_oid" || deny 'develop update would rewrite remote history.' + updates=$((updates + 1)) +done +[ "$updates" -gt 0 ] || deny 'no ref updates were supplied; the destination cannot be established.' diff --git a/.githooks/test-guards.sh b/.githooks/test-guards.sh new file mode 100755 index 00000000..b0dcfffa --- /dev/null +++ b/.githooks/test-guards.sh @@ -0,0 +1,140 @@ +#!/bin/sh +set -eu + +kit=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +tmp=$(mktemp -d "${TMPDIR:-/tmp}/ichiloto-guards.XXXXXX") +trap 'rm -rf "$tmp"' EXIT HUP INT TERM +work="$tmp/work" +remote="$tmp/remote.git" +checks=0 + +pass() { checks=$((checks + 1)); } +fail() { printf 'FAIL: %s\n' "$1" >&2; cat "$tmp/log" >&2; exit 1; } +expect_failure() { + label=$1 + shift + if "$@" >"$tmp/log" 2>&1; then fail "$label unexpectedly succeeded"; fi + pass +} +expect_success() { + label=$1 + shift + if ! "$@" >"$tmp/log" 2>&1; then fail "$label failed"; fi + pass +} +check_update() { + expected=$1 + label=$2 + shift 2 + printf '%s\n' "$@" >"$tmp/input" + if (cd "$work" && "$kit/.githooks/pre-push" origin "$remote" <"$tmp/input") >"$tmp/log" 2>&1; then + actual=allow + else + actual=deny + fi + [ "$actual" = "$expected" ] || fail "$label: expected $expected, got $actual" + pass +} + +git init --quiet --initial-branch=main "$work" +git -C "$work" config user.name 'Governance fixture' +git -C "$work" config user.email 'governance-fixture@example.invalid' +printf 'seed\n' >"$work/content" +git -C "$work" add content +git -C "$work" commit --quiet -m 'test: seed fixture before installing guards' +seed=$(git -C "$work" rev-parse HEAD) +git -C "$work" branch develop +git clone --quiet --bare "$work" "$remote" +git -C "$work" remote add origin "$remote" +cp -R "$kit/.githooks" "$work/.githooks" +mkdir -p "$work/scripts" +cp "$kit/scripts/install-git-guards.sh" "$work/scripts/" + +expect_success 'initial install' git -C "$work" -c core.hooksPath=.git/hooks status --short +(cd "$work" && ./scripts/install-git-guards.sh) >"$tmp/log" 2>&1 || fail 'initial guard installation' +pass +first_target=$(readlink "$work/.git/hooks/pre-push") +(cd "$work" && ./scripts/install-git-guards.sh) >"$tmp/log" 2>&1 || fail 'idempotent guard installation' +[ "$(readlink "$work/.git/hooks/pre-push")" = "$first_target" ] || fail 'installer replaced existing guard symlink' +pass + +printf 'forbidden\n' >"$work/forbidden-main" +git -C "$work" add forbidden-main +expect_failure 'main commit' git -C "$work" commit --quiet -m 'test: must be blocked' +[ "$(git -C "$work" rev-parse HEAD)" = "$seed" ] || fail 'main commit changed HEAD' +pass +git -C "$work" reset --quiet +rm "$work/forbidden-main" + +git -C "$work" switch --quiet develop +printf 'develop\n' >>"$work/content" +git -C "$work" add content +expect_success 'develop commit' git -C "$work" commit --quiet -m 'test: accepted develop fixture' +develop=$(git -C "$work" rev-parse HEAD) +git -C "$work" switch --quiet main +expect_failure 'main merge commit' git -C "$work" merge --no-ff -m 'test: blocked main merge' develop +[ "$(git -C "$work" rev-parse HEAD)" = "$seed" ] || fail 'main merge changed HEAD' +pass +git -C "$work" merge --abort +expect_failure 'main no-op push' git -C "$work" push origin main +git -C "$work" switch --quiet develop +expect_success 'existing develop fast-forward push' git -C "$work" push origin develop +[ "$(git --git-dir="$remote" rev-parse develop)" = "$develop" ] || fail 'remote develop did not advance' +pass + +zero=0000000000000000000000000000000000000000 +unknown=1111111111111111111111111111111111111111 +check_update allow 'develop fast-forward' "refs/heads/develop $develop refs/heads/develop $seed" +check_update allow 'develop unchanged input' "refs/heads/develop $develop refs/heads/develop $develop" +check_update deny 'main update' "refs/heads/develop $develop refs/heads/main $seed" +check_update deny 'main no-op' "refs/heads/main $seed refs/heads/main $seed" +check_update deny 'main deletion' "(delete) $zero refs/heads/main $seed" +check_update deny 'develop deletion' "(delete) $zero refs/heads/develop $develop" +check_update deny 'develop creation' "refs/heads/develop $develop refs/heads/develop $zero" +check_update deny 'unknown remote object' "refs/heads/develop $develop refs/heads/develop $unknown" +check_update deny 'feature source into develop' "refs/heads/feature/test $develop refs/heads/develop $seed" +check_update deny 'mixed destination push' "refs/heads/develop $develop refs/heads/develop $seed" "refs/heads/develop $develop refs/heads/main $seed" +check_update deny 'false develop object' "refs/heads/develop $seed refs/heads/develop $seed" +check_update deny 'malformed update' "refs/heads/develop not-a-hash refs/heads/develop $seed" +check_update deny 'extra input field' "refs/heads/develop $develop refs/heads/develop $seed surprise" +for ref in refs/heads/feature/test refs/heads/fix/test refs/heads/release/1.0.0 refs/tags/v1.0.0; do + check_update deny "forbidden destination $ref" "refs/heads/develop $develop $ref $seed" +done + +git -C "$work" update-ref refs/heads/main "$develop" +expect_failure 'actual direct main push' git -C "$work" push origin main +[ "$(git --git-dir="$remote" rev-parse main)" = "$seed" ] || fail 'blocked main push mutated remote' +pass +git -C "$work" update-ref refs/heads/develop "$seed" +expect_failure 'actual force rewrite of develop' git -C "$work" push --force origin develop +[ "$(git --git-dir="$remote" rev-parse develop)" = "$develop" ] || fail 'blocked rewrite mutated remote' +pass +git -C "$work" update-ref refs/heads/develop "$develop" +expect_failure 'actual remote working branch creation' git -C "$work" push origin develop:feature/test +expect_failure 'actual remote develop deletion' git -C "$work" push origin :develop +expect_failure 'actual mixed develop/main push' git -C "$work" push origin develop main + +git -C "$work" switch --quiet --detach "$develop" +expect_failure 'detached commit' git -C "$work" commit --allow-empty --quiet -m 'test: detached must be blocked' +git -C "$work" switch --quiet -c release/1.0.0 +expect_failure 'release commit' git -C "$work" commit --allow-empty --quiet -m 'test: release must be blocked' +git -C "$work" switch --quiet -c feature/test +expect_success 'local working branch commit' git -C "$work" commit --allow-empty --quiet -m 'test: local working branch is allowed' + +# The configured hooks directory is preserved; unknown hooks block all installation. +git -C "$work" config core.hooksPath custom-hooks +mkdir "$work/custom-hooks" +printf '#!/bin/sh\nexit 0\n' >"$work/custom-hooks/pre-push" +chmod +x "$work/custom-hooks/pre-push" +cp "$work/custom-hooks/pre-push" "$tmp/existing-hook" +expect_failure 'unknown existing hook' sh -c 'cd "$1" && ./scripts/install-git-guards.sh' sh "$work" +cmp -s "$tmp/existing-hook" "$work/custom-hooks/pre-push" || fail 'installer changed unrelated hook' +[ ! -e "$work/custom-hooks/pre-commit" ] || fail 'blocked install partially changed hook directory' +[ "$(git -C "$work" config core.hooksPath)" = custom-hooks ] || fail 'installer changed core.hooksPath' +pass +rm "$work/custom-hooks/pre-push" +ln -s "$work/.githooks/pre-push" "$work/custom-hooks/pre-push" +expect_success 'configured hooks directory install' sh -c 'cd "$1" && ./scripts/install-git-guards.sh' sh "$work" +[ "$(git -C "$work" config core.hooksPath)" = custom-hooks ] || fail 'configured path was not retained' +pass +printf 'Passed %s guard and installer checks using real commits, merges and local bare push destinations.\n' "$checks" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..8555c0e3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,9 @@ +Read `AGENTS.md` and `GIT_WORKFLOW.md` before acting. Andrew's develop-to-main +rule applies only where he has created a `develop` branch. This repository has +that existing branch: no direct commits, local merges or pushes to `main`. +All changes integrate into `develop` and reach `main` only through a +same-repository develop-to-main PR. Repositories found without Andrew-created +`develop` are outside this remediation and must be left alone; neither creating +that branch nor direct-main publishing is authorized by that exclusion. +Keep explicit branch-publication, deletion and history-rewrite permissions. +No historical private-repository exception grants permission. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..48bdc1b5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +## Change and validation + +Describe the resulting behavior, tests run, and relevant limits. + +## Git workflow + +- [ ] I read `AGENTS.md` and `GIT_WORKFLOW.md` and preserved existing work. +- [ ] I confirmed Andrew created `develop` in this repository; the develop-to-main rule applies within that scope, and repositories without it are left alone. +- [ ] In this existing-develop repository, a PR into `main` comes from its `develop`. +- [ ] Any working branch here feeds `develop`; its publication has explicit approval. +- [ ] No direct-main commit/local merge/push here, unauthorized branch deletion or history rewrite; exclusion of other repositories grants no publishing permission. +- [ ] Removed, disabled or narrowed behavior is named plainly. diff --git a/.github/workflows/git-governance.yml b/.github/workflows/git-governance.yml new file mode 100644 index 00000000..dc743db9 --- /dev/null +++ b/.github/workflows/git-governance.yml @@ -0,0 +1,35 @@ +name: Git workflow governance + +on: + pull_request_target: + branches: [main] + types: [opened, synchronize, reopened, edited, ready_for_review] + +permissions: + contents: read + +jobs: + validate-develop-source: + name: Validate develop source + runs-on: ubuntu-latest + steps: + - name: Require same-repository develop for main + env: + EXPECTED_REPOSITORY: ${{ github.repository }} + run: | + python3 - <<'PY' + import json, os, sys + with open(os.environ['GITHUB_EVENT_PATH'], encoding='utf-8') as event_file: + pr = json.load(event_file)['pull_request'] + head = pr.get('head') or {} + head_repo = head.get('repo') or {} + valid = ( + pr.get('base', {}).get('ref') == 'main' + and head.get('ref') == 'develop' + and head_repo.get('full_name') == os.environ['EXPECTED_REPOSITORY'] + ) + if not valid: + print('Main accepts only a PR from this repository\'s develop branch.', file=sys.stderr) + sys.exit(1) + print('Verified: same-repository develop -> main.') + PY diff --git a/AGENTS.md b/AGENTS.md index dc90ce8d..e0e26611 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,37 @@ -# Editor Engineering Rules - -These rules intentionally repeat the repository-level rules so editor work cannot drift into local-only shortcuts. - -## Correctness First - -Always favor correctness, maintainability, and complete design over speed. No shortcuts. No hacky solutions. If a problem deserves a proper model, architecture, state machine, picker, abstraction, or validation layer, build that instead of patching symptoms. - -## Solve Globally By Default - -When fixing a problem, solve the root issue globally rather than making a narrow local workaround, unless Andrew explicitly asks for a local-only change. Look for the broader pattern, existing architecture, and future maintenance cost before implementing. - -## Best Practices Are Required - -Apply best practices by default: explicit data models, stable references, validation at boundaries, clear ownership, cohesive APIs, tests for behavior, and clean separation of concerns. Avoid hidden conventions, magic strings, fragile coupling, and UI flows that rely on memory or perfect spelling. - -## Editor References Must Be Selected - -When editor fields reference another resource, they must use a selector, picker, dialog, browser, or constrained list. Do not require manual typing for references to actors, classes, items, skills, animations, summon cutscenes, maps, events, files, or other resources known to the editor. Manual text is for authored free text, not resource references. - -Persist the selected stable reference after selection. Do not make authors remember or spell resource ids by hand. +# Editor Engineering Rules + +## Binding Git workflow + +Read [GIT_WORKFLOW.md](GIT_WORKFLOW.md) before work, after compaction and before +publishing. On Andrew's workstation also read the original +`/Users/andrewmasiye/.codex/AGENTS.md`. Andrew's develop-to-main rule applies +only where he has created a `develop` branch. This repository has that existing +branch: NEVER commit, merge locally or push directly to `main`, including private +documentation. Changes integrate into `develop`; `main` receives only a PR from +this repository's `develop`. +Repositories found without Andrew-created `develop` are outside this remediation +and must be left alone; do not create that branch or infer direct-main permission. +Remote working branches, history rewrites and branch deletion require explicit +scoped authorization. Within the existing-develop scope, prior direct-main +exception claims are superseded by Andrew's 2026-10-08 rule. +Install the tracked Git guards before committing or publishing here. + +These rules intentionally repeat the repository-level rules so editor work cannot drift into local-only shortcuts. + +## Correctness First + +Always favor correctness, maintainability, and complete design over speed. No shortcuts. No hacky solutions. If a problem deserves a proper model, architecture, state machine, picker, abstraction, or validation layer, build that instead of patching symptoms. + +## Solve Globally By Default + +When fixing a problem, solve the root issue globally rather than making a narrow local workaround, unless Andrew explicitly asks for a local-only change. Look for the broader pattern, existing architecture, and future maintenance cost before implementing. + +## Best Practices Are Required + +Apply best practices by default: explicit data models, stable references, validation at boundaries, clear ownership, cohesive APIs, tests for behavior, and clean separation of concerns. Avoid hidden conventions, magic strings, fragile coupling, and UI flows that rely on memory or perfect spelling. + +## Editor References Must Be Selected + +When editor fields reference another resource, they must use a selector, picker, dialog, browser, or constrained list. Do not require manual typing for references to actors, classes, items, skills, animations, summon cutscenes, maps, events, files, or other resources known to the editor. Manual text is for authored free text, not resource references. + +Persist the selected stable reference after selection. Do not make authors remember or spell resource ids by hand. diff --git a/CHANGELOG.md b/CHANGELOG.md index cd346f58..b74861b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,139 @@ # Changelog +## Unreleased + +### Added + +- Skit backdrops that follow the place, through the Engine's `SkitBackdrop`: a map's `Skit Backdrop` Inspector + row (`skitBackdrop`), a Regions Database page over the Engine's `RegionCatalog` (`assets/Data/regions.php`, one + record per region name with its skit backdrop) and a skit's optional `Backdrop` (`backdrop`), each chosen with + the shared image picker in the Terminal and GUI editors and stored as an asset-root-relative path. The map row + says what its skits show when it names none (its region's backdrop, or none). Validation warns where a skit + would show no backdrop, about a backdrop the game cannot draw and about a region record no map is in. +- Configuration offers how long each battle action category's announcement is read at each animation pace + (`ui.battle.announcement_seconds..`, the Engine's `BattleActionCategory::getAnnouncementConfigPath()`), + with the Engine's time as its default, and refuses a time outside 0.1 s to 10 s. Choosing the default writes + nothing. The summon and effect battle previews read the authored time, as the battle does. +- Place Types, a Database page over the Engine's `PlaceTypeCatalog` (`assets/Data/place-types.php`): the kinds + of place maps lead into, such as town, outpost or shrine. A map chooses its place's type on a `Place Type` row + (`placeType`); a tileset gives each type the piece shown for it under `Place Icons` on its Database > Tilesets + record, chosen among its pieces stamped whole. Validation warns at every entrance whose icon, in its tiles or + terminal glyphs, is not its destination's type's (`place.icon`) or a place icon over a way into a map with no + type (`place.untyped`), and names a map whose type the project does not have (`map.place-type`). A type stays + while a map is of it or a tileset gives it an icon. +- Database > Tilesets authors the Engine's thickets in the GUI editor: a **Thickets** list (id, name, depth up + from the feet, hidden part opacity, rustles, tiles) and a terrain kind's **Thicket**, chosen among the + tileset's thickets (`tileset_thickets`). The terminal Database neither shows nor drops them. +- Skills author the Engine's `reveal` effect: its facts (`Reveals`, picked from the Engine's + `reveal_facts`: `hp`, `mp`, `stats`, `affinities`) and its `Chance %`, shown in the Effects pane as + `Reveal: hp, stats, 75%`. The effect Type list is the Engine's own (`SkillRecord::getEffectTypes()`). +- States author `Confusion Chance %` (`confusionChancePercent`): the chance each turn that the bearer + attacks a random battler of either side instead of acting as it chose. Cleared, it leaves the file. + +- Map review renders: `EditorSession::renderMapReview()` draws a map, or a + rectangle of it, both ways as the game draws it, unsaved edits included, + through the game's own field: the Terminal picture the field's camera draws + through the Engine's Console, and a still of the graphical field the + Engine's frame composer sends a renderer (world tiles, corner-edged terrain, + tile shadows, glyph cells, NPCs, world objects, and the player or an actor + on a cell), drawn by `Review\FieldStillRaster` in the renderer's paint + order. `Review\MapReviewWriter` writes each view as `.ans`, a Terminal + page, a native PNG and an optional scaled PNG, and one review page that + sets every view's Terminal picture beside its still. + `ChangeBatch::preview()` shows a session as a batch leaves it, then undoes + the batch, writing nothing. `PreviewField::openProjectMap()` opens the field + on a map as the Editor holds it, with the player optional. +- Configuration offers the volumes a game starts at: `audio.master_volume` + and, beneath it, `audio.music_volume`, `audio.sfx_volume` and + `audio.voice_volume`, each listed with its Engine default even where + `config.php` leaves it out and refused outside 0 to 100. Players set their + own in the game's Options and Config. +- Dash authoring, as RPG Maker's: Database > System edits the Engine's + `system.dash` (Dash, Dash Speed Multiplier, greater than 1 and at most 4). + The map Inspector's Disable Dashing switch writes the map's `disableDashing` + and removes it again, in the Terminal editor and the GUI session alike. + Configuration offers Always Dash's first value (`field.always_dash`) under a + new `field` root. Validation reports a dash or a map value the Engine would + not read. The GUI session's walk measurement shows dashing seconds at the + authored speed, unsaved edits included, and says why when the player cannot + dash on the map. +- A tileset piece's **Painted Doors** record row (GUI Database), typed as + `2,4; 4,4 front`, and each piece's doors in the pieces list. Glyph-footprint + and physical-recipe authoring keep a piece's doors. +- Database > Tilesets, Terrain Kinds: `Joins` names a corner-edged kind's join + group. Kinds on one tile layer naming the same group share one edge, so a + river runs into a lake or the sea with no bank between them; validation's + see-through ground check reads the joined edges as the game draws them. +- Database > Tilesets, Terrain Kinds: `Corner Sheet` chooses a kind's corner + sheet among the project's PNG assets, so its edges are drawn on the corners + between cells; none draws them in each cell. Validation warns when a map + shows a kind whose corner sheet cannot be drawn, and judges see-through + ground by the corner tiles a corner-edged kind draws on its cells and the + cells beside them. +- Stacked pieces: stamping a piece on one of its tileset's stacks lays each + tile into the cell's stack in depth order (further south, then further east, + in front), refusing a cell that would overfill the stack. Database > + Tilesets authors Stacks. + +### Removed + +- The dialogue catalogue's `skits` key: Dialogue Portraits and Speaker Names refuse a catalogue that still has it + and name `ichiloto upgrade` (format 4), which moves each skit background to its skit's `Backdrop`. + +### Fixed +- `map:render` review pages carry their graphical stills inside the page, so they show when the page is opened or sent on its own; they used to reference the PNG files beside them and showed no pictures elsewhere. + +- A preview field's Terminal picture draws its world objects' Terminal cells, + as the game's field does; cinematic and effect previews left them out. +- A preview's graphical view draws field effects: the effect manager a + graphical view installs learns the window's capabilities, where before it + knew none and drew no effect. +- A preview field's map events are bound to its world state, as the game's + are: an event cue shows in both pictures only while its event is + available, and the Terminal picture draws event cues, which it left out. + Before, the graphical view cued events whose conditions a new game has not + met. +- Save relocations land saves only on floor the player reaches once the change + is saved, and also move saves off floor the change walls off. A wall that + closed off a pocket of floor reachable before the change could send saves into + that pocket, or leave saves standing in it, where they loaded with no way out; + moving a building onto open ground did both with its enclosed interior. + Saves on floor an event now covers that no event covered before, such as a + door moved there, move too, so loading one starts nothing new. + +- World objects keep the footprint their resource stamps (a tree's trunk) in a + map's own declared collision: making, moving, changing or deleting an object + moves its solid cells with it, and a cell it leaves goes back to plain floor + unless another object still stamps it. A footprint may only take plain floor, + as the tree brush's trunks do, so moving a tree no longer leaves an invisible + wall behind or arrives without one. + +- Database > Weapons, Armors: clearing a parameter change (Attack, Defence, + Speed and the rest) removes it from the record instead of writing a zero, so + an accessory made from the armor blank can drop the Defence it started with. + +- A `map:apply` batch or change set with a step that writes a set of files at + once (`skills.retire`, `mastery.adopt`, inserting or removing rows, an + actor's identity freeze) judges only the maps whose files changed. Reading + the project again from those files used to give every map a new revision, + so the batch counted every map as changed and errors already on maps it + never touched refused it. A map read again that holds what it did keeps its + revision, undo and redo of such a step name only the maps it changed, and + an open GUI editing canvas starts again on the map read afresh instead of + drawing the earlier reading. + +- Painting a terrain kind clears a stack by whole object instead of by cell: + an object goes when the paint covers every cell it shows on and otherwise + stays whole, so painting the road beside a range no longer crops the peaks + that overhang it. Painting part of an object no longer removes that part. + +- Converting a map to terrain kinds removes the map's own atlas + classifications of the glyphs it converts and of glyphs that name kinds, + which the kinds now own; before, Last Legend's converted overworld kept + `':' => 'obstacle'` beside the desert kind's `:` and the game refused to + prepare it. Atlas terrain the game cannot read is reported as an error, not + a warning. + ## 0.5.0 Ichiloto Editor 0.5.0 delivers a full-screen terminal workspace for authoring, diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5aa59fe1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,11 @@ +# Claude instructions + +Read `AGENTS.md` and `GIT_WORKFLOW.md` in full before work, after compaction +and before publishing. Andrew's develop-to-main rule applies only where he +has created a `develop` branch. This repository has that existing branch: +no direct-main commits, local merges or pushes; all main changes use a +same-repository `develop` -> `main` PR. Repositories found without that branch +are outside this remediation and must be left alone. That exclusion does not +authorize creating `develop` or publishing directly to `main`. Never infer an +exception from historical messages; retain all explicit branch-publication, +deletion and history-rewrite permission requirements. diff --git a/GIT_WORKFLOW.md b/GIT_WORKFLOW.md new file mode 100644 index 00000000..bc322b43 --- /dev/null +++ b/GIT_WORKFLOW.md @@ -0,0 +1,117 @@ +# Ichiloto Git workflow + +Andrew's develop-to-main rule applies only to repositories in which he has +created a `develop` branch. This repository has that existing branch, so: +**NEVER commit directly to `main`. All changes integrate into `develop`. +`main` changes ONLY through a GitHub pull request from the same repository's +`develop`.** Within that scope, there is no documentation, private-repository +or minor-change exception. + +Repositories found without an Andrew-created `develop` branch are outside this +remediation and must be left alone. Do not create `develop` or infer direct-main +publishing permission from that exclusion. Check Andrew's current scoped +instructions rather than inventing a workflow for those repositories. + +## Authority and preflight + +Read this file and repository `AGENTS.md` at task start, after compaction and +before publishing. On Andrew's workstation, also read the original +`/Users/andrewmasiye/.codex/AGENTS.md`; a summary or handover is insufficient. +Andrew's current direct instructions take precedence. Historical approvals, +agent-written policies and old handoffs cannot grant a standing exception. +Within the scope above, Andrew's 2026-10-08 rule supersedes previous claims +allowing private-documentation direct-main publication. Preserve those records +as history; they do not authorize future publication. + +Record repository, current branch, local/remote heads and dirty state before +writing. Coordinate with the responsible repository task and preserve its +work. Check ownership and the actual source before changing shared mechanisms. + +## Development and publication + +1. Install and verify Git guards with `sh scripts/install-git-guards.sh` before + committing or publishing from a fresh clone. Stop on an unknown existing hook; + preserve it and arrange a reviewed composition. Never bypass a guard. +2. Work on local `develop` or a local working branch. Integrate accepted working + branches into local `develop`, never local `main`. +3. Establish Andrew's scoped authorization, repository, destination ref and action + before each remote mutation. Push an authorized fast-forward from local + `develop` to **existing** remote `develop`. +4. Open a PR from that repository's `develop` to `main`. Do not merge locally into + `main`, fast-forward local `main` from another local branch, or push to `main`. +5. After the PR merges remotely, pull remote `main`, then synchronize `develop`. + Local `main` receives changes only by pulling from remote. + +Andrew's exact branch restriction: "you are not to make any remote feature +branches or push any local feature branches unless I give EXPLICIT PERMISSION." +It covers every remote working branch regardless of prefix, and any missing +remote `develop`. Get explicit repository-and-branch approval before creation. +Generic "commit and push", "merge everything" or "get it done" is insufficient. +The default guards deliberately provide no agent-controlled exception switch. + +Use conventional commits. Do not amend, force-push, delete branches, discard +changes or rewrite history without explicit authorization for that action. +Preserve local working branches until deletion is explicitly approved. Andrew +alone creates/publishes release branches, tags and releases. The flow is +`develop -> main -> release/X.x.x`. + +The same restrictions apply through Git, GitHub APIs, browser interfaces, +plugins, scripts and delegated agents. Never route a blocked action through +another tool, weaken/relocate safeguards or infer permission from silence. +Name removed, disabled or narrowed behavior plainly in commits and reports. + +## Enforcement and limits + +The tracked guards reject commits and merge commits on `main`, commits on +release branches or detached HEAD, and all pushes except a verified fast-forward +from local `develop` to an existing remote `develop`. They also reject branch +creation/deletion, unknown remote history and mixed forbidden destinations. +The installer preserves existing matching hooks and configured `core.hooksPath`. +Run `sh .githooks/test-guards.sh` to exercise real temporary Git operations. + +Hooks do not run for every ref manipulation, fast-forward merge, API or browser +operation. They are a local guard, not a server security boundary. Instructions +remain binding for all of those paths. Every new clone needs installation. + +For participating public repositories with Andrew-created `develop` on the +current Free organization plan, `main` has a separate update restriction with +PR-only merger bypass, plus a no-bypass ruleset +requiring PRs and preventing deletion and history rewrites. Do not weaken them. +A trusted `pull_request_target` workflow validates that main PRs come from the +same repository's `develop`; it never checks out or executes PR code. Its status +must become required after the workflow reaches `main` through its governance +PR. Until then, server protection enforces PR-only updates but does not select +the source branch. Private repositories on this plan cannot enforce branch +protection/rulesets. Their instructions, local guards and advisory checks do +not amount to server enforcement. An organization Team/Enterprise plan is the +remaining server-enforcement prerequisite; no upgrade or visibility change is +implicitly authorized. + +## Shared policy maintenance + +Andrew owns the binding workflow; the original canonical policy and his current +direct instructions define its authority. The portable policy and guard kit are +mirrored identically in participating repositories so standalone clones retain +his rules. Governance changes must audit and update the authorized mirrors +through `develop` and develop-to-main PRs where Andrew has created `develop`. +His develop-to-main rule applies only to those repositories. Repositories found +without that branch are excluded from this remediation at Andrew's direction; +leave them alone, do not create `develop`, and do not treat their default branch +as an integration substitute or infer authority to publish it. GitHub does +not automatically inherit agent instructions, Git hooks or workflows from an +organization `.github` repository. No dependency on publishing such a shared +repository is required. Do not assume a parent-directory policy exists elsewhere. + +## Engineering and handovers + +Andrew's standing rule, 2026-10-02: "FIX THINGS GLOBALLY!!! NO HACKY SOLUTIONS! ADDRESS ROOT CAUSES RATHER THAN PATCHING SYMPTOMS!!!" "THIS SHOULD BE THE WAY YOU WORK ON ALL TASKS GOING FORWARD!" +Before building, establish who owns the data or behavior and why the path +exists. Correct wrong ownership and shared mechanisms instead of adding +instance-specific overrides, fallbacks or defaults. Solve design limitations +or name them as blockers; never scope down to save context or tokens. Include +this rule in full in every agent prompt and handover. + +Handovers include the canonical policy path, exact pending permissions, current +refs, preserved work and next action. Report verified execution, tests, skipped +platforms, PR links and enforcement limits accurately. A handover grants no +additional permission. diff --git a/README.md b/README.md index a7c8c250..50d27356 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ That frame is the editor rendering itself at 100x24 — the manual's copy of it - **NPCs** — first-class map NPCs (`F3`): placement, sprites and directional sprites, fixed or bounded wander, visibility conditions, dialogue with conditional variants, inline scripts, and completion writes. - **Events** — the engine's trigger types placed and configured on the canvas: Dialogue, Transfer Player, Shop, Sleep, Chest, Story Script, and Cinematic. - **Database** (`Ctrl+D` / `F2`) — actors, classes, skills, quests, animations, states, troops, skits, event scripts, terms and system settings authored; items, weapons, armors and enemies browsed with the reason they are read-only stated. -- **Cutscenes** (`F4`) — cinematic and summon cutscenes as paired-file assets: metadata, cast, a nested command tree with parallel lanes, skip policy and finalizer, summon tracks, keyframes and cues — previewed through the engine's own interpreter and playback session before a byte is written. +- **Cutscenes** (`F4`) — cinematic and summon cutscenes as paired-file assets: metadata, cast, a nested command tree with parallel lanes, skip policy and finalizer, summon tracks, keyframes and cues, and standalone effect timelines under `assets/Animations` — previewed through the engine's own interpreter and playback session before a byte is written. - **Safety** — everything is undoable and identity-pinned; dirty state is tracked by content, so undoing back to the saved state is clean again; saves are atomic and pair-aware; references are picked from what the project actually has, never spelled from memory. - **Validation** — one `ProjectValidator` behind the editor and `ichiloto validate`, judging content by what the engine will actually do with it. - **Playtest** (`Ctrl+T`) — the real game, launched from the cursor against a throwaway overlay, so a playtest can never write into the project. @@ -95,6 +95,11 @@ Editor development currently tracks Engine `dev-develop` for the shared cinematic route-validation API. `composer.lock` pins the published Engine revision used by local tests and CI. +`dev-develop` is for unreleased integration. Before publishing an Editor +release, require a compatible released Engine version, refresh the lockfile, +and verify a clean Console installation with Composer's default stable +dependency settings. + Install and verify from the `editor` repo: ```bash @@ -107,9 +112,23 @@ Set `ICHILOTO_GAME_SRC=/path/to/game` to include a game project in the production-verification tests. `docs/manual.md` is covered by `tests/Unit/ManualCoverageTest.php`; registered keybindings must be documented. +For layered-map integration tests against an unpublished sibling Engine, +set `ICHILOTO_ENGINE_SRC=/path/to/engine`. The test bootstrap prepends that +checkout's source directory, including in isolated data-evaluation children; +it does not modify `vendor`, dependency versions or the lockfile. +Use `vendor/bin/phpstan analyse -c tests/phpstan-engine.php +--autoload-file=tests/bootstrap.php` with the same environment to analyse +against that checkout instead of the installed Engine sources. + ## Project Links - Engine repository: [github.com/ichiloto/engine](https://github.com/ichiloto/engine) - Console repository: [github.com/ichiloto/console](https://github.com/ichiloto/console) - Website repository: [github.com/ichiloto/website-v2](https://github.com/ichiloto/website-v2) - Editor issues: [github.com/ichiloto/editor/issues](https://github.com/ichiloto/editor/issues) + +## Contributing and Git workflow + +Read [GIT_WORKFLOW.md](GIT_WORKFLOW.md) and install the Git guards with +`sh scripts/install-git-guards.sh` before contributing. All changes integrate +into `develop`; `main` is updated only by a PR from this repository's `develop`. diff --git a/composer.lock b/composer.lock index 7ed6b5d9..e74068e6 100644 --- a/composer.lock +++ b/composer.lock @@ -260,12 +260,12 @@ "source": { "type": "git", "url": "https://github.com/ichiloto/engine.git", - "reference": "2161b83d8ac9ef1a7153dc0d5001994a3caff551" + "reference": "799203d609fc6f22651db5816946e8987afe646c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ichiloto/engine/zipball/2161b83d8ac9ef1a7153dc0d5001994a3caff551", - "reference": "2161b83d8ac9ef1a7153dc0d5001994a3caff551", + "url": "https://api.github.com/repos/ichiloto/engine/zipball/799203d609fc6f22651db5816946e8987afe646c", + "reference": "799203d609fc6f22651db5816946e8987afe646c", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "issues": "https://github.com/ichiloto/engine/issues", "source": "https://github.com/ichiloto/engine/tree/develop" }, - "time": "2026-09-22T13:49:38+00:00" + "time": "2026-10-19T15:55:46+00:00" }, { "name": "league/climate", diff --git a/docs/guides/README.md b/docs/guides/README.md index fdaece8c..d047b44a 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -19,6 +19,16 @@ Read these in order the first time: through the engine from the editor. 7. [Author a Summon Cutscene](author-a-summon-cutscene.md) — a frame-driven battle presentation: tracks, keyframes, cues, and the actor assignment. +8. [Make a Building](make-a-building.md): bring a building's art into a + tileset, define its piece, place it with its collision and give its door an + entrance, in the GUI editor. + +## For Agents And Scripts + +- [Change Maps From A Script](change-maps-from-a-script.md): every map change + an agent or script makes goes through the editor session, as a batch with + `ichiloto map:apply` or over `ichiloto edit:host`, and is written only when + the project it leaves is valid. ## What The Editor Can Do Today @@ -29,8 +39,9 @@ Read these in order the first time: scripts, and completion writes. - Place and configure the event types: Dialogue, Transfer Player, Shop, Sleep, Chest, Story Script, and Cinematic. -- Author cinematic and summon cutscenes on the Cutscenes screen (`F4`), and - preview both through the engine itself without writing a file. +- Author cinematic and summon cutscenes and standalone effect timelines on + the Cutscenes screen (`F4`), and preview each through the engine itself + without writing a file. - Author actors, classes, skills, quests, animations, states, troops, skits, event scripts, terms, and system settings. - Browse items, weapons, armors, and enemies. diff --git a/docs/guides/add-an-npc.md b/docs/guides/add-an-npc.md index e27a20e2..7b467522 100644 --- a/docs/guides/add-an-npc.md +++ b/docs/guides/add-an-npc.md @@ -175,10 +175,13 @@ or a fixture. 4. Edit the dialogue rows: each line has a speaker `name` and its `text`. Leave the name empty for narration with no title. -The five event types are `Dialogue`, `Transfer Player`, `Shop`, `Sleep`, and -`Chest`. A `Transfer Player` event links two maps: after choosing the type, -`Enter` on `Destination` jumps to the target map to pick the arrival tile in -context, and returns you afterwards; `Ctrl+G` follows the link any time and +The seven event types are `Story Script`, `Cinematic`, `Dialogue`, +`Transfer Player`, `Shop`, `Sleep`, and `Chest`. `Story Script` runs a Common +Event ([Author a Cutscene](author-a-cutscene.md)) and `Cinematic` launches a +cinematic ([Author a Cinematic Cutscene](author-a-cinematic-cutscene.md)). +A `Transfer Player` event links two maps: after choosing the type, `Enter` on +`Destination` jumps to the target map to pick the arrival tile in context, and +returns you afterwards; `Ctrl+G` follows the link any time and `Ctrl+B` comes back. Painting a glyph or a marker is an ordinary canvas edit, so `Ctrl+Z` removes it diff --git a/docs/guides/author-a-cutscene.md b/docs/guides/author-a-cutscene.md index 3c2d108b..794c4b1b 100644 --- a/docs/guides/author-a-cutscene.md +++ b/docs/guides/author-a-cutscene.md @@ -50,12 +50,12 @@ The full vocabulary: | Type | Fields | Notes | | --- | --- | --- | | `text` | Speaker, Text | An empty speaker means narration | -| `choice` | Prompt, Title, Options | Options are fixed here; see below | +| `choice` | Prompt, Title, Options | Each option's commands open as a frame; see below | | `wait` | Seconds | Accepts fractions | | `set_switch` | Switch, Value | | | `set_variable` | Variable, Operation, Value | Operation is `set` or `add` | | `record_event` | Story Event | What conditions test with `event:` | -| `give_item` | Item, Quantity | Item must exist in `items.php` | +| `give_item` | Item, Quantity | Item must be an item, weapon or armor the project defines | | `give_gold` | Amount | Negative debits | | `recover_party` | none | Fully restores travelling members and clears battle-only states | | `play_sound` | Sound | | @@ -92,14 +92,13 @@ being skipped. A `branch` command's `Conditions` row uses the shared condition grammar documented in [Wire a Quest](wire-a-quest.md). All conditions must hold. -Current limit: the `Then` and `Else` arms are shown as fixed rows and edited by -hand in the file. The same applies to a `choice` command's `Options`. Both -round-trip untouched when you save from the editor, so it is safe to author the -outer script here and the nested arms in your editor of choice. - -The reason is deliberate: flattening a command *tree* into one settings pane -would be unreadable, and dropping the tree on save would be worse than not -offering the edit. +The `Then` and `Else` arms, and each option of a `choice`, are edited as +frames rather than flattened into the outer list. A `branch` shows `Then +Commands` and `Else Commands` rows, and a `choice` shows each option as a text +row and a commands row. `Enter` on a commands row opens that arm's own command +list, with the same rows and keys as the top level, at any depth. `Esc` steps +back out one frame at a time. The manual's +[Command Frames](../manual.md#command-frames) section has the details. ## Continue After A Transfer Or Battle @@ -181,8 +180,7 @@ reports missing scripts, duplicate NPC IDs, missing route targets where map context is known, malformed route steps/timing, unknown commands, bad result variables, and invalid defeat policies. -Current limits: the editor preserves but does not structurally edit nested -choice/branch trees. Cutscene skipping and finalizers, camera and fade +Current limits: cutscene skipping and finalizers, camera and fade commands, field-animation commands, parallel routes, patrol routes, pathfinding, and full NPC creation/placement remain deferred. diff --git a/docs/guides/change-maps-from-a-script.md b/docs/guides/change-maps-from-a-script.md new file mode 100644 index 00000000..d343b114 --- /dev/null +++ b/docs/guides/change-maps-from-a-script.md @@ -0,0 +1,511 @@ +# Change Maps From A Script + +This guide is for agents and scripts that change a project's maps without a +window. Every map change goes through the Editor, the same source-preserving +session the GUI editor uses, so it is written only when the project it leaves +is valid. Never edit map files (`*.data.php`, `*.event.php`, `layers/`, +`graphics/`) by hand: that path skips every check below, and it is how raw +glyphs, events left behind by moved furniture and chests with no reward +reached the game. + +## The Two Commands + +```bash +ichiloto map:apply changes.json --directory path/to/game +``` + +Applies a batch of changes as one change set. It writes them only when every +map they change has no errors and nothing new is broken anywhere else. +Otherwise it writes nothing, undoes what it did, and prints each problem with +its map, cells and fix. It exits `0` once written, `1` when refused. + +| Option | Does | +| --- | --- | +| `--check` | Reports what writing would find, and writes nothing. | +| `--relocate-saves` | Lets saves that may stand on cells the change makes solid move to open floor, as an author confirms in the editors. Without it such a change is refused. | +| `--json` | Prints the outcome as JSON: `status`, `maps`, `saved`, `issues`, `blocking`, `relocated`, `reason`. | + +```bash +ichiloto validate --directory path/to/game --map happyville/bsa-annex --map happyville/home +``` + +Reports only what is wrong on the named maps and exits `1` on any of their +errors. Use it to gate a commit on the maps it touched. Without `--map` it +checks the whole project. + +## Rendering Maps For Review + +A map or building review shows both presentations: what the Terminal field +draws and what the graphical field draws. `map:render` makes both from the +project, the way the game draws them, so a review never needs a scratch +renderer. + +```bash +ichiloto map:render garden-of-roads/lanternrest-wayhouse-yard happyville/town-center@24,17,20,10 \ + --directory path/to/game --out review --scale 0.5 +``` + +Each argument is a view: a map id, or `MAP@X,Y,WIDTH,HEIGHT` for a rectangle +of it in cells. For every view it writes, into `--out`: + +| File | Holds | +| --- | --- | +| `.terminal.ans` | The Terminal field's characters and colours, one column per cell, as ANSI text. `cat` it in a terminal. | +| `.terminal.html` | The same picture as a page of its own: dark background, every colour kept. | +| `.png` | The graphical field at native size, one field cell 48 pixels square. | +| `@x.png` | The graphical field scaled down, with `--scale`. | + +and one `review.html` that sets every view's Terminal picture beside its +graphical still, with the cells it shows and anything it could not draw. +`` is the map id with `/` written `--`, and the region when there is +one. + +| Option | Does | +| --- | --- | +| `--player X,Y[,HEADING]` | Stands the player on a cell of every view, facing `north`, `east`, `south` (the default) or `west`. Use it to check that a door's art meets the cell the player enters by. | +| `--actor ID` | Shows that actor's field art where `--player` stands, instead of the player's own. The Terminal keeps the player's sprite. | +| `--views FILE` | Reads views from JSON, each with its own region, subject and label: `{"views": [{"map": "...", "region": "X,Y,W,H", "player": "X,Y,north", "actor": "...", "label": "..."}]}`. | +| `--changes BATCH` | Renders the project as a batch would leave it, before it is applied. The batch is the one `map:apply` takes; nothing is written to the project. | +| `--scale S` | Also writes each graphical still at scale `S` (above 0, below 1), such as `0.5` or `0.25`, for an overview. | +| `--no-native` | Writes only the scaled still, for a map too large to draw at native size. | +| `--font PATH` | The monospaced font glyph cells are drawn in on the graphical still. Without it the command uses the graphical renderer's own field font (Menlo on macOS) or another monospaced system font. | +| `--title TEXT` | The review page's title. | +| `--json` | Prints what was written as JSON: `status`, `review`, and each view's `files` and `diagnostics`. | + +Both pictures come from the game's own field, opened on the map as the Editor +holds it: its NPCs and world objects stand where a new game shows them, at +rest. The Terminal picture is what the field's camera draws through the +Engine's Console. The graphical still is the world, tiles and sprites the +Engine's frame composer sends a graphical renderer, drawn by the Editor in the +renderer's paint order: tiles composed from their sheet pieces, corner-edged +terrain, tile shadows, glyph cells no covering tile hides, and sprites on +their cells' ground. It is a still of what the graphical field is sent, not a +screenshot of a window. Sign labels need a renderer to lay out their text, +so a sign shows its glyphs, and motion and animation are drawn at rest. Art +that cannot be read is named under the view, and its cells keep their +glyphs. + +## What Counts As An Error + +An error is a defect the player would meet. The editors still save an +author's map with errors as a draft and show them; validation and +`map:apply` fail until they are fixed. + +The map rules are the Engine's own (`MapCompletenessValidator`), the checks +the game itself relies on; the Editor keeps no copy. Each finding carries the +rule as its `code`: + +- `graphics.glyph`: a gameplay glyph with no graphical presentation, shown + raw in the field. `graphics.missing`: a map with no kind in a project with + tilesets. +- `npc.art`, `object.art`, `object.reference`: an NPC or world object without + usable field art, or a world object resource that does not resolve. +- `event.unmapped`, `event.area`, `event.subject`, `event.definition`, + `event.grid`: a marker with no definition, an event with no cells and no + subject, a subject that is missing or competes with painted cells, a + malformed event, an event layer that does not match the map. +- `chest.reward`: a chest whose reward the Engine cannot give, or that has + none chosen. +- `event.speaker`: an event's speaker who is not an NPC on the map, is never + shown while the event can run, wanders freely or stands more than one cell + from the event's area. +- `object.doorway`: a world object whose resource paints a doorway with no + entrance (transfer event) under it, or an entrance inside an object's + covered cells behind its ground row, where the art hides the player. + +The Editor's project checks add the rest `ichiloto validate` reports, also as +errors: floor the player can reach with no tile, an event the player can +never reach, an arrival on a cell they cannot stand on, a door or edge to a +map that does not exist, a spawn point outside its map, unknown references, +invalid scripts, and a door, edge or scripted transport whose destination +map's role is not one its own map's role leads to (`map.role-destination`; +see Map Roles in the manual), or a map naming a role the project does not have +(`map.role`). + +Warnings never refuse a change: tiles a piece draws whose glyph is gone, the +missing-art placeholder, see-through ground, a map nothing reaches yet, an +entrance whose icon is not its destination's place type's (`place.icon`) or a +place icon over a way into a map naming no place type (`place.untyped`). A map +naming a place type the project does not have is an error (`map.place-type`). + +A change to a map that already has an error is refused until that error is +fixed too. Put the fix in the same batch. + +## The Batch + +A batch is a JSON list of editor session requests, or `{"changes": [...]}`: + +```json +{"changes": [ + {"method": "event.move", "params": {"map": "happyville/bsa-annex", "marker": "F", "dx": 8, "dy": 0}} +]} +``` + +Each change is a request of the session protocol: the GUI editor's own +requests, listed in `src/Session/SessionHost.php`. Two things are filled in +for you: + +- **Revisions.** A change naming a `map` without a `revision` edits that map + as it stands at that step. Give a `revision` only when the batch must refuse + a map that changed. +- **Questions.** Some requests ask before they act (inserting rows reviews + the coordinates they shift). Give the change an `"answer"` beside `params`; + the batch confirms what the question showed. A question without an answer + refuses the batch. +- **Pieces.** When several pieces draw a glyph you paint, the batch is + refused with their role keys. Answer as the editors do, in the change's + params: `"choices": {"x": "crate:0:0"}`, or `null` for no tiles. + +Edits are made in order and held in memory, and every later change reads +them: a field resource, tileset piece, place category, skill, effect timeline, +database record or world object made earlier in the batch is usable by the +changes after it, before anything is written. Painting, piece lists, pickers, +the map world the GUI draws and the validation `changes.commit` runs all read +these current records. Battle previews and the cinematic preview field still +read the saved items, enemies, summons and maps until the Engine can take them +from the Editor; commit or save first when one of those must show an edit. +Inserting or removing rows, and creating or duplicating a map, write their +files when they run; a refused batch undoes them. Inserting or removing rows +needs no pending edits, so put it first, or in its own batch. + +A step that writes a set of files at once (inserting or removing rows, +`skills.retire`, `mastery.adopt`, an actor's identity freeze) reads the +project again from them. A map whose files the step did not change keeps its +revision and is not one of the maps the batch changed, so errors already on +maps the batch never touches do not refuse it. + +The batch alone writes. When it is valid, `changes.commit` saves everything +it changed, databases (field resources, common events and the rest), +cutscenes, maps and the save relocations its maps need, as one transaction. +Every file is worked out and staged first, and each document proves its +staged file reads back as what it holds. Only then is any file installed, +and a file that cannot be installed puts back the ones already installed. +So the batch is written whole or not at all. A document that refuses here +refuses the batch as an error does: nothing is written, the batch is undone, +and `reason` starts with `Nothing was written:` and names the document. A +saved result's `written` names each document written and the files it wrote +or removed. `database.save`, `map.save` and `project.saveAll` are refused +inside a batch; leave them out. Moving or deleting a map is refused too, since it clears the +undo history a refused batch relies on. Deleting a database record inside a +batch removes its file only when the batch is written. + +## Finding Ids And Keys + +Read before you write. Over the protocol (see Driving The Session Directly) +or with the GUI editor open, these reads give every id a change needs: + +| Read | Gives | +| --- | --- | +| `maps.list` | Map ids and revisions. | +| `map.read {map}` | Layers (`id`, `name`), `baseLayer`, events, NPCs and world objects, unsaved changes included. | +| `inspector.read {map, event?}` | The map's rows, and an event's rows when `event` names its marker. Each row's `key` goes back unchanged to `inspector.apply`, `inspector.add` or `inspector.remove`. | +| `worldObjects.read {map, id}` | A world object's rows, for `worldObjects.apply`. | +| `npc.read {map, index, frame?}` | An NPC's rows, for `npc.apply`. | +| `pieces.list {map}` | The pieces the map's tileset can stamp. | +| `kinds.list {map}` | The terrain kinds of the map's tileset, and whether the map is painted in them (`painted`, with an `issue` saying how to convert it when not). | +| `kinds.at {map, x, y}` | The kind a cell shows in both its glyph and its tiles, or null. | +| `kinds.previewConversion {map, mapping?}` | What converting the map would do: each mapped glyph's cells, the glyphs left, those that already name a kind (`reread`) and the collision changes. Without a mapping it lists the map's glyphs. | +| `event.types` | Event type labels for `event.create`. | +| `references.list {map, category}` | The values a reference row accepts, such as `inventory` ids. | +| `map.measure {map, points}` | The shortest walk through `[x, y]` points in order (a start, any waypoints, an end), and its steps, seconds and expected encounters. See Measuring A Walk. | + +Layers are named by `id` in edits. An event row key looks like +`{"target": "event", "marker": "C", "path": ["data", "loot"]}`. + +## Condition And Write Lines + +A conditions or writes row takes one line. Entries are separated by `;`, +never `&` or a comma, and each entry is `type:name` plus the parts its type +takes: + +| Conditions | Writes | +| --- | --- | +| `[!]quest:id[:completed\|active]` | `quest:id[:confirm\|grant]` | +| `[!]switch:name[:true\|false]` | `switch:name[:true\|false]` | +| `[!]event:name` | `event:name` | +| `[!]map_event:map:marker` | `variable:name[:set\|add][:value]` | +| `[!]variable:name[:op[:value]]`, op one of `==` `!=` `>` `>=` `<` `<=` | | +| `[!]item:id[:quantity]`, `[!]key_item:id[:quantity]` | | + +A leading `!` negates a condition. A name or value that holds `:`, `;` or +`\` escapes it with a backslash: the event flag `deckers:won` is written +`event:deckers\:won` (in JSON, `"event:deckers\\:won"`). A line is read only +as written: a part its type does not take, an unknown type or a value the +Engine would refuse makes the row refuse the whole line, and nothing changes. +`event:a & !event:b` is refused, because the event condition takes nothing +after its name; write `event:a; !event:b`. + +## Named Areas + +A map's `areas` name territory it owns, with no art, passage or discovery of +their own. An entry is `['id' => ..., 'area' => ...]`, the area either exact +`cells` (`['cells' => [[x, y], ...]]`) or a rectangle (`x`, `y`, `width`, +`height`); the Engine reads every edit against the map's physical grid and +refuses a duplicate or malformed id, an empty area, a repeated cell or a cell +off the map, naming the area. `areas.create` and `areas.setArea` take the +area as written (`area`), or selected `cells`, which are written as a +rectangle when they fill one. An event bound with +`{kind: area, id}` covers the area's cells and follows its reshaping; a +landmark anchors to it with `area:`. `areas.rename` rewrites the events +and landmarks naming the area in the same undo step; `areas.delete` is +refused while one names it, listing them. All of these work inside a batch, +so an area made earlier in the batch can be bound and anchored to before +anything is written. Row and column inserts move areas with the lines; a +removal through one of an area's exact cells is refused until it is reshaped. + +## Measuring A Walk + +`map.measure {map, points}` measures how far a player walks, on request, and +changes nothing. `points` lists two to 64 `[x, y]` cells: the start, any +waypoints, then the end. The answer holds the whole walk as `path` (every cell, +start included), `steps` with the steps `across` and `down`, `walkSeconds` +(each step lasting the field's walk, `stepSeconds`), `screens` (steps across +over the field view's columns plus steps down over its rows, for the GPUI field +at the game's default screen and the project's field zoom, named in `view`), +and one entry per leg in `legs`. Where the map has encounters, `encounters` +gives the map's `rate`, the steps the walk counts toward an encounter +(`countedSteps`, a danger tile counting twice on a map that counts every +tile), the encounters `expected` on the way (counted steps over the rate, +with no repel or lure assumed) and the `troops` that can be met along it; +without encounters it is null. `dashSeconds` is null, with `dashUnavailable` +saying why, while the game authors no dash speed. + +The walk is the game's: the Engine's movement over the map as the session +holds it, unsaved edits included, so walls, fixed NPCs and cells that take the +player off the map stand where the game puts them. Of the equally short walks +it keeps its heading wherever it can. When a point cannot be stood on, a +waypoint would leave the map, or no walk joins two points, `reachable` is +false, `problem` says which, and `path` and `legs` are empty. A map the game +could not load, such as one with an event marker that has no definition, is +refused with the reason. + +## Save Migrations + +`saveCompatibility.appendStep {class}` registers the project's own save +migration class as the next step of `assets/Data/save-compatibility.php`: +`contentVersion` rises by exactly one, and the step is +`['from' => N, 'to' => N + 1, 'class' => ...]`. The class must load through +the project's own `vendor/autoload.php` and implement the Engine's +`ContentMigrationInterface`; the staged manifest is read back as the game +reads it, so a class that does not load or is not a migration is refused +with nothing written, as is a manifest the source-preserving writer cannot +rewrite. It is one undo step, and inside a batch it is undone with the batch. + +`saveCompatibility.retireMap {map, into, to, landings?}` retires a map whose +places now stand on another map. It appends one step to +`assets/Data/save-compatibility.php` with one `mapRetirements` entry and +raises `contentVersion` by one. A save standing on `map` then loads onto +`into`: at the `to` of the first landing rectangle that holds its cell, or at +the entry's own `to`. Each landing is `{x, y, width, height, to, map?}`: a +rectangle in `map`'s coordinates and the cell its saves land on, on `into` +or, when that part of `map`'s places was split off onto another map, on the +successor its `map` names. For example: + +```json +{"method": "saveCompatibility.retireMap", "params": {"map": "overworld", "into": "continent", + "landings": [{"x": 0, "y": 0, "width": 60, "height": 61, "to": [12, 40]}], "to": [30, 30]}} +``` + +A region split across maps lands each part on its own successor: + +```json +{"method": "saveCompatibility.retireMap", "params": {"map": "garden", "into": "continent", + "landings": [{"x": 140, "y": 0, "width": 81, "height": 41, "map": "high-reach", "to": [2, 22]}], "to": [142, 128]}} +``` + +Every landing must be a cell a save can stand on in the map it lands on as +the session holds it, unsaved edits included: plain floor the player can +reach, not grass, an exit or a save point, with no event or NPC on it. The +request is refused with nothing written when a landing is not, naming each +map, cell and why, when `into` or a landing's `map` is not a map, when a +landing's `map` is the retired map (unless it retires into itself), or when +the entry is malformed. Committing a change set judges the landings again on +each map as the change set leaves it. + +A map redrawn under its own ID retires into itself: `map` and `into` are the +same, the rectangles are in its old layout's coordinates and the landings are +judged on the new layout the session holds. Every save made on the old layout +moves. +`map` may still exist: retire it first, then delete it with `map.delete` +outside the batch. A save that visited `map` has visited `into` too; only +`into`, which takes over the retired identity, inherits the visit. The +retirement adds no map alias, since an alias would make the two maps one and +read every completed one-shot event `map:marker` as `into`'s own marker. + +`saveCompatibility.alias {category, from, to}` adds one alias, so every save +reads the saved identity `from` as `to`. `category` is a saved-content +category such as `maps`, `one_shot_events` or `quests`. A one-shot event that +moved to another map keeps its completion this way: + +```json +{"method": "saveCompatibility.alias", "params": {"category": "one_shot_events", + "from": "overworld/garden-of-roads:Q", "to": "garden-of-roads/high-shadow-reach:Q"}} +``` + +`to`, followed through the aliases already declared, must exist in its +category where the category has a catalogue, such as a map's event marker +for `one_shot_events`. An alias the game would refuse (a self-alias, a cycle, +a source already sent elsewhere) is refused with nothing written, and an +alias the manifest already holds gives `added: false`. + +Both are one undo step, staged and read back as the game reads the manifest, +and inside a batch they are undone with the batch. `changes.commit` checks a +retirement written in its batch again: each landing, moved by the steps +written after it (rows inserted in the same batch move it), must still be a +cell a save can stand on in the maps the batch leaves, or the batch is +refused. Relocations `changes.commit` writes for the batch's maps go in a +later step, so they move landed saves as they move any other save. + +The Engine reads a step as a migration class or as declared position edits, +never both. So a batch that registers a class and also changes a map in a +way that moves saves (the relocations `changes.commit` reports) is refused +whole, saying so; register the migration and change the map in separate +batches. + +## Which Request Makes Which Change + +| Change | Request | +| --- | --- | +| Paint glyphs | `map.paint {map, layer, cells, symbol, color?}`; a whole block of rows at once with `map.paintRegion {map, layer, x, y, rows}` | +| Draw the tiles a layer's glyphs need | `layer.drawTiles {map, layer}` | +| Paint or stamp tiles | `tiles.paint`, `tiles.stamp` | +| Stamp a tileset piece | `pieces.place {map, piece, from, to, color?}`; `{map, piece, origins}` stamps one copy at each listed top-left `[x, y]` in one undo step, such as the peaks of a range | +| Paint a terrain kind | `kinds.paint {map, kind, from, to}` on a map painted in kinds; its glyph, its tiles and, under declared occupancy, its collision. `{map, kind, cells}` paints listed `[x, y]` cells instead, for terrain no rectangle fits | +| Convert a map to terrain kinds | `kinds.convert {map, mapping}`, such as `{";": "grassland", "~": "water"}`; or `ichiloto map:convert-kinds` | +| Layers | `layer.create`, `layer.rename`, `layer.remove`, `layer.reorder`, `layer.decoration`; tile layers `tileLayer.*` | +| Collision | `map.paintOccupancy`, `occupancy.stampPiece` | +| Resize | `map.insertLines`, `map.removeLines` (with an `answer`), or the map's size rows | +| Events | `event.create {map, cells, type, marker?}`, `event.move {map, marker, dx, dy}`, `event.bounds`, `event.destination`, `event.delete`; fields with `inspector.apply` | +| Name who speaks an event | `inspector.add {map, key: {target: event-speakers, marker}}` names the nearest NPC not yet listed; `inspector.apply` with `{target: event-speakers, marker, field: speaker, index}` and an NPC id chooses another; `inspector.remove` with that key removes it | +| A resource's doorway | the field resource's Doorway list: `database.add` on its heading (`child: true`), then `database.apply` its `doorwayX` / `doorwayY` rows | +| Bind an event to the object or area it describes | `event.subject {map, marker, subject}`, `subject` as `{kind: world_object, id}` or `{kind: area, id}` (a bare id names a world object); `subject: null` with `cells` unbinds it | +| Named areas | `areas.read {map}`; `areas.create {map, id?, area or cells}` (no `id` takes the first free `area-N`); `areas.setArea {map, id, area or cells}`; `areas.rename {map, id, to}`; `areas.delete {map, id}` | +| A landmark anchored to an area | the landmark's anchor row (`inspector.apply` with `{target: map-landmarks, field: anchor, index}`), value `area:` | +| An event's conditions, writes, blocked message, cue conditions | `inspector.apply` with `{target: event-record, marker, field, frame: []}`, `field` one of `conditions`, `sets`, `whenBlocked`, `cue.conditions`; the value is a condition or write line (`conditions.grammar`), empty removes it | +| A Story Script event's inline commands | `inspector.read {map, event, frame: ["script"]}`, then `inspector.apply`/`add`/`remove` with the frame's row keys; the first command goes on the `frameEmpty` row | +| Dialogue pages with conditions and writes, talk-to id | the event's `variantList`, `variantNConditions`, `variantNSets`, `variantNLineM...` rows; the Talk-To Id row sets `npcId` | +| Where a transfer arrives facing | the Spawn Facing row, or `event.destination {..., heading}` | +| A route waypoint or staged actor placed relative to a subject | the waypoint's or actor's Relative To row (player, npc, world_object, staged_actor, party_actor, marker), then its id row and Offset X / Offset Y rows: `database.apply` in a cinematic (`database.record {category: "cutscenes/cinematic", index, frame: ["commands"]}`), `npc.apply` in an NPC script, `inspector.apply` in an event script. A relative point follows its subject and is not shifted by row or column inserts | +| Which way a waypoint or staged actor faces once there | the same entry's Facing row (north, east, south, west), or its Face Toward row (the same subject kinds) and then its Face Toward id row; choosing one clears the other | +| A map's region-map cell | `inspector.apply` with `{target: map-station, field: station}`, value `x, y`, empty removes it | +| World objects | `worldObjects.create`, `worldObjects.move {map, id, dx, dy}`, `worldObjects.place`, `worldObjects.apply`, `worldObjects.add`, `worldObjects.remove`, `worldObjects.delete` | +| NPCs | `npc.create`, `npc.move`, `npc.apply`, `npc.add`, `npc.reorder`, `npc.remove`, `npc.duplicate`, `npc.delete` | +| Move a character's own skills onto their Mastery Map | `mastery.adopt {actor}` (asks; answer `write` with its `confirm`): each skill the actor file says they know gets terms known from the start (cost 0, level 1), each learnable terms costing its EXP requirement, for the tree node that teaches it (a skill the tree does not place yet joins the first region's trunk), skills their role teaches stay with the role, and the actor's learned lists and learnables are emptied; then edit the terms in the Mastery Maps category | +| Place or link Mastery Map nodes | `mastery.page {index}` reads the regions and each node the map gives terms for, with the tree's `name`, `skill`, `region`, `branch` and `kind` and its place (`x`, `y` as fractions of the map; `placed` false where the Engine places it in its region); `mastery.placeNode {index, node, x, y}` places a node; `mastery.toggleRequirement {index, node, requires}` makes `node` build on `requires`, or stops it, refusing a loop. Each is one undo step | +| Retire a skill | `skills.retire {index, replacement?}` (asks; answer `write` with its `confirm`): every class grant, actor learned skill and learnable, attack and counter skill, enemy action and skill or state counter that names it moves onto the replacement (a list already holding the replacement drops the old entry) and saves read the old skill as the replacement; without a replacement each is removed and saves drop the skill. A replacement must be a spell for a spell and an ability for an ability, and a summon's action follows its summon. Then `database.delete` the skill, which is refused while anything still names it | +| Retire a map onto the map that took its places | `saveCompatibility.retireMap {map, into, to, landings?}`, then `map.delete {map}` outside the batch; see Save Migrations | +| Let saves read a moved identity as its new one | `saveCompatibility.alias {category, from, to}`, such as a one-shot event `old-map:Q` to `new-map:Q` | +| Move a story event onto the NPC who delivers it | `npc.adoptEvent {map, marker, npc?}`: the event becomes the NPC's first page (its conditions, writes, cue, action and run-once), its cells are cleared, a run-once event's saved completion is aliased to the page, and the result lists what was dropped and the quest waymarks to repoint at the NPC (`database.apply` on the waymark's NPC row) | +| Map fields (name, kind, music, encounters, atlas, landmarks) | `inspector.apply` with the map row's key | +| A map's role | `inspector.apply` with `{target: map-role, field: role}`, value a role id from the Map Roles page (`database.create {category: map_roles}`), empty removes it | +| A map's place type | `inspector.apply` with `{target: map-place-type, field: placeType}`, value a type id from the Place Types page (`database.create {category: place_types}`), empty removes it | +| New map | `map.create {name, kind, width, height, id?}`; `id` is its folder, such as `town/inn/cellar` | +| Copy or move a map | `map.duplicate {map, to?}`; `map.move {map, to}` asks first, then `{map, to, destination, confirm: true}` | +| Landmark id | `inspector.apply` with `{target: map-landmarks, index, field: id}`; other maps' atlas parents follow the rename | +| NPC id | `npc.create {..., id?}`, `npc.assignId {..., id?}`, or the NPC's Id row with `npc.apply` | + +Two NPCs may anchor on one tile only as phases the game never shows +together: one has a visibility condition (its Visible When row) that the +other negates, such as `!event:evidence` and `event:evidence`, or +`switch:door` and `switch:door:false`. Variable `==` and `!=` against the +same value count too. Nothing else does, however disjoint it looks: quest +statuses, variable ranges and item counts can hold together. So give the +phases their conditions first, then move one onto the other's tile. A move, +create or conditions edit that would leave two NPCs visible on one tile at +once is refused with nothing changed, and validation reports such a tile. +In the terminal editor, Enter on that tile steps through its phases. + +`worldObjects.move` moves an object with what shows it in the terminal: its +anchor, the cells it covers and their glyphs, with the tiles those glyphs +draw. Moving only the anchor (`worldObjects.place`) leaves its glyphs behind. +On a map that declares its own collision, the footprint the object's resource +stamps (a tree's trunk) follows the object whenever it is made, moved, placed +or deleted: the cells it leaves go back to plain floor unless another object +still stamps them, and the cells it takes must be plain floor, or the change +is refused. + +## Worked Example: Bind An Event To Its Object, Then Move Them + +An inspection event that describes lockers is bound to them once. From then +on its area is the lockers' covered cells, so the event can never be left +behind when they move: + +```json +{"changes": [ + {"method": "event.subject", "params": {"map": "town/annex", "marker": "F", "subject": "annex-lockers"}}, + {"method": "inspector.apply", "params": {"map": "town/annex", + "key": {"target": "event", "marker": "F", "path": ["engagement"]}, "value": "facing"}}, + {"method": "worldObjects.move", "params": {"map": "town/annex", "id": "annex-lockers", "dx": 2, "dy": 0}} +]} +``` + +Binding clears the event's painted cells. A bound event cannot be moved or +reshaped by itself, and its object cannot be deleted while the event names +it. Run the batch with `--check` first: a move onto another glyph or another +object's cells is refused and names the cell, and moving furniture onto open +floor usually needs `--relocate-saves`. + +## Worked Example: Stamp A Piece + +Read `pieces.list` for the piece's id, then stamp it with its top-left at +`from`. With `to` on the same cell it stamps one copy; a larger area stamps as +many whole copies as fit, and a connected piece (a wall) is drawn along the +area's outline: + +```json +{"changes": [ + {"method": "pieces.place", "params": {"map": "town/inn", "piece": "bookshelf", "from": [4, 2], "to": [4, 2]}} +]} +``` + +The piece writes its glyphs and its tiles together, so nothing shows as a raw +glyph. Stamping on cells that make the floor solid may need +`--relocate-saves`. + +## Worked Example: Place A Chest With A Reward + +Create the event, then set what it gives. The reward is an inventory id from +`references.list {map, category: "inventory"}`: + +```json +{"changes": [ + {"method": "event.create", "params": {"map": "town/cellar", "cells": [[10, 5]], "type": "Chest", "marker": "C"}}, + {"method": "inspector.apply", "params": {"map": "town/cellar", + "key": {"target": "event", "marker": "C", "path": ["data", "lootType"]}, "value": "item"}}, + {"method": "inspector.apply", "params": {"map": "town/cellar", + "key": {"target": "event", "marker": "C", "path": ["data", "loot"]}, "value": "item.tonic"}} +]} +``` + +Without the last change the batch is refused: the chest has no item chosen. + +## Driving The Session Directly + +For a long piece of work, an agent can hold one session open instead of +writing batches: start `ichiloto edit:host --directory path/to/game`, send one +JSON request per line on its standard input and read one response per line +from its standard output. The first request is +`{"id": 1, "method": "hello", "params": {"protocol": 1, "project": ""}}`. +Then: + +1. `changes.begin` opens a change set. It needs no pending edits. +2. Make the edits, passing each map's current `revision` (every edit returns + the new one), and read back what you need. +3. `project.validate {maps?}` reports the issues the project would have now, + unsaved edits included. +4. `changes.commit {relocateSaves?, check?}` writes the change set when it is + valid, or refuses it, writes nothing and undoes it. `changes.cancel` + undoes it. Closing the host with a change set open cancels it. + +A response is `{"id", "result"}` or `{"id", "error": {"kind", "message"}}`. +`map.save` and `project.saveAll` are the author's draft saves: they write a +map with errors. Agents commit change sets instead. + +## Before You Run + +- Do not apply a batch to a project an author has open in an editor with + unsaved changes to the same maps. The editor's own save refuses a file that + changed since it read it, so nothing is overwritten silently, but the + author's edits then have to be redone. +- `map:apply` validates the whole project twice, before and after. Expect it + to take as long as `ichiloto validate` does, twice. diff --git a/docs/guides/make-a-building.md b/docs/guides/make-a-building.md new file mode 100644 index 00000000..1d4841c2 --- /dev/null +++ b/docs/guides/make-a-building.md @@ -0,0 +1,86 @@ +# Make a Building + +This guide takes a building from its art to a door the player walks through: +the art brought into a tileset, the building's piece defined over it, the piece +placed on a town with its collision, and an entrance at its door. It uses the +GUI editor (`ichiloto edit --gui`), which shows the art; the terminal parts work +in the TUI too. The manual's [Pieces](../manual.md#pieces) and +[Map Graphics](../manual.md#map-graphics) sections are the reference. + +A building is a stamped tileset piece: its tiles on a tile layer, its terminal +glyphs on a gameplay layer, its physical footprint and the doors its art +paints. The terminal and the graphical field show the same building; collision +belongs to the map, never to the picture. + +## Bring In The Art + +Art for a building usually arrives as an image of its own, cut into 48-pixel +cells like an RPG Maker B sheet. + +1. Open the Database (`F3`), choose **Tilesets** and the tileset of the maps + the building goes on. +2. In the tileset view, choose **Add art**, then **Choose image…**. If the image + is not in the project yet, **Import image…** brings it in: pick the file, + then where under `assets/` it goes. +3. Drag across the building's cells in the image. +4. Choose the sheet tab and click the cell where the building's top-left + goes. Cells with art are shaded: + only free cells take the copy, and the rest of the sheet stays as it was. + +The copy is written at once; Undo puts the sheet back. The copied cells are +selected under **Define piece**, ready for the next step. A tileset with a free +upper sheet can take the image as that sheet instead: choose it in the +inspector's Sheet row. + +## Define The Piece + +1. Under **Define piece**, choose **New piece from these cells**. Name it, + choose the gameplay layer its glyphs go on (usually `buildings`) and the + tile layer its tiles go on, then **Create piece**. +2. **Glyphs**: click each cell and type the character the terminal shows there, + in the brush colour, then Enter. Walls, roofs and the door each get their + own; a blank cell leaves the map's glyph as it is. +3. **Collision**: choose **Add physical footprint**, pick a collision in the + brush and click the cells the building stands on. Leave the roof's cells + unchanged so the player can walk behind it. +4. **Doors**: choose **Entered from the front** for a door painted on a wall, + or **Walked onto** for an open archway, and click the door's cell. +5. Under **Above characters**, drag across the roof's tiles so they draw over + the player walking behind the building. +6. Save the tileset. + +## Place It + +1. Open the town, choose the Tiles tool (`⌘2`) and the palette's Pieces tab, + and take the building. +2. Keep **With its collision** checked, so its footprint comes with it. The map + must have its own collision; the layer inspector's **Separate collision** + gives an older map one. +3. Click where its top-left goes. The Game and Terminal views (`F5`) show it + both ways. + +Click any of its cells to see it in the inspector. **Move** picks it up, and a +click puts it down elsewhere with its tiles, glyphs, collision and entrances; +**Remove** takes it away. Both are one undo step. + +## Give The Door Its Entrance + +1. In the inspector's **Placed piece** section, choose **Add entrance**. The + entrance goes on the cell in front of the door. +2. In the entrance's inspector, choose the interior as its **Destination Map**, + then the cell the player arrives on as its **Spawn Point** and the way they + face as its **Spawn Facing**. +3. On the interior, place the way back out the same way, arriving in front of + the door. + +The canvas marks each door and its entrance cell, red until an entrance stands +there. **Check this map** in the map inspector lists any door still without an +entrance in plain words, with **Show** to go to it. + +## See It As The Game Does + +Save the map. On a project with a save manifest, saving first asks where saved +games standing on cells the building now makes solid should go. The toolbar's review button writes a review of the map, +both ways side by side, to a folder you choose and opens it; +`ichiloto map:render` writes the same review from the command line, with +`--player X,Y` to stand the player at the door. diff --git a/docs/guides/make-a-new-map.md b/docs/guides/make-a-new-map.md index 2ef2e6d9..44d40875 100644 --- a/docs/guides/make-a-new-map.md +++ b/docs/guides/make-a-new-map.md @@ -29,16 +29,20 @@ creation is not undoable. 1. `Tab` to the `Inspector`. 2. Move to `Name` and press `Enter`, type the display name, press `Enter`. -3. Set `Region` the same way — it groups maps, and becomes part of the map id. +3. Set `Region` the same way. Like the name, it is metadata; see below. 4. Under `Size`, move to `X` and `Y` and use `Left` / `Right` to resize, or `Enter` to type a number. Resizing is undoable. Shrinking a map discards the tiles outside the new bounds, -so `Ctrl+Z` is your friend if you overshoot. +in its terminal layers and in any graphical tile layers in `graphics/`, so +`Ctrl+Z` is your friend if you overshoot. -Current behavior: the map id is derived from the region and name. Changing -either means the map's *folder* moves on the next save, and the editor asks for -explicit confirmation before it does. +The name and region are metadata. Saving keeps the map in its folder, so its +id, which doors, quests and saves refer to, does not change. To move the folder +to a path derived from the region and name, choose *Move Map to Derived Path* +from the command palette (`Ctrl+P`). It shows the current and proposed ids and +asks for `y`. References to the old id are not updated. See +[Stable map identities](../manual.md#stable-map-identities). ## Paint The Tile Layer @@ -86,6 +90,10 @@ neither is written to the file by just looking. `Del` removes the row the cursor is in; removing the last row turns encounters off and removes the block entirely. +A troop that lives in one part of the map, such as bats at a roost, is limited +to that part by its `Areas` row: make a named area from cells on the map, then +type its id there. Steps outside every troop's areas never start a fight. + ## Save Press `Ctrl+S`. diff --git a/docs/guides/wire-a-quest.md b/docs/guides/wire-a-quest.md index 81cf4695..a021db32 100644 --- a/docs/guides/wire-a-quest.md +++ b/docs/guides/wire-a-quest.md @@ -43,6 +43,72 @@ from its type and target. Press `Ctrl+S` to save the category. +## Point The Way + +Each objective has a `Waymarks` row. A waymark tells the player where to go: +the field shows it on screen, or as an arrow hugging the screen edge when it +is off screen, and the local and world maps mark it. When it is on another +map, the exit that leads there is marked instead. + +- `Shift+O` on an objective's `Waymarks` row adds a waymark to it (on the + objective's other rows it still adds an objective). +- Pick its `Map` first. `Event`, `NPC`, `Object` and `Landmark` then offer + what that map holds; choose one, or none to mean the whole map. Choosing + one clears the others, since a waymark points at one thing. +- `Shows When` takes a condition line. Use it for phases (show the next + step only once the previous one's flag is set) and for free-order work + (several waymarks, each hidden once its own part is done). + +Waymarks store the subject's stable id, never coordinates, so moving an NPC +or an event moves its waymark too. Validation reports a waymark whose map or +subject no longer exists. The `Field Cues` Database category sets how they +look: a terminal colour (which also picks the cue art drawn in graphics), a +one-cell symbol and how far edge markers sit in from the edge, along with the +cue plate, bob and pointer images every cue mark shares. + +## Lead To The Quest's Start + +Objectives only lead once the quest is in the journal. To lead the player to +whoever hands it over before that, use the quest's `Start Waymarks` heading: + +- `Shift+O` on the `Start Waymarks` row adds a start waymark. +- Pick its `Map`, then the `NPC` or `Event` that starts the quest, the same + way as an objective's waymark. `Shows When` limits it further. + +A start waymark shows while the quest can start: it is not yet accepted or +finished and its `Prereqs` hold. Once the quest is accepted its objectives +take over. The story's opening is the first main quest's start waymark: a +new game leads to its giver. + +## Keep The Story Path Led + +Validation warns where the wayfinder would have nothing authored to lead with: + +- A main quest's prerequisite, or a door that turns the player back + (`whenBlocked`), needs a story flag that nothing in the project sets, or + that no objective or start waymark of a main quest leads to. An objective + leads to a flag when it tracks the flag, or when its waymark points at what + sets it: the event or NPC, or, for a flag set in a battle (its script after + `start_battle`, or the troop's event pages), the event that starts the + battle or the map whose encounters bring it. +- Nothing in the project starts a main quest, or nothing leads to what does. + The warning names where the quest starts. Lead there with the quest's own + start waymark, or with an objective of the main quest active before it. + +Guidance counts only when it can show before the step: a waymark waiting for +the step, or a quest that needs it, does not lead to it. A start waymark +disappears once its quest starts, so it leads to what starts its quest (and +to a flag set by the same NPC page or event). When whoever starts the quest +is there, or speaks, only once a flag is set, the quest cannot start before +that step, so its start waymarks lead to whatever sets the flag too. That is +how to lead between quests through a step that opens the next one: give the +next quest `Prereqs` the player already meets (`quest::completed`) +and two start waymarks, one on what sets the flag shown while it is unset +(`!event:`), and one on the giver shown once it is set +(`event:`). Should a gap reach the +game anyway, the game leads to what takes the next step itself and notes the +gap in `logs/warning.log`; close it with an authored waymark all the same. + ## Condition Lines Quest prerequisites, skit conditions, and event `branch` arms share one grammar, diff --git a/docs/manual.md b/docs/manual.md index 12498c7f..97fa3525 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -54,18 +54,18 @@ is what the code draws rather than a sketch of it: ┌─Assets [Focus]───────────────┐ ┌─Canvas─────────────────────┐ ┌─Inspector──────────────────────┐ │ Maps │ │ Preview: test-map │ │ Name: Test Map │ │ > test-map │ │ Test Map | | 12 x 5 | vie │ │ Region: │ - │ │ │ ############ │ │ Description: A tiny fixture │ - │ │ │ # ~~~ # │ │ map. │ + │ │ │ ############ │ │ Kind: Not set │ + │ │ │ # ~~~ # │ │ Description: A tiny fixture │ + │ │ │ # # │ │ map. │ │ │ │ # # │ │ Size │ - │ │ │ # # │ │ X: 12 │ - │ │ │ ############ │ │ Y: 5 │ + │ │ │ ############ │ │ X: 12 │ + │ │ │ │ │ Y: 5 │ │ │ │ │ │ Events · 1 │ │ │ │ │ │ Triggers · 0 │ │ │ │ │ │ Audio │ │ │ │ │ │ Background Music: (None) │ │ │ │ │ │ Music Variants · None │ - │ │ │ │ │ Encounters · off │ - └─/:Filter Del:Delete─────────┘ └─i:Paint m:Map e:Event────┘ └─Enter:Edit─────────────────────┘ + └─/:Filter Del:Delete─────────┘ └─i:Paint L:Layer ?:Help─────┘ └─Enter:Edit─────────────────────┘ ┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐ │ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1 │ │ Cursor: (0, 0) | Viewport: (0, 0) | Ready. │ @@ -79,6 +79,12 @@ is what the code draws rather than a sketch of it: and, on its second line, the cursor, the viewport, and the most recent message. +A message's colour says what happened: blue for an update, including every +edit, which stays in memory until you save; green only when something reached +disk (a save, or a command that writes files at once) or a check passed; +yellow for a warning; red for an error. Edits never show green, so green +always means saved, and the header's `*` shows what is still unsaved. + Each panel writes its own keys into its bottom border, and shortens them on a narrow terminal rather than cutting one in half - so what a panel offers is always on the panel. `?` lists everything at any width. @@ -104,8 +110,7 @@ prefer. Arrow keys always act inside the focused panel. Every selection list wraps: `Down` on the last row selects the first, and `Up` on the first row selects the last. This holds for the asset list, the -Database categories, entries, settings rows and animation frames, dialog and -picker lists, the command palette, option-value cycling, and the Cutscenes +Database categories, entries and settings rows, dialog and picker lists, the command palette, option-value cycling, and the Cutscenes lists. Map cursors, scrolling panes and text editing keep ordinary bounds - wrapping is for choosing from a list, not for walking a canvas. @@ -148,7 +153,8 @@ exist. `Esc` backs out exactly one level, everywhere: - a field edit returns to the pane -- a pending canvas anchor is dropped, then a selection is cleared +- a connected piece's anchor is dropped, then piece placement ends, a pending + canvas anchor is dropped, then a selection is cleared - a filter query is cleared before its list closes - an overlay or dialog closes - the Database screen closes @@ -163,8 +169,8 @@ carries a trailing `*`. Controls: - `Up` / `Down`: move the selection -- `Shift+A`: create a new map -- `Shift+D`: duplicate the selected map +- `Shift+A`: create a new map, asking its [kind](#map-kind) first +- `Shift+D`: duplicate the selected map, kind included - `Delete`: delete the selected map (destructive confirmation) - `/`: filter the list incrementally - `Enter`: focus the canvas on the selected map @@ -179,6 +185,553 @@ asks first and defaults to Cancel. ## Canvas Panel +### Authored Layers + +The TUI edits the terminal experience. Maps with `layers/` show numbered +gameplay (`NN.name.map.php`) layers and Events, with NPC overlays in their +authoring context. Graphical decoration (`NN.name.deco.php`) never appears on +the terminal canvas or in its layer selection, palette or inspector. In Normal +mode, `L` opens the layer picker: the map's gameplay layers and Events, each +marked visible or hidden and the one being edited, and last NPCs, which enters +NPC mode. Type to filter, move with the +arrows or `j` / `k`, and press `Enter` to edit that layer (`Esc` keeps the +current one). The canvas title shows the layer being edited, and only that +layer is painted. `[` and `]` still step to the previous or next layer, `v` +toggles the selected layer's visibility, and `d` dims inactive layers. Spaces +above the base show through. Visibility and dimming are session settings, not +map data. + +The normal canvas is editable immediately: press `i` and paint terminal glyphs. +The separate read-only terminal-preview toggle has been removed; there is no +graphical-marker view to switch away from. + +Use `Ctrl+P` and choose a Layers action to create, rename or remove a gameplay +layer. The TUI cannot create, +select, rename or remove graphical decoration layers. New gameplay layers +preserve each row's width, including ragged +maps. Renaming retains its numeric order. If the actual resolved collisions would +change, the rename dialog names the change and requires `y` confirmation; +shared `collisions.php` is never changed. Removal is confirmed and undoable. +Creating a layer on a legacy map explicitly moves its terrain into +`layers/00.terrain.map.php` in the same save transaction; ordinary legacy +saves keep the original layout. + +The GUI offers **Separate collision** for an undeclared map. After explicit +confirmation, the shared session captures its current resolved physical cells +without changing glyphs, artwork or passage. This is one undo step, written only +on save; opening or saving a legacy map never converts it automatically. Once +converted, terminal appearance and layer edits no longer choose collision. +Resize and row/column insertion and removal preserve the declaration with +geometry, adding `SOLID` physical cells independently of blank glyphs. Malformed or unsupported +source edits refuse before mutation. The TUI receives no graphical conversion +control; it preserves the declaration through its existing edit/save/history +workflow. + +The GUI **Collision** tool displays the shared physical cells over either +presentation. On converted maps its constrained collision-type picker and the +existing pencil, line, outline, filled-rectangle and fill brushes edit passage +as one undo step without changing glyphs or tiles. The overlay's numbers match +the picker; right-click picks the physical cell's type. Fill follows equal +physical cells and does not bridge missing cells in ragged rows. Unsupported +types, stale revisions, malformed declarations and source-preservation failures +refuse before any change. Escape, map/tool changes and brush changes retire +pending region answers. Conversion remains explicit on legacy maps. + +In the GUI tileset inspector, **Physical footprint** is an optional reusable +recipe, not an automatic consequence of placing a picture. Its cells use final +collision types or **Leave unchanged**; adding a recipe initially leaves all +cells unchanged. Edit it by clicking the piece's cells over its art in the +tileset view's Collision mode, or through the Physical Footprint record row, +then save the tileset. Placing the piece in the GUI's Tiles tool sets its +footprint in the same undo step while **With its collision** is checked, on a +map with its own collision; unchecked, placing leaves collision as it is. The +Collision tool's **Object footprint** mode selects a saved recipe and stamps it +alone with one click as one undo step: only map occupancy changes, never glyphs +or tiles. Every non-null cell must fit the actual map, including ragged rows, +or the entire stamp refuses. Stale map/recipe values and unsupported source +expressions refuse before mutation. Removing a recipe does not erase collision +already stamped into a map. The TUI preserves recipes through existing record +edits without adding graphical authoring controls, and its piece stamps leave +collision as it is. Native acceptance of the physical brushes and footprint +controls remains open. + +A stamped piece whose art paints doors, such as a building, names them in its +**Painted Doors** record row: each door's cell from the piece's top-left cell, +with `front` for a door entered from the cell before it, separated by +semicolons (`2,4; 4,4 front`), or clicks them on the piece's art in the GUI +tileset view's Doors mode. Validation then requires every copy of the piece +standing whole on a map to meet an entrance at each door, and leaves those cells +to the doorway rule rather than judging their look against passage. The pieces +list reports each piece's doors. The TUI preserves them through record edits. + +The GUI tileset view lays each upper sheet (B to E) out as its image is, +sixteen cells across, so a piece as wide as the sheet is one selection; the A +tab keeps RPG Maker's palette layout. Its **Add art** mode copies another +image's cells, such as a building delivered on a sheet of its own, into free +cells of a sheet, pixel for pixel: choose the image (the image picker's +**Import image** brings one in from the computer), drag across its cells, and +click the sheet cell where their top-left goes. Cells that already hold art +are shaded and never drawn over, B's first cell is the empty tile, and every +other pixel of the sheet stays as it was. The sheet is written at once, not on +Save; Undo puts back its exact bytes, and refuses when the file was changed +outside the Editor since. The copied cells come back selected. **Define piece** +then makes a new piece from the selected cells (its name, the gameplay layer +its glyphs go on and the tile layer its tiles go on), with a blank glyph in +every cell, and shows a stamped piece's cells over its art: Glyphs (click a +cell, type its character, Enter; its colour is the brush's), Collision and +Doors. Each edit is one undo step through the tileset record's own rows. +**Above characters** marks a dragged rectangle, such as a roof, in one step. +None of this is offered by the TUI, which edits the same rows as text. + +A building or other stamped piece on a map is found by its tiles, as the +doorway rule finds it. Clicking one of its cells in the GUI shows it in the +inspector's **Placed piece** section: where it stands, each door its art paints +with the cell it is entered from and the entrance there, and **Add entrance**, +**Move** and **Remove**. The canvas outlines it, fills its doors and marks each +entrance cell, filled when an entrance stands on it. Add entrance places a +transfer on the cell in front of a `front` door (on the door itself for one +walked onto) and shows it, so its Destination Map, Spawn Point and Spawn +Facing rows choose where it leads, the arrival cell and the facing there. Move carries the piece until a click puts +its top-left down; its glyphs, its tiles, with **With its collision** its +footprint, and the entrances meeting its doors go with it, as one undo step. +Remove takes away its glyphs where they are still its own, with the tiles they +carry, its other tiles, and with its collision the footprint's cells, which +take the collision the glyphs left on them give, as Separate collision would +have read them. Cells a piece covered keep whatever lies beneath on other +layers. + +The GUI tileset view's **Appearance** mode sets what tiles show at ground +level, for validation alone: choose Ground, Water, Barrier or Overhead (art +over the player, or a light decoration that says nothing about the ground), +then click a tile or drag over several; **Sheet default** takes a tile's own +class away, and **Whole sheet** sets the selected tile's whole sheet. Each is +one undo step through the tileset record's Appearance rows. Badges show each +tile's class, filled when authored and outlined when its sheet kind's default +decides; `?` marks a tile no class covers yet. Passage never changes. The TUI +preserves the classes through record edits without offering the graphical mode. + +Gameplay and event layers use the same Paint/Normal modes, tools, mouse strokes, +selection, colour, clipboard and undo. The layer inspector lists terminal layer +visibility only. Ordinary glyph edits do not rewrite graphical decoration. +Glyph-keyed crop tables (`tiles2d`) are no longer read by the Engine or edited +here; a map that still has one keeps its bytes, and validation warns that it is +no longer read. Canonical source safety, collision and grid checks remain in +force for terminal edits. Untouched layer files are never written; unchanged +rows retain their authored bytes, and all changed files are saved or rolled +back together. + +### Inserting and removing rows and columns + +In Map mode, `Ctrl+P` offers **Map: Insert rows above the cursor** and +**Map: Insert columns left of the cursor**. Both first refuse while the project +has unsaved changes: save or undo them first. A prompt asks how many to insert +(a whole number of at least 1, `1` by default; `Up` / `Down` step it, `Enter` +plans, `Esc` cancels). Inserting `N` rows at the cursor's row moves everything +from that row down by `N`; inserting columns moves everything from the cursor's +column right. The map grows by `N`. + +Nothing is written until you confirm. The confirmation lists every file the +insertion writes, then every coordinate it could not rewrite, as +`Hand edit: file path` with the reason, then whether saves follow. `Enter` on +**Write N files now** writes them all at once, not on Save; `Esc` or **Cancel** +leaves every file unchanged. One `Ctrl+Z` restores every written file byte for +byte and reloads the workspace; `Ctrl+Y` writes them again. + +What moves, when it is in the map's space and at or beyond the line: + +- every grid: gameplay and decoration layers, the event layer (so event areas + follow) and the tile layers, whose new cells are empty (`0`); styled cells + keep their colour and rows that only move keep their bytes; +- the map's NPC positions and wander areas (an area straddling the line + stretches), bed spawn points, legacy trigger areas and explicit event areas; +- spawn points of transfers into the map from any map, legacy triggers into + it, and `startingPositions` in `assets/Data/system.php` that start on it; +- script coordinates on the map: `move_player`, `move_route` waypoints (only + the axes a waypoint names; `retrace` is relative, and relative `steps` are + listed as a hand edit because a walk across the line ends elsewhere), `camera` and + `field_animation` position targets, `stage_actor`, and `transfer` into the + map. Scripts start on their map; after a `transfer` elsewhere, later commands + are in that map's space and stay. A waypoint, staged actor or cast member + placed relative to a subject (`at` with an optional `offset`) is never + rewritten: it follows its subject. When the edited lines fall between the + subject and the point, or may (a player or staged actor subject), its + offset is listed as a hand edit; +- reusable scripts in `assets/Events/` started only from this map, and + cutscenes whose start map it is (their cast, script and finalizer). + +A regional `station` and tile-layer `offset` values are not map cells and never +move. + +What is reported instead of rewritten: a coordinate written as a PHP expression +or variable, or inside a list built with a spread (such as generated NPCs); a +file that is not one returned array literal; a coordinate in a reusable script +or cutscene that may run on this map or another one. The editor never guesses +or flattens authored PHP; move those by hand after writing. + +Saves follow through the project's `assets/Data/save-compatibility.php`: the +insertion raises `contentVersion` by one and appends a `mapShifts` step, so a +saved player position on the map moves too. A project without that manifest is +told that existing saves are not migrated. + +**Map: Remove rows from the cursor down** and **Map: Remove columns from the +cursor rightward** take the same count prompt and confirmation. Removing `N` +rows from the cursor's row deletes those rows from every grid and moves +everything below them up by `N`; columns move left. The map shrinks by `N` and +keeps at least one line. + +Nothing authored may stand on a removed line: an event marker, an NPC, a +spawn point or transfer into the map, a starting position, a world object's +covered cell, or a script coordinate surely on the map. The removal is refused +before anything is written and the refusal names each one, so move them first. +An area that straddles the removed lines shrinks by the lines it loses; one +wholly on them refuses the removal. Coordinates that may be on this map or +another are listed as hand edits, as for an insertion. + +With a save manifest, the removal appends a `mapRemovals` step. A saved +position beyond the removed lines moves back with the map; one standing on a +removed line lands on the cell the confirmation names, the cell the player can +reach today nearest the reachable part of the removed lines, given in the map's +coordinates after the removal. The GUI lets you name another reachable cell +instead. + +### Pieces + +A map offers the pieces of its [kind](#map-kind)'s tileset (see +[Map Graphics](#map-graphics)): whole items such as a bed or a table, so a map +is built from items instead of single glyphs. In Normal mode on the canvas, `P` +opens the piece picker for the layer being edited: only the pieces whose glyphs +go on that layer, titled after it (`Fixtures piece`), one entry per piece with +its footprint in cells and the tile layers it writes, for example +`1 x 2 · tiles: furniture`, or `connected` in place of the footprint for a +[connected piece](#connected-pieces) such as a wall (`connected · tiles: +walls`). In Event mode it offers the pieces of the layer Map mode edits, and +choosing one returns to Map mode. Move with the arrows or `j` / `k`, press `/` +to filter by name, `Enter` to choose and `Esc` to cancel. The command palette +offers the same picker as `Pieces: Choose a piece to place`. + +`T` in Normal mode (or `Pieces: Draw tiles for this layer's glyphs` in the +command palette) draws the tiles for the glyphs already on the layer being +edited, as if each were painted again: every glyph a piece draws gets that +piece's tiles, so a map authored before its pieces existed, or before it had +a kind, draws correctly graphically without a per-map script. A glyph that +could be several pieces asks which, as painting does, and the answer applies +wherever its neighbours do not decide. The glyphs themselves, and glyphs no +piece draws, are left as they are. It is one undo step, saved with the map. + +When the layer being edited has no pieces, the status line names the layers +that do. When the map has no kind yet, or its tileset has no pieces or cannot +be loaded, it says so and nothing opens. Placing a piece never chooses a map's +tileset: it comes from the map's kind, set in the Inspector. + +Choosing a piece starts placing it, in Map mode. The canvas previews the +piece's glyphs in reverse video with its top-left cell at the cursor, and the +canvas border shows `PIECE Enter:Stamp Esc:Done`. The arrows move the +piece; a Normal-mode click moves the cursor, and the piece with it, to the +clicked cell. `Enter` stamps the piece and keeps it for the next stamp; `Esc` +ends placement. Entering Paint mode, choosing a tool or a layer, switching to +Event or NPC mode and selecting another map also end it. + +A stamp is one undo step. It writes the piece's glyphs on the gameplay layer +the piece names, whichever layer the canvas is editing, in the brush colour as +painting does, and its tiles on the tile layers it names. A space glyph and a +`0` tile leave their cells as they are, and every other cell takes the piece's +whole tile. A field cell holds one whole RPG Maker tile, so an entry naming +half a tile (`42L`) is refused: in a tileset piece the canvas offers no pieces +and says which entry, in a tile layer the stamp changes nothing, and +validation reports both. A tile layer the map does not have yet is created as +`graphics/NN.name.tiles.php`, empty elsewhere, in the order of the gameplay +layer it belongs to: before the first tile layer of a gameplay layer drawn +above its own, so terrain tiles draw under building tiles and those under +fixtures, and otherwise after them all. It takes the free order just below +that layer; when there is none, that layer and every later one move up one +order, and saving writes their new files and removes the old ones. The terminal never shows the tiles, but a map built from pieces +draws correctly graphically without a second pass. A stamp that cannot be made +whole changes nothing: the map has no gameplay layer with the piece's name, +the footprint does not fit inside the map at the cursor (ragged rows +included), or a tile layer it names cannot be read or does not match the map. +Undo and redo restore glyphs and tiles exactly. `Ctrl+S` saves the changed +terminal and tile layer files in the map's one transaction; untouched files +are not written, and a changed tile layer is rewritten as a literal nowdoc +that keeps its leading comment. + +A project adds pieces to its tileset (`assets/Data/Tilesets/.php`) under +`pieces`, keyed by id, each with a `name`, the gameplay `layer` for its +`glyphs` (rows of one-cell characters) and optional `tiles` keyed by tile +layer name, with rows over the same footprint: +`'bed' => ['name' => 'Bed', 'layer' => 'fixtures', 'glyphs' => ['=', '='], 'tiles' => ['furniture' => ['32', '40']]]`. +The Engine's `docs/graphical-field.md` (Pieces) holds the contract. Every +stamp reads the tileset again, so the tileset stays the one source of pieces. + +#### Connected pieces + +A connected piece (`'connects' => 'lines'`), such as a wall or a fence, is +drawn rather than stamped, one cell per map cell, and joins the cells of the +same piece beside it. A cell belongs to the piece when its glyph on the +piece's gameplay layer is one of the piece's shape glyphs, so walls typed by +hand join too, while other glyphs (a `_`, say) are left alone and do not join. +Each cell takes a shape from the member cells beside it: joined only across it +is `horizontal`, only down it is `vertical`, and anything else (a corner, a +junction, a lone post) is a `corner`. A line's end cells are joined on one +side only, so a wall drawn across reads `----`, not `+--+`. + +While a connected piece is placed, the canvas border shows +`PIECE Enter:Draw Del:Erase Esc:Done`, and `Esc:Unanchor` while an +anchor is set. + +- `Enter` with no anchor draws the cell at the cursor and anchors there. +- `Enter` with an anchor draws from the anchor to the cursor: a straight line + when they share a row or a column, otherwise the outline of the rectangle + with the anchor and the cursor as opposite corners, which is a room. The + anchor then moves to the cursor, so the next `Enter` carries on from there. +- The erase keys (`Backspace`, and `Delete`) erase the piece's cell under the + cursor. A cell that is not part of the piece is left alone, and the status + line says so. +- `Esc` drops the anchor when one is set; otherwise it ends placement. + +The canvas previews the cells `Enter` would draw, each with the glyph it would +take, in reverse video. Every draw or erase is one undo step. It writes the +drawn cells, then reshapes every drawn cell and every member cell beside a +drawn or erased cell: its glyph becomes its shape's glyph and its entry on each +of the piece's tile layers becomes that shape's tile. So drawing a wall that +meets another turns the meeting cell into a corner, and erasing a cell +straightens the corners beside it. Drawn cells take the brush colour; reshaped +cells keep theirs. An erased cell becomes a space on the gameplay layer and +`0` on the piece's tile layers. A draw that reaches a cell beyond the map +(ragged rows included) changes nothing, and neither does a piece whose +gameplay layer the map lacks. A missing tile layer is created as for a stamp, +and undo, redo and `Ctrl+S` work exactly as they do for stamps. The tileset +lists a connected piece with one glyph per shape and one tile entry per tile +layer, or one per shape: +`'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines', 'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']]`. +An A4 wall top can be the one entry for every shape, since the autotile shapes +its own edges. + +### Terrain Kinds + +A world map is painted in terrain kinds: grassland, woodland, mountains, +water and the like, declared in its tileset (Database > Tilesets, Terrain +Kinds). A kind is the cell's identity; its glyph in its colour and its +autotile are its two presentations, and its passage and encounters come with +it. `K` on the canvas opens the kind picker for the layer being edited, one +entry per kind with its glyph, whether it is walkable, its encounters, the +ground a feature stands on and whether its art is still to be drawn. The +picker opens on the kind under the cursor. Choosing one paints it like an +item piece: `Enter` paints the cursor's cell and anchors there, `Enter` again +fills the area to the cursor, a mouse drag fills the area it covers, and `Esc` +drops the anchor and then ends painting. The canvas border shows +`KIND Enter:Paint Drag:Fill Esc:Done`. + +Painting a kind makes the cell's terrain the kind's alone, as one undo step: +the tiles the layer's tile layers held there are cleared, except the layers +the kind keeps (a substrate beneath it), then its glyph is written in its own +colour and its autotile with its ground's. A kind awaiting its art draws no +tiles, so its glyph shows in the graphical field until the art is bound. On a +map with its own collision, the kind's passage is written into the cells +where nothing stands above it. + +A kind's `Corner Sheet` (chosen among the project's PNG assets) draws its +edges on the corners between cells instead of in each cell, so diagonal +shorelines and bends are smooth curves rather than stair steps. Painting, +the terminal, collision and saves are unchanged: the kind still writes the +same glyph and autotile. Validation warns when a map shows a kind whose +corner sheet cannot be drawn; those cells show their glyphs. + +A corner-edged kind's `Joins` names a join group, such as `water`. Kinds on +the same tile layer naming one group share one edge: a river runs into a lake +or the sea with no bank between them, and each still shows its own water. +Leave it empty for a kind that draws its edge against every other kind. The +tileset refuses a join on a kind without a corner sheet, and kinds of one +group that paint different tile layers. + +Only a map painted in terrain kinds is painted by kind (`'terrainKinds' => +true` in its data file). Until then `K` says how to convert it: +`ichiloto map:convert-kinds ` lists the map's glyphs and the kinds, and +`--kind ';=grassland' --kind '~=water' ...` maps each old glyph to a kind, +shows each mapping's cells and the collision it changes, and converts the map +through the Editor's validated change set (`--check` writes nothing). A glyph +that already names a kind must be mapped too, so nothing is reread unseen. A +kind owns its glyph's atlas role, so the map's own atlas classifications of +the glyphs it converts, and of glyphs that name kinds on that layer, go with +the conversion; the preview lists them. Validation reports atlas terrain the +game cannot read as an error, since the game refuses to prepare that map. + +Validation warns about cells whose glyph and tiles show different kinds, +about cells of kinds awaiting art, and lists each tileset's kinds awaiting +art. A troop of the map's encounters can be limited to terrains (the +inspector's `Terrains` row, offered on maps painted in kinds), typed as the +encounter terrains the map's kinds meet. + +### Thickets + +Woodland, marsh reeds and tall grass hide a character walking through them +in the graphical field, as classic JRPGs do. The GUI editor's Database > +Tilesets declares a tileset's **Thickets**, one per foliage type: its id and +name, its **Depth** (field pixels of a character hidden, up from its feet, 1 +to 48), its **Hidden Part Opacity** (0 masks the hidden part behind the +foliage; up to just below 1 shows it through, as RPG Maker draws a bush), +whether it **Rustles** around a character walking through it, and the +**Tiles**, other than terrain kinds' own autotiles, that are it. A terrain +kind's **Thicket** row chooses among the tileset's thickets the one its own +autotile is. The game hides a character in the deepest thicket among the +tiles its cell shows. The terminal, collision, movement and encounters are +unchanged, and the terminal Database neither shows nor drops thickets. + +### Stacked Pieces + +Some decorations overlap their neighbours, such as the peaks over a world +map's mountains and the crests over its hills. A tileset declares the tile +layers they stack on (Database > Tilesets, Stacks: an id and its tile layers, +bottom to top, such as `relief-1, relief-2, relief-3, relief-4`), and a piece +stacks by putting its tiles on the stack's first layer. It has no effect, +kept layers or doorway. A decoration such as a peak has spaces for glyphs and +no physical footprint, so it never changes passage, encounters or the atlas: +the mountain kind beneath it is solid already. An obstacle standing on +walkable ground, such as a hill crest's mound, declares its ground footprint +like any object: glyphs on the cells it blocks, on a gameplay layer above the +terrain kinds (never the kinds' own layer), and the occupancy those cells +take on a map with declared occupancy. Stamping it writes those glyphs. + +Stamping a stacked piece lays each of its tiles into the cell's stack in +depth order, whichever was placed first: an object whose bottom row is +further south draws in front, and of two on one row the one further east. +The map gets the stack's tile layers as they are first needed, in the +stack's order. A cell holds at most one tile per stack layer, so a stamp +that would overfill one is refused and changes nothing; stamping the same +piece on the same cells again adds nothing. Each tile of a stack's pieces +belongs to one piece cell, so a cell's objects are read back from its tiles. +Removing one object from a stack is a remaining GUI capability. Repainting +terrain kinds clears a stack by whole object: an object goes when the paint +covers every cell it shows on, and stays whole when it also shows on a cell +the paint leaves, so painting the road beside a range never crops the peak +that overhangs it. An object that goes takes its footprint glyphs with it +where they are still its own. To remove an object, paint over all of its +cells at once. + +Each stacked object belongs to its piece's gameplay layer, so a stack whose +objects belong to several layers moves with none of them as whole tile +layers. Copying, cutting and pasting a selection carries the selected +layer's own objects, cell by cell, and lays them into the stacks where they +land in depth order: copying the terrain takes the peaks and leaves the hill +crests, which travel with the layer their footprint glyphs are on. + +### Map Graphics + +A map's [kind](#map-kind) names its tileset in its data file +(`'tileset' => 'interior'`, read from `assets/Data/Tilesets/interior.php`), +and the map may keep RPG Maker tile layers in +`graphics/NN.name.tiles.php`. A tile layer holds one tile identity per map +cell, so each of its rows is exactly as wide as the map's row in terminal +columns. The TUI never displays tiles or paints single tiles; painting them +belongs to the GUI editor. It writes tiles only when it stamps a +[piece](#pieces) or edits glyphs the tiles follow, and otherwise keeps them +intact: + +- Tiles follow a gameplay layer's glyphs however they are edited: typing, + painting, erasing, the line, rectangle and fill tools, the mouse, cutting and + pasting. A glyph that leaves a cell takes the tiles of the piece it drew + there; a glyph that arrives draws its piece's tiles, in the same undo step. + A piece's tiles over blank cells, such as a sofa's back over its seat, go + with the glyph nearest them. When a glyph belongs to several pieces (`-` for + a chair facing north or south), the neighbours decide when they hold the + rest of one piece, such as the left half of a table beside the right; + otherwise the editor asks which piece it is, or No tiles, and Esc leaves the + map as it was. The brush remembers the answer for its glyph until a glyph is + typed again, and the eyedropper picks up the piece a glyph draws. Tiles no + piece accounts for, such as a floor under a wall, stay where they are. A + tile that a glyph-less piece also draws, such as a stone wall cap that also + tops the stone wall, is free: placed on a cell it plays the piece of the + glyph already there, or none, so a cap beside a wall never becomes a wall, + and validation never asks for its glyph. A + piece that keeps the tiles beneath it, such as a window mounted on a wall + face, leaves them when it arrives; erasing it gives the cell back to the + piece those kept tiles stand for, such as the face's `#`, so the wall is + whole again. When they stand for several pieces, the editor asks which, or + none to leave the cell blank. + +- Glyphs follow the tiles that stand for them, the other way round. Any tile + a tileset piece draws stands for that piece's glyph on its gameplay layer, + such as a bed's tiles for `O` and `U` on fixtures or a wall's tile for `-`, + `|` and `+` on buildings. Placing such a tile in the GUI editor writes its + glyph where the piece puts it, with the rest of that + glyph's tiles and the piece's glyph cells that draw no tile (the lower half + of a window); erasing or covering one removes the glyph it stood for, with + its other tiles; a wall's cells take the shapes their neighbours give them. + On undeclared maps, glyph-derived collision follows in that same undo step; + converted maps retain their independently authored physical cells. Picked-up tiles + move in one step too. A tile two pieces draw is asked about. A tile whose + glyph would fall off the map is refused. Tiles no piece draws, such as a + rug or a picture, stand for nothing in the terminal and are placed freely. + +- Each tile layer moves with one gameplay layer: the one the map data names + (`'tileLayers' => ['floor' => ['movesWith' => 'buildings']]`), or else the + one whose tileset pieces write it, when only one does. Copying, cutting and + pasting a block on that gameplay layer carries its tiles cell for cell, in + the same undo step, so a moved chest or room keeps its art. Other tile + layers, and blocks on the event layer, leave tiles where they are. + +- Resizing the map crops or pads every tile layer with empty tiles (`0`) in the + same undo step and save as the terminal layers. A resized tile layer is + rewritten as a literal nowdoc that keeps its leading comment; an unchanged + one keeps its bytes. A resize is refused, changing nothing, while a tile + layer cannot be read or does not match the map. +- Duplicating, moving and deleting a map take `graphics/` with it, in the same + transaction and rollback. Other files in `graphics/` are the author's and + stay. +- A tile layer changed or added on disk after opening refuses the save, as + terminal layers do. + +Validation (`Ctrl+E`, or `ichiloto validate`) reads graphics as the Engine does. +A missing or invalid tileset, graphics without a tileset, misnamed or +duplicate-order tile layers, rows or cells that do not match the map, and +invalid tile identities are errors. Unusable sheets and tiles from a sheet the +tileset does not provide are warnings. The game shows terminal glyphs for +anything it cannot draw. + +For a map with a kind, validation also warns about what the graphical field +still shows as terminal glyphs, using the Engine's rule for which tiles hide +which glyphs: + +- glyph cells no tile covers, grouped by glyph and layer, or a single warning + when the map has no tiles yet; +- NPCs without a field sprite (`sprites2d`) whose glyph therefore shows; an + NPC with an empty sprite draws nothing and is not counted; +- a copy of an NPC's own glyph in the map, which shows under its sprite; + remove the copy when the NPC is always there. +- tiles a tileset piece draws whose glyph is no longer there, such as a + window's tile left after its `x` was removed outside the editor. Tiles no + piece draws, such as a house's walls over blank cells, are not judged; +- cells showing the tileset's missing-art placeholder (its `missingArt` + tile), which mark art nobody could yet infer. +- cells whose ground tiles (the A1 to A5 sheets) can show the background + through see-through edges in some animation frame, because no ground is + painted beneath them: the game shows that as a dark seam. Paint the map's + base or floor beneath on a lower tile layer. Upper-sheet props are + see-through by design and are not judged. This reads the sheets through + PHP's GD extension; without it, validation says it could not check. + +These are art still to do, not faults: the game plays the same either way, +so saving a map does not warn about them; `ichiloto validate` reports them. + +Validation also warns where what a map shows disagrees with what it lets the +player do, by the Engine's passage agreement rule (see the Engine's graphical +field guide, *Look and passage*): solid cells that show only walkable ground +(invisible walls), walkable cells that show water, a barrier or a world +object's art (walk-throughs), and world objects whose art leaves most of their +solid footprint bare. Each warning names its cells; the GUI editor's +**Collision** tool marks every one over the map with a coloured outline and +corner flag, counted by kind in its options. They are warnings, never +refusals: change the art or the collision as the map needs. A tile whose +ground appearance is unknown is not judged; give it one in the GUI tileset +view's **Appearance** mode. + +Text catalogues in `assets/Graphics/Tilesets/*.txt` appear as Facade brushes in +`Ctrl+P`. Separate multi-row shapes with blank lines. Select a brush to target +the gameplay `buildings` layer, then press `Enter` to stamp it as one undo +step. In Paint mode (`i`), a left click also stamps; Normal-mode clicks only +move the cursor. Mode and map changes clear the stamp brush; `b` returns to a +glyph brush. Every stamp rereads its catalogue, so +the catalogue remains the single source of shapes. Clipping never grows rows. + The canvas previews the selected map and is where you paint. It is modal, in the vim tradition: in **Normal mode** letters are commands, and in **Paint mode** every printable key is a glyph. This is what guarantees that no @@ -190,6 +743,59 @@ Normal. While painting, the Status pane shows `[PAINT]` beside the mode. Control-byte and function-key shortcuts (`Ctrl+S`, `F3`, ...) work in both modes, since they are not glyphs. +### Terminal Editing and Pending Graphical GUI + +Keep walkable floors and solid walls readable as terminal gameplay glyphs. +A readable notice or interactive fixture needs its gameplay glyph and +interaction, not a graphical decoration marker. Graphical representations +must not drive or change the terminal editing experience. + +1. Focus the canvas and choose a gameplay or event layer with `L`, or step + through them with `[` / `]`. Press `i` to paint; `Esc` returns to Normal + mode. `L`, brackets and + other printable characters remain paintable while in Paint mode. +2. Use the existing glyph tools, colours, selections, clipboard and mouse + strokes. Continue authoring events and NPCs in their existing modes. +3. Use `Ctrl+Z` / `Ctrl+Y` to undo/redo, including after changing layers. + `Ctrl+S` saves; `Ctrl+R` reloads the saved workspace and clears history. + Existing graphical source data remains preserved during ordinary glyph edits. + +**Removed TUI workflows:** painting decoration markers, choosing graphical +layers, inspecting graphical crop tables and editing selected-cell crops are +no longer terminal authoring features. Decoration data is not deleted, and +its low-level APIs and source-preserving round trips remain available for the +graphical editor. Glyph-keyed crop authoring (`tiles2d` and its cell +overrides) has been removed along with the Engine's support for it; tilesets +replace it. + +**Measuring walks** is a GUI editor tool: its Measure tool shows the +shortest walk between cells with its steps, walking and dashing seconds, +screens crossed and expected encounters, read from the session's `map.measure`. +Dashing seconds use the game's Dash Speed Multiplier from Database > System, +unsaved edits included; a game without a dash, a map with Disable Dashing on, +or a dash the game could not read shows walking time only and says why (see Measuring +A Walk in [Change Maps From A Script](guides/change-maps-from-a-script.md)). +The terminal editor keeps its workflow and has no measuring mode. + +**Rendering maps for review** draws a map, or a rectangle of it, both ways +from the project: `ichiloto map:render MAP[@X,Y,WIDTH,HEIGHT] --out DIR` +writes the Terminal field's characters and colours (`.ans` and a page of its +own), a native-size PNG of the graphical field, and a `review.html` with the +two side by side for every view. `--player X,Y[,HEADING]` stands the player +on a cell for door checks, `--actor ID` shows an actor there, `--scale 0.5` +adds an overview, and `--changes BATCH` renders a batch before it is applied, +writing nothing. The session serves the same render to an editor as +`EditorSession::renderMapReview()`, unsaved edits included, and writes it as +`map.review`: the GUI toolbar's review button asks for a folder, writes the +review of the open map there and opens its page. See Rendering +Maps For Review in [Change Maps From A Script](guides/change-maps-from-a-script.md). + +**Pending, not implemented:** richer renderers will use a separate, +RPG Maker-like GUI editor for graphical materials, atlases and crop authoring. +The planned `ichiloto edit` entry point will offer a TUI/GUI choice; that choice +and the GUI itself are not delivered by this TUI boundary correction. See the +[GUI editor plan](https://github.com/ichiloto/gui-editor/blob/develop/docs/plan.md). + ### Normal mode | Key | Action | @@ -198,7 +804,10 @@ modes, since they are not glyphs. | `m` | Switch to Map mode (paint tiles) | | `e` | Switch to Event mode (paint event markers) | | `n` / `F3` | Toggle NPC mode (place and edit the map's NPCs) | +| `a` | Switch to Area mode (select cells for the map's named areas; see [Area Mode](#area-mode)) | | `c` | Open the character map | +| `P` | Choose a tileset piece to place (see [Pieces](#pieces)) | +| `T` | Draw the tiles for the glyphs already on this layer (see [Pieces](#pieces)) | | `o` | Open the brush colour picker (see [Colour](#colour)) | | `b` / `l` / `r` / `R` / `s` | Choose a tool: Brush, Line, Rectangle, Filled Rectangle, Select | | `f` | Flood fill from the cursor (same as `Ctrl+F`) | @@ -210,18 +819,20 @@ modes, since they are not glyphs. | `u` / `U` | Undo / redo (same as `Ctrl+Z` / `Ctrl+Y`) | | `?` | Open the help overlay | | `Arrows` | Move the cursor | -| `Enter` | Apply the active tool | -| `Esc` | Pop one canvas level: a pending tool anchor, then the selection | +| `Enter` | Apply the active tool, or stamp or draw the piece being placed | +| `Esc` | Pop one canvas level: a connected piece's anchor, piece placement, a pending tool anchor, then the selection | Typing an unassigned printable key in Normal mode paints nothing; the status line points to `i` instead. ### Paint mode -Every printable key paints its glyph at the cursor with the brush tool, or -loads it into the brush under a shape or select tool. `Arrows` move, -`Enter` applies the active tool, erase keys erase, and `Esc` returns to -Normal mode. Entering NPC mode or moving focus off the canvas also returns +Every printable key paints its glyph at the cursor with the brush tool. Under +the Line or Rectangle tools a key chooses the glyph instead, and `Enter` sets +the anchor and then draws; the canvas border says which. Selecting is a Normal +mode job: entering Paint mode with the Select tool active switches to the +brush, and choosing Select returns to Normal mode. `Arrows` move, `Enter` +applies the active tool, erase keys erase, and `Esc` returns to Normal mode. Entering NPC mode or moving focus off the canvas also returns to Normal. ### Mouse @@ -229,9 +840,19 @@ to Normal. The mouse honors the canvas's modality. In Normal mode a click **selects**: the cursor jumps to the clicked cell and the status line reads out its coordinates - the fastest way to find a tile's position for a spawn point or -event without walking the cursor there. In Paint mode a left click paints -the brush symbol and a right click erases, dragging paints a stroke, and in -Event mode clicks keep their event-editing behavior. +event without walking the cursor there. With the Select tool, dragging in +Normal mode selects the rectangle from where the press began to where it is +released; a click still only moves the cursor. In Paint mode a left click +paints the brush symbol and a right click erases, dragging paints a stroke, +and in Event mode clicks keep their event-editing behavior. + +The mouse follows the active tool. With Line, Rectangle or Filled Rectangle +in Paint mode, the press sets the anchor, dragging moves the other end with +the shape previewed on the canvas, and releasing draws it in one undo step, +in the brush colour; dragging with the right button erases the shape. The +same preview shows while a tool anchored with `Enter` waits for its second +`Enter`. In Normal mode the canvas border names the tool keys +(`b/l/r/R/s:Tool`). The wheel scrolls the viewport without moving the cursor - free look for surveying a map larger than the canvas (horizontal wheel scrolls sideways). @@ -245,22 +866,23 @@ beside the viewport offset. `o` in Normal mode opens the brush colour picker: the 16 standard 4-bit ANSI colours (in the map format's Symfony colour names, where `gray` is bright black), rendered as live swatches, plus two brush states above them. -The brush colour applies to every paint on the Map layer - brush dabs, +The brush colour applies to every paint on every layer - brush dabs, shapes, flood fills - and is written as `` tags, exactly the styling authored by hand. - **Keep cell colour** (the default): painting changes the glyph and leaves each cell's existing styling byte-for-byte, authored options included. - **No colour**: painting strips styling and writes plain glyphs. -- **A colour**: painting writes the glyph in that colour. Selecting a - colour under the brush tool also recolours the cell at the cursor in - place, keeping its glyph, as one undoable stroke. +- **A colour**: painting writes the glyph in that colour. Choosing a + colour recolours in place, keeping the glyphs, as one undoable stroke: + with a selection active (`s`), every cell in the selection; otherwise, + under the brush tool, the cell at the cursor. Spaces stay uncoloured. -The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour along with its -glyph; an uncoloured cell loads an uncoloured brush. A painted space is +The eyedropper (`k` / `Ctrl+K`) picks up a cell's colour and the piece it +draws along with its glyph; an uncoloured cell loads an uncoloured brush. A painted space is always uncoloured, so erasing never leaves invisible styling behind. Event -markers are authoring geometry and carry no colour; the picker says so on -the Event layer. The Status pane shows the brush colour beside the tool. +markers remain authoring geometry, but can now carry colour through the same +picker and undo workflow. The Status pane shows the brush colour beside the tool. ### NPC Mode @@ -268,8 +890,10 @@ the Event layer. The Status pane shows the brush colour beside the tool. an overlay - sprites at their authored anchor, wide glyphs occupying the two columns the game gives them, styled sprites as the plain glyph - and nothing you do here paints a tile or an event marker. The selected NPC is shown in -brackets. NPC mode sits on `n` in Normal mode and on `F3` everywhere, so no paintable -character is taken from you (and not a control byte, since the terminal driver +brackets, except an explicitly empty sprite: selection highlights its existing +map cell without replacing the glyph or neighbouring cells. An unselected empty +sprite draws no overlay. NPC mode sits on `n` in Normal mode and on `F3` +everywhere, so no paintable character is taken from you (and not a control byte, since the terminal driver reserves the remaining ones). | Key | Action | @@ -292,14 +916,23 @@ with a hint rather than painted under an NPC. `Enter` on an empty tile asks for the NPC's name first, and derives its stable `id` from that name - `Gate Guard` becomes `gate-guard`, numbered if the map -already has one - because the id is what `move_route` and script diagnostics -name, and it is **immutable after creation**: renaming the NPC, moving it, or -changing its sprite never touches it. Duplicating assigns a fresh id from the -name. An NPC authored without an id loads and edits normally, shows a -`! No stable id` row (`Enter` there assigns one from its name, the one time an -id is ever written after creation, since nothing can yet name it), and -validates with a warning that scripted movement cannot target it. Changing an -existing id is an identity migration, which the editor does not offer. +already has one - because the id is what routes, dialogue events, cinematics +and script diagnostics name. Renaming the NPC re-derives an id its old name +derived from the new name **while nothing refers to it**, so an NPC created as +`New NPC` and named later gets the id its name suggests. Once an event, script, +route or cinematic starting on the map names the id, it stays: a rename keeps +it and the status says what names it. Moving the NPC or changing its sprite +never touches it. Duplicating assigns a fresh id from the name. + +The `Id` row sets the id directly, for an NPC whose id should differ from its +name (one character met as several NPCs, such as `djin-contract-trial` for +`Djin`). The id is lowercase letters and digits joined by single hyphens and +unique on the map. Changing it is refused, with each reference listed, while +anything names the current id: references are not migrated, so change them +first. A chosen id stays when the NPC is renamed. An NPC authored without an +id loads and edits normally, shows a `! No stable id` row (`Enter` there +assigns one from its name; the `Id` row takes a chosen one), and validates +with a warning that scripted movement cannot target it. #### The NPC Inspector @@ -311,27 +944,78 @@ category uses - pickers, condition lines, world-write rows, command frames, | --- | --- | | Identity | `Id` (read-only), `Name` | | Placement | `X`, `Y` (the canvas moves it too) | -| Appearance | `Sprite`, `Facing North/South/East/West` | -| Movement | `Movement` (`fixed` / `wander`), and while wandering `Wander X/Y/Width/Height` | +| Appearance | `Sprite`, `Facing North/South/East/West`, `Graphical Sprites` | +| Movement | `Movement` (`fixed` / `wander`), `Direction Fix` (`false` / `true`), and while wandering `Wander X/Y/Width/Height` | | Visibility | `Visible When` - a condition line | -| Interaction | `Script` (a command frame), then one `Dialogue variant N` heading per variant with its rows `When`, `Then Set`, `Script Commands`, `Line 1 Speaker`, `Line 1 Text`, … | +| Cue | Only beside a top-level `Script`: `Cue Symbol` (one cell; empty means no cue), `Cue Colour`, `Cue When`. Pages carry their own cue | +| Interaction | `Script` (a command frame), then one `Page N` heading per page with its rows `When`, `Then Set`, `Cue Symbol`, `Cue Colour`, `Cue When`, `Event Script`, `Cinematic`, `Repeats`, `Page Id`, `Staged Speakers`, `Script Commands`, `Line 1 Speaker`, `Line 1 Text`, … | | Completion Writes | `After Talking` - world-write rows | Rows read as the game will read them: an unset `Movement` shows `fixed`, an -unset `Sprite` shows `@`. Fields the game does not read are listed in a -`Preserved fields` row and written back untouched. Each dialogue variant is a -heading (`Dialogue variant 2 · when switch:gate_open` once it has a +unset `Direction Fix` shows `false`, an unset `Sprite` shows `@`. Fields the game does not read are listed in a +`Preserved fields` row and written back untouched. Each page is a +heading (`Page 2 · when switch:gate_open` once it has a condition) with short row labels under it, and long lines wrap, so what a character says is read in the pane rather than in the edit buffer. +- **Pages.** An NPC's pages are what talking to it does, as RPG Maker's + event pages: talking runs the first page whose `When` holds, so put the + most advanced story page first and an unconditional page last. `[` / `]` + on any row of a page moves it one place earlier or later. A page shows + its lines, then runs its `Script Commands`, its `Event Script` or its + `Cinematic` (one of them), and applies `Then Set`. Its cue is the NPC's + guidance while it is the page talking would run, so a story beat the NPC + delivers needs no separate event or stand-in cell, and a quest waymark + on the NPC follows the cue. `Repeats: false` retires the page once its + action completes; such a page needs a `Page Id`, which names its + completion in saves. `Staged Speakers` lists the other NPCs (by id, + comma-separated) its scene stages; each must stand beside this NPC + whenever the page can run. Lines alone with at most a cue are saved as + plain lines with the cue on the NPC, as they always were. Validation + reports a page an earlier page always pre-empts, and a story event an + NPC delivers from a separate cell (`event.npc-interaction`): move it + onto that NPC's pages. The session's `npc.adoptEvent` does that move as + one undo step, keeping a run-once event's saved completion for old saves. + +- **Map-owned appearance.** For a fixed interaction already drawn on a map + layer (such as a mounted notice on `fixtures`), clear the `Sprite` text + completely and press `Enter`. This stores `'sprite' => ''`, not a missing + field, and removes the duplicate NPC overlay. The NPC retains its stable id, + dialogue, conditions and blocking anchor. Select it at that anchor, through + `L`, or with `[` / `]`; selection highlights the underlying cell. Omitting + `sprite` still defaults to `@`. Whitespace-only or style-only sprite text + still warns: use genuinely empty text when the map owns the appearance. - **Movement.** `wander` roams one tile at a time; the wander bounds only appear while wandering, and loaded bounds are kept (not shown) for a fixed NPC. Omitting every bound leaves the game's unbounded wander. Patrol routes, pathfinding and followers are not engine features, so the editor does not offer them; scripted movement is a `move_route` command in an event script. +- **Direction fix.** When the player talks to an NPC, the game turns it to + face the player before it speaks and turns it back to its previous heading + when the conversation ends, unless the conversation's own script turned, + moved or staged it. `Direction Fix` (RPG Maker's option of the same name) + set to `true` keeps its heading throughout instead, for a clerk behind a + counter or a guard watching a gate. It stores `'directionFix' => true`; + setting it back to `false` removes the key, the game's default. It affects + only the talk turn: wandering and `move_route` still turn the NPC. The + validator reports a `directionFix` that is not `true` or `false`. +- **Stands facing.** The heading the NPC stands with when its map loads, + such as a clerk facing the door their desk is approached from. It stores + `'facing' => 'north'` and shows that heading's directional sprite; clearing + it removes the key, which the game reads as `south`. Talking turns the NPC and back + to it. The validator warns about a facing that is not one of the four. - **Directional sprites.** Optional glyphs shown when the NPC turns; the base sprite covers a heading you leave blank. Resting the Inspector cursor on a `Facing …` row previews that glyph on the canvas in the NPC's place. +- **Graphical sprites are not edited in the TUI.** The `Graphical Sprites` + inspector action and its numeric PNG/crop/sheet dialog have been removed. + Existing NPC `sprites2d` data remains preserved when editing terminal glyphs, + and its model services and source-preserving validation remain available. + Graphical NPC authoring belongs in the pending + [GUI editor](https://github.com/ichiloto/gui-editor/blob/develop/README.md), not a terminal form. Terminal base + and directional glyphs, identity, dialogue, movement and conditions remain + editable as before. This correction does not claim that all other actor or + database artwork controls have been removed. - **Dialogue.** Pages are shown as variants: one variant with lines is written back as plain pages; add a second variant, or give one a `When` condition, a `Then Set`, or a `Script`, and the whole thing is written as conditional @@ -361,6 +1045,44 @@ unreachable, wander areas that leave the map or that the NPC starts outside, a script shadowing dialogue, unknown fields - as errors where the authored content cannot happen and warnings where it can but probably not as meant. +### Area Mode + +A map's named areas are territory it owns, with no art, passage or discovery +of their own: an event can be bound to an area as its subject, covering the +area's cells, and a landmark can be anchored to one. `a` enters Area mode. The +cells you select show in reverse video over the map's own glyphs; nothing +here paints a tile or a marker. + +| Key | Action | +| --- | --- | +| `Space` | Add the cursor's cell to the selection, or take it away | +| `r` | Mark one corner of a rectangle; move, then `r` again adds the whole rectangle | +| `Enter` | With cells selected, make an area of them (type its id, then `Enter`), or apply them to the area picked up; with nothing selected, pick up the area under the cursor to change its cells | +| `n` | Step to the next area sharing the cursor's cell | +| `c` | Clear the selection | +| `Del` | Delete the area picked up, or the one under the cursor - refused, naming them, while an event or landmark names it | +| `Esc` | Drop a rectangle corner, then the selection, then leave Area mode | + +A selection that fills a rectangle exactly is written as one (`x`, `y`, +`width`, `height`); any other selection is written as its exact cells. An id +is lowercase letters, digits, `.`, `_` and `-`, starting with a letter. The +Inspector lists the map's areas under **Areas**: rename one there, and the +events and landmarks naming it follow in the same undo step, or remove it. +An event's **Bound To** row chooses its own painted cells, a world object or an +area; choosing its own cells again keeps it on the cells its subject gave it. + +Under **Speakers** an event names the NPCs who speak its lines, chosen from +the map's NPCs that have an id. Adding one (`a` on the heading or a speaker, +or the GUI's add) names the nearest NPC not yet listed; choose another on its +row, and delete the row to remove it. Naming a speaker moves no one: +validation reports, as `event.speaker`, a speaker who is not on the map, is +never shown while the event can run, wanders freely or stands more than one +cell from the event, so stand them beside it or bind the event to what they +stand at. An NPC an event names as a speaker keeps its id and cannot be +deleted until the event stops naming it. +Inserting or removing map rows and columns moves areas with the lines; a +removal through one of an area's exact cells is refused until you reshape it. + ### Canvas Tools The active tool is modal and always shown in the footer, for example @@ -385,11 +1107,12 @@ a cut, and a paste each undo in a single `Ctrl+Z`. Repeated pastes are separate steps, so you can stamp freely. The clipboard is layer-tagged: a block lifted from the event layer refuses to -land on tiles. +land on tiles. A block lifted from a gameplay layer carries the graphical +tiles that move with that layer, and a paste replaces the tiles under it. -Current limit: the canvas draws no on-screen preview of a pending line, -rectangle, or selection rectangle. The footer reports the anchor and selection -size instead. +A pending line or rectangle is previewed on the canvas in the brush glyph from +its anchor to the cursor. A pending selection is not drawn; the footer reports +its anchor and size. ## Inspector Panel @@ -420,6 +1143,78 @@ scrolls by rows, keeping the selected row's first line in view. The Destination row on an event is a reference: `Ctrl+G` follows it. +### Map region + +`Region` is the place name the game shows for where the party is, such as +Happyville or Garden of Roads. Enter opens a picker of the region names the +project's maps already use, so a region is spelled one way everywhere; it is +never typed. Choosing one changes only the map data's `region`: the map stays +in its folder under `assets/Maps`. + +### Map role + +`Role`, under `Region`, is what the map is to the world, one of the project's +[Map Roles](#database-screen), shown by name, such as Overworld, Outside or +Interior. Enter opens the picker; `None` removes it. It is stored as the map +data's `role`. The role's **Leads To** decides where the map's doors and edges +may lead, so an overworld whose role leads only to outside maps and dungeons +cannot lead into an interior. A role the project no longer has stays visible +as `id · not in Map Roles`, and validation names it. + +### Place type + +`Place Type`, under `Role`, is the kind of place the map belongs to, one of +the project's [Place Types](#database-screen), shown by name, such as Town, +Outpost or Shrine. Enter opens the picker; `None` removes it. It is stored as +the map data's `placeType`. A map drawn with a tileset that gives place icons, +such as a world map, shows every entrance into this map by the icon its +tileset gives the type (Database > Tilesets, Place Icons), so a larger +settlement and a single building read differently at a glance. Changing the +type changes the icon every such entrance should show; validation warns at +each entrance whose icon, in its tiles or its terminal glyphs, is not its +destination's type's, and at a place icon over a way into a map with no place +type. Restamp the icon over the entrance with the piece it names, keeping the +entrance cell. A type the project no longer has stays visible as +`id · not in Place Types`, and validation names it. + +### Skit backdrop + +`Skit Backdrop`, under `Place Type`, is the scene a skit's stage shows while +the skit plays on this map, unless the skit's own `Backdrop` names one (see +[Skits](#skits)). Enter opens the project's image picker (the GUI editor +shows each image); `None` removes it. It is stored as the map data's +`skitBackdrop`, a path under `assets`, so replacing the file keeps the +binding. With none, the row says what skits here show instead: its region's +backdrop from the [Regions](#database-screen) page, or no backdrop, which the +game reports with the skit and the map. A path the project no longer has +stays visible as `path · not a PNG in assets`, and validation names it. + +### Map kind + +`Kind`, under `Region`, is the setting the map draws: one of the project's +tilesets in `assets/Data/Tilesets/`, shown by name, such as Interior, +Exterior, World or Dungeon. Every tile and [piece](#pieces) on the map comes +from its kind, so the tiles a map offers change only when its kind does. +Enter opens the picker; the kind is stored as the map data's `tileset`. + +- A new map is asked its kind before it is created (`Shift+A` in the Assets + panel); `Esc` creates nothing. A duplicate keeps its original's kind. In a + project with no tilesets, a new map is created without a kind. +- A map without a kind shows `Not set`, and `P` offers no pieces until it + has one. Choosing a kind keeps any tiles the map already has. +- Changing one kind to another on a map with tiles asks first, since its + tiles name places on the old kind's sheets and would show the wrong art. + `Cancel`, the default, keeps everything; `Clear N tile layers and change` + clears its tile layers and their `tileLayers` settings. Glyphs, collision + and events stay. +- A map without tiles changes kind at once. +- Each change is one undo step and is written on save; saving a cleared map + removes its `graphics/` tile layer files. +- A kind the project no longer has stays visible as + `id · not in assets/Data/Tilesets`. +- In a project with tilesets, validation and the pre-save checks warn about a + map without a kind. A project without tilesets is not warned. + ### Map audio and encounters In Map mode the Inspector carries the map's runtime metadata beneath its @@ -469,6 +1264,13 @@ skip: a missing, empty, or unknown track, malformed conditions, and the unreachable-variant warning above. The editor authors and validates the structure; the engine alone evaluates conditions and plays anything. +**Movement.** `Disable Dashing` is a switch, RPG Maker's map setting of the +same name: on, the player walks on this map even when the game's Dash is on in +Database > System and Dash is held. Switching it on writes the map's +`disableDashing`; switching it off removes the key, so the map follows the +game's dash. A value the Engine would not read stays visible with what it +must be, and validation names it. + **Encounters.** The `Encounters` heading summarises the map's random encounters (`off`, or `2 troops, every ~22 steps, danger tiles`), and the rows beneath it edit the engine's own `encounters` block: @@ -481,6 +1283,15 @@ rows beneath it edit the engine's own `encounters` block: troop is its weight over the sum. Two rows can never name the same troop: PHP would keep the last weight and discard the other without a word, so the editor refuses the edit and says which row already has it. +- Each troop row has `Areas`: the map's named areas (see `Areas` above the + map's events) the troop is met in, typed as area ids separated by commas, + or left empty for `(whole map)`. A troop with areas is met only while the + player stands in one of them, and a step where no troop can be met does not + count, so a map whose troops all live in areas is quiet between them. Only + the map's declared areas are accepted. Renaming an area renames it in every + troop that names it, in the same undo step, and an area a troop names + cannot be deleted until the troop no longer names it. Written + `'Roost Bats' => ['weight' => 3, 'areas' => ['east-roost']]`. - `Rate` is the **average** number of steps between fights, not an exact interval - the engine rolls each gap between half and one-and-a-half times this value. Until you set one it shows `(engine default: 15)`, and @@ -490,12 +1301,107 @@ rows beneath it edit the engine's own `encounters` block: `;` glyphs) or `any` (every step, with danger tiles counting double). Its default, `encounter`, is likewise shown in parentheses and never written by browsing. - -A key inside the block the editor does not own - a field a later engine will -read - is preserved untouched, and keeps the block alive even when the last -troop is removed. A block shaped in a way the editor cannot hold exactly -(troops keyed by number, a weight that is an array) is shown read-only with -the shape named, and no edit anywhere rewrites it. +- In the GUI editor, `Arena` picks the battle presentation's arena a graphical + fight here takes place in, `(default arena)` leaving it to the + presentation's default, and each troop row has an `Arena` of its own that + outranks the map's, `(map arena)` leaving it to the map. A troop with an + arena is written `'Loch Ness' => ['weight' => 1, 'battleArena' => + 'arena.secret-lake']`. Arenas are graphical presentation only: they change + no weight, no fight and nothing the terminal shows, so the terminal editor + keeps them exactly as written without offering them. +- `Arena Rules` (GUI editor) pick the arena for one of the map's areas, one of + its encounter terrains, or a terrain within an area, each chosen from the + map's own; Shift+O adds a rule and Del removes it. A rule for a terrain + draws that arena outright; a rule for an area alone, the map's `Arena` and + the default arena are refined by the arena's terrain variants, so a battle + started in woodland is drawn on the region's woodland arena. A troop's own + arena and `start_battle` arenas stay as authored. Written + `'battleArenas' => [['area' => 'glade', 'battleArena' => 'arena.glade'], + ['terrain' => 'woodland', 'battleArena' => 'arena.deep-woods']]`. + Validation lists every arena and encounter terrain a battle on the map + starts on without a variant, which draws the arena's own background, and + every rule the Engine leaves out. + +A key inside the block or a troop's entry the editor does not own - a field a +later engine will read - is preserved untouched, and keeps the block alive +even when the last troop is removed; an entry written as a map stays one. A +block shaped in a way the editor cannot hold exactly (troops keyed by number, +a weight that is an array, an arena that is not a key) is shown read-only +with the shape named, and no edit anywhere rewrites it. Validation reports an +arena, in an encounter or a `start_battle` command, that the battle +presentation does not declare, or that a project without one names. + +### Minimap landmarks + +`Minimap Landmarks` authors the map's `landmarks` list, the places the +minimap marks (Engine `docs/maps.md`, "Active-map landmarks and discovery"). +A landmark names its anchor and never copies its cells: an event by marker, a +world object by id, or a legacy transfer that carries an `id`. Moving the +event or object moves the landmark. + +- `Unnamed Route Markers` counts the transfers no landmark describes. Every + transfer is marked on the minimap at once without a name; describing one + names it, keeps it secret or makes it an entrance or exit. +- On an event, the `Minimap` row says what the event is on the minimap. A + transfer steps through `Unnamed route marker`, `Passage`, `Entrance` and + `Exit`; any other event through `Not on the minimap`, `Building` and + `Landmark`. Choosing a kind describes the event; choosing the first option + removes the description. In the GUI editor a selected world object has the + same row. +- `Landmarks` is the Inspector's usual list. `Shift+O` describes the next + place no landmark describes yet; `Del`/`Shift+X` removes a landmark. + `Anchor` is picked from the map's events, world objects and legacy + transfers that no other landmark uses, and `Kind` from the kinds that + anchor may be. Moving a landmark between a transfer and anything else makes + it a passage or a landmark accordingly. +- `Id` is the landmark's stable id, shown on its heading. A new landmark + takes an object's own id when it is a valid landmark id, otherwise the next + free `landmark-N`; the row renames it to any id the Engine accepts that no + other landmark on the map has. Other maps name a landmark as their atlas + entry, and a rename changes their `atlas.parent.landmark` in the same undo + step; when one of those maps cannot take the edit, nothing is renamed. The + name's translation key stays. Saves remember what the party has learned by + id, so a save that learned the landmark under its old id learns it again. +- `Category` says what the place offers, whatever its kind: the Engine's + `inn`, `shop`, `home` and `organization`, or a category of the project's + own (see Place Categories). An entrance into a shop is a route with the + shop category. `(none)` leaves the marker to the kind. +- `Name` is the label the minimap shows once the place is known; clearing it + removes the name. A first name takes the Engine's map-scoped translation + key, shown and editable as `Name Translation Key`. +- `Secret` hides the marker until play reveals it explicitly; a secret says + nothing about approach. Otherwise `Learned On Approach` and `Approach + Distance (cells)` set how near the party must come, `(engine default: 3)` + until set. `Revealed When` builds the world conditions that reveal it + through play with the shared conditions editor; an empty list removes them. + +Every edit is checked by the Engine's own validator against the map as it +stands and is one undo step. An event or world object a landmark anchors +cannot be deleted, and an anchored event cannot change between a transfer and +any other type, until the landmark is changed or removed. Validation reports +landmarks the Engine would refuse. + +### Atlas + +`Atlas` places the map in the atlas (Engine `docs/maps.md`). `Scope` is +`Local` (a town, a house, a dungeon; what a map is when it says nothing) or +`Global`, the overworld geography itself. `Terminal View` is how the +Terminal's map draws it: `Places` (simplified terrain and buildings from the +map's Terrain classifications, with a square for each location and a chevron +for each route, for outdoors) or `Floorplan` (rooms and corridors as simple +outlines, for interiors). It shows what the Engine reads for the map's +scope until an author chooses otherwise, and choosing that again says nothing; +graphical maps are unaffected. A local map's `Parent Entry` is the +landmark on another map that is its entrance there: a house's on its town, the +town's on the world. It is picked from the landmarks other maps declare, those +whose door leads to this map first; a global map has no parent. `Global Map` +shows where the chain of parents reaches the global map, as the Engine +resolves it. No coordinates are copied: moving the parent's door moves the +entry. An edit the Engine would refuse (a cycle, a missing landmark, too deep +a chain) changes nothing and says why. A landmark another map names as its +entry cannot be removed until that map chooses another, and deleting or moving +a map lists the maps that name it as parent. Validation reports an atlas the +Engine would refuse. In Event mode, painting or selecting a marker with no definition opens the Event Type picker. **Story Script** creates the engine's @@ -505,6 +1411,15 @@ Select `Script Id` and use the reference picker to choose an existing Common Event; the value is not free-typed. Conditions and completion writes use the same structured inspector-list controls as other event types. +An event triggers on exactly the cells painted with its marker, in any shape: +paint the same marker in two places, such as a town exit on the east edge +and another on the south side, and they are one event. The cells between +placements are not part of it. For a marker that fills a rectangle the +inspector's Position and Size edit that rectangle. For any other shape it +shows `Cells` (how many, in how many places) instead of Size: Position moves +every cell together, keeping the shape, and is refused when a cell would +leave the map or cover another marker; paint or erase cells to reshape it. + ## Database Screen `Ctrl+D` or `F2` opens the Database screen over the main shell; the same key @@ -520,7 +1435,10 @@ Controls: - `Shift+A`: create a new entry in the current category - `Delete`: delete the selected entry (destructive confirmation) - `Shift+O`: append a sub-list entry (objective, beat, member, command, or - movement-route step when a route-step row is selected) + movement-route step when a route-step row is selected); a troop member is + added by choosing its enemy first +- `Shift+C` / `Shift+N` (Troops): clear the troop's members, or name the troop + after them - `Shift+X`: remove the last sub-list entry at the selected level - `/`: filter the entry list - `Ctrl+S`: save the current category @@ -532,6 +1450,22 @@ edited even after the selection has moved on. Current behavior: deleting an entry takes effect in memory immediately, and the file changes on the next save. Undo before saving costs nothing. +### Terrain + +`Terrain` classifies the map's own cells for its atlas, so the Terminal's map +can show simplified terrain (the Engine's `MapAtlasTerrain` roles, such as +ground, roads, water, vegetation, obstacles, highland, buildings and +boundaries) around its place markers. It lists each +source layer in the order the map composes them. `Other Glyphs` gives every +non-blank glyph of that layer one role; each glyph the layer holds, blank +ground included, has a row of its own with its cell count, and a glyph's own +role outranks the layer's. `(unclassified)` draws nothing. Classifying any +glyph binds the layer; clearing the last one removes the binding, and an atlas +with nothing left removes its key. Only the glyphs are named: no grid, +coordinate or collision is copied, so editing the map changes what the atlas +shows. A layer the map no longer has stays listed with a way to clear it, and +validation reports terrain the Engine would refuse. + ### Actors An actor's Inspector opens with **Identity**: a `Definition Id`, which is @@ -539,6 +1473,20 @@ what a save resolves the actor by. A project that declares none is resolved by display name, so the row says so, and renaming such an actor strands every save that named it. +The row's action freezes the current name as the permanent id. When that +is the whole repair, it is one edit, written on Save. When other files name +actors by what the repair changes (skits, maps, other actors), both editors +first list every file it writes; confirming writes them all at once, not on +Save, and Undo restores them. Pending edits are saved or undone first. + +**Attack Style** is the character's own weapon: the weapon type they fight +with when no weapon is equipped, part of who they are and with no stats +(`Unarmed`, the default, writes nothing). A battle's basic attack plays the +animation bound to the equipped weapon's type, else to this style, else to +`attack-unarmed`; equipment stays the stat-bearing upgrade. The row offers +the Engine's weapon types, writes them as the Engine spells them, and +validation reports a written style the Engine does not know. + **Nature** is what this actor is, as distinct from the class it shares with others: an adjustment per canonical stat, applied on top of the class baseline. Adjustments keep their sign, because being slower than the @@ -586,83 +1534,354 @@ resolved-stat preview: the game does not resolve them as layered stats. They Whether a category can be written is *detected*, not assumed. On load the editor evaluates the authored file and asks two questions: can every value be -written back out losslessly, and would a rewrite drop a comment? A category is -editable only when both answers are safe. Anything else is browsable, and the +written back out losslessly, and would a save drop a comment? A file of plain +data (an array literal of values, with no objects) is saved by editing its own +source, so comments, nowdocs and layout inside the data stay as written, a +change the source cannot express is refused rather than flattened, and a file +changed on disk since it was read is refused rather than overwritten. A category +is editable only when both answers are safe. Anything else is browsable, and the status line says exactly why. -| Category | Backing file | Status | -| --- | --- | --- | -| Actors | `assets/Data/Actors/*.php` | Editable | -| Classes | `assets/Data/classes.php` | Editable | -| Skills | `assets/Data/skills.php` | Editable | -| Items | `assets/Data/items.php` | Editable - authored as `new Item(...)` calls, edited entry by entry | -| Weapons | `assets/Data/items.php` | Editable - authored as `new Weapon(...)` calls, edited entry by entry | -| Armors | `assets/Data/items.php` | Editable - authored as `new Armor(...)` calls, edited entry by entry | -| Enemies | `assets/Data/enemies.php` | Editable - authored as `new Enemy(...)` calls, edited entry by entry | -| Troops | `assets/Data/troops.php` | Editable | -| Battle Entry | `assets/Data/battle-entry-rules.php` | Editable | -| States | `assets/Data/states.php` | Editable | -| Animations | `assets/Data/animations.php` | Editable | -| Tilesets | - | Read-only - the engine has no tileset system | -| Common Events | `assets/Events/*.php` | Editable | -| Quests | `assets/Data/quests.php` | Editable | -| Skits | `assets/Data/Skits/*.php` | Editable | -| Knowledge | `assets/Data/knowledge.php` | Editable | -| Knowledge Reports | `assets/Data/knowledge.php` | Editable | -| Knowledge Types | `assets/Data/knowledge.php` | Editable | -| Knowledge Enemies | `assets/Data/knowledge.php` | Editable | -| Permanent Growth | `assets/Data/permanent-growth.php` | Editable | -| Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | -| Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | -| Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | -| System | `assets/Data/system.php` | Editable | -| Types | `assets/Data/Types/*.php` | Read-only - PHP enum declarations | -| Terms | `config.php` (`vocab`, `messages`) | Editable when the config carries no inline comments | +Both editors edit every category through the same service, so a value +changed in one reads the same in the other, and both save it the same +source-preserving way. The GUI shows a record's rows as the terminal does, +with the summaries the terminal keeps in its side panes (a class's curves, a +skill's effects, a quest's rewards) listed under them. + +| Category | Backing file | Terminal editor | GUI editor | +| --- | --- | --- | --- | +| Actors | `assets/Data/Actors/*.php` | Editable | Same, plus `images.field2d` artwork selected for the established actor id | +| Classes | `assets/Data/classes.php` | Editable - levels, the experience curve, every stat curve, equipment types and skills learned | Same | +| Skills | `assets/Data/Skills/*.php` | Editable - one data record per numbered file, attacks, abilities and spells alike, with their effects; `skills.php` loads them | Same | +| Mastery Regions | `assets/Data/mastery.php` (`regions`) | Editable - the regions of the mastery tree every character shares, each with the nodes on its trunk | Same | +| Mastery Branches | `assets/Data/mastery.php` (`branches`) | Editable - each region's branches (element, aspect or non-elemental) with their nodes in the order they progress | Same | +| Mastery Maps | `assets/Data/Mastery/*.php` | Editable - one file per character stating their own terms for tree nodes: price, opening level, what each builds on | Same, plus a graphical page that places and links the character's nodes | +| Items | `assets/Data/Items/*.php` | Editable - one data record per numbered file, with scope, occasion, animation and effects; `items.php` loads Items, Weapons and Armors | Same | +| Weapons | `assets/Data/Weapons/*.php` | Editable - one data record per numbered file, with type, stats, element, affinities and equipment metadata | Same | +| Armors | `assets/Data/Armors/*.php` | Editable - one data record per numbered file, armor or accessory, with type, slot, stats and affinities | Same | +| Enemies | `assets/Data/Enemies/*.php` | Editable - one data record per file, which `enemies.php` loads | Same | +| Troops | `assets/Data/troops.php` | Editable | Same | +| Battle Entry | `assets/Data/battle-entry-rules.php` | Editable | Same | +| States | `assets/Data/states.php` | Editable | Same | +| Animations | `assets/Data/animations.php` | Editable | Same | +| Tilesets | `assets/Data/Tilesets/*.php` | Editable - name, RPG Maker sheets, missing-art tile, above and table tiles, shadow, pieces by id with their glyphs and tiles, and place icons (the piece shown for each place type) | Same | +| Common Events | `assets/Events/*.php` | Editable | Same | +| Income | `assets/Data/income.php` (`schedules`) | Editable - cadence, interval, idle timeout, pay, delivery, run conditions and an ordered pay-rate table; the id follows the name until a script names it | Same | +| Quests | `assets/Data/quests.php` | Editable - objectives (the target picked by type) with their waymarks, reward items with quantities, prerequisites, optional; the id follows the name until something refers to it | Same | +| Field Cues | `assets/Data/Presentation/field.php` (`cueStyle`, `cues`, `waymarks`) | Editable - one row per setting: bob height and seconds, edge pointer spacing across and up or down, Terminal background and bold; each cue colour's mark effect, plate image and its eight pointer images and turns; the waymark colour, one-cell symbol and edge inset. Engine defaults show until set | Same | +| Skits | `assets/Data/Skits/*.php` | Editable | Same | +| Knowledge | `assets/Data/knowledge.php` | Editable | Same | +| Knowledge Reports | `assets/Data/knowledge.php` | Editable | Same | +| Knowledge Types | `assets/Data/knowledge.php` | Editable | Same | +| Knowledge Enemies | `assets/Data/knowledge.php` | Editable | Same | +| Permanent Growth | `assets/Data/permanent-growth.php` | Editable | Same | +| Optimize Weights | `assets/Data/equipment-optimization.php` | Editable | Same | +| Optimize Outcomes | `assets/Data/equipment-optimization.php` | Editable | Same | +| Optimize Exclusions | `assets/Data/equipment-optimization.php` | Editable | Same | +| System | `assets/Data/system.php` | Editable - title, starting gold, party, inventory and position, battle engine, openings and time gauge settings, whether a level up restores HP and MP (on unless turned off), and the dash (Dash, Dash Speed Multiplier) | Same | +| Configuration | `config.php` (`save`, `accessibility`, `ui`, `field`, `graphics`, `audio`, `inn`) | Editable - one row per setting, typed by what it holds; field zoom is kept from 1 to 8 | Same | +| Types | `assets/Data/system.php` | Editable - the project's elements, one per row; an empty list means the Engine's defaults. Weapon, armor and equipment types are the Engine's own | Same | +| Terms | `config.php` (`vocab`, `messages`) | Literal terms editable; comments and unrelated expressions preserved | Same | +| Dialogue Portraits | `assets/Data/Presentation/dialogue.php` (`actors`, `resources`) | Editable - one record per speaker identity: what it speaks as (an actor, chosen, or a non-actor character with a stable id), its dialogue portrait and skit bust, and its emotions, each a name with its own portrait and bust, chosen as project images | Same, with each image shown beside its path | +| Speaker Names | `assets/Data/Presentation/dialogue.php` (`speakers`) | Editable - one record per label lines are spoken under, and the dialogue portrait it shows | Same | +| Place Categories | `assets/Data/map-categories.php` (`categories`) | Editable - one record per category: stable id, name and translation key, and the generic category it specializes | Same | +| Map Roles | `assets/Data/map-roles.php` (`roles`) | Editable - one record per role: stable id, name and translation key, and the roles its maps may lead to | Same | +| Place Types | `assets/Data/place-types.php` (`types`) | Editable - one record per type: stable id, name and translation key | Same | +| Regions | `assets/Data/regions.php` (`regions`) | Editable - one record per region, keyed by its name: its skit backdrop, chosen as a project image | Same, with the image shown beside its path | +| Player Field Appearance | `assets/Data/Entities/player.php` | Not shown: graphical ownership and art | Choose fixed-player, selected party leader, or absent legacy selector; fixed sheet picker with index/layer controls | +| Field Resources | `assets/Data/Presentation/field.php` (`resources`) | Not shown: graphical resources | One record per named resource, keyed by its stable id: name, ground contact, an image or a sign board, and an optional occupancy stamp | +| Battle Arenas | `assets/Data/Presentation/arenas.php` (`arenas`) | Not shown: graphical battle presentation | One record per arena, keyed by its stable id (set when it is created): name, background image and optional crop, and the ground its picture shows: far edge (`Ground Far Edge`, just below the horizon or walkable edge), near edge and far-edge scale, each defaulting to the battle theme's. Every troop's formation stands on the chosen arena's ground. `Terrain Variants` name the arena drawn for each encounter terrain a random battle on this arena starts on (`variants`, terrain => arena), the arena itself when its picture already shows that terrain | +| Default Battle Arena | `assets/Data/Presentation/arenas.php` (`default`) | Not shown | The arena a battle that names none is fought on | +| Text Roles | `assets/Data/Presentation/text.php` (`roles`) | Not shown: graphical text | One record per text role: the project's fonts, first choice first, a size and a colour | +| Menu Theme | `assets/Data/Presentation/menus.php` (`atlas`, `icons`) | Not shown: graphical presentation | One row per setting: the minimap atlas's corner sizes, margin, marker size and frame, and an image per icon role | +| Actor Battle Art, Enemy Battle Art, Battle Scale | `assets/Data/Presentation/battlers.php` | Not shown: graphical battle art | Editable from the actor's or enemy's own page (see Battle Art) | + +Player Field Appearance edits only `graphicalSubject` and `sprites2d`, preserving +the player's Terminal glyphs and unrelated authored source. Selecting +`party-leader` retains inactive fixed art read-only and uses the selected actor's +`images.field2d` role; missing leader art is diagnosed, never replaced with +another actor. Actor and fixed-player sheet pickers share current-file +four-direction previews. Clearing removes only the optional binding; undo/redo +and save/reopen restore its settings. Unsupported expressions and external +source conflicts are refused before overwriting files. These controls have +automated coverage; native visual inspection remains pending. + +Menu Theme edits the game's shared menu presentation catalog by setting, as +Configuration edits `config.php`. It offers the minimap atlas: the compact and +larger corner sizes, their margin, the marker size and the frame (`quiet` or +`panel`) the atlas is drawn in, all in logical pixels of the GPUI atlas, and +the corner map's opacity, from 0 (clear) to 1 (solid); markers and the opened +map stay solid. Each +setting the catalog leaves out shows the Engine's default, and choosing that +default writes nothing. A value is checked by the Engine with its neighbours, +so the larger corner must first be widened before the compact one may exceed +it. Only changed leaves are written; comments and settings other pages own are +kept. A project without `menus.php` shows the category read-only and says why. +Menu Theme also binds icon images by role (`icons`): every role the catalogue +already binds, and every marker the atlas draws, offered even before one is +bound: `map.player`, each landmark kind, and `map.category.` for each place +category, the project's own included as soon as its Place Categories record +exists. Each is a project image chosen with the shared picker; `(Engine +pictogram)` leaves the Engine's generic marker, and choosing it for a bound +role removes the binding. An image the Engine cannot draw is refused. The +Terminal's maps keep their own symbols, so the Terminal editor does not list +this category. + +Place Categories name the kinds of place a game marks on its maps beyond the +Engine's generic `inn`, `shop`, `home` and `organization`: a school, a temple, +a guild. Each record has a stable id, a name with its translation key, and the +generic category it specializes, whose marker it shows until the Menu Theme +gives it one of its own. A new category starts as a kind of `inn`, the +Engine's first generic category, for the author to change. Its id may change +until a landmark uses it; while one does, the id stays and the category cannot +be deleted. + +Map Roles name what a game's maps are to its world: an overworld, an outside +place, an interior, a dungeon. Each record has a stable id, a name with its +translation key, and **Leads To**, the roles a map of this role may lead to, +chosen from the project's roles; `(any map)` leaves it unrestricted. A map +chooses its role on its [Role](#map-role) row. Validation and the pre-save +checks then hold every way off a map of a restricted role to those roles: +transfer events, edge triggers and repeatable scripted transports, from events +and NPC pages alike. A way to a map of another role is an error; a way to a map +with no role is a warning, since it cannot be checked. A new role leads +anywhere until the author chooses. Its id may change until a map is of it; +while one is, the id stays and the role cannot be deleted. + +Place Types name the kinds of place a game's maps lead into: a city, a town, +a village, a castle, an outpost, an inn, a shrine, a cave, a wild area. Each +record has a stable id and a name with its translation key. A map chooses the +type of its place on its [Place Type](#place-type) row, and each tileset gives +a type its icon under **Place Icons** on its Database > Tilesets record: a +place type and the piece shown for it, chosen among the tileset's pieces +stamped whole. Validation then holds every entrance on a map drawn with that +tileset to its destination's type. Its id may change until a map is of it or a +tileset gives it an icon; while one does, the id stays and the type cannot be +deleted. + +Regions hold what a whole region shows, one record per region, chosen among +the region names the project's maps use. A region's **Skit Backdrop** is the +scene skits show on any of its maps whose own Skit Backdrop is none, interiors +included, so give a region one only where it suits every such map. A region +needs no record to be a region; validation warns about a record no map is in. + +Field Resources are reusable whole images, such as a tree, that a map places by +reference. A resource owns its art and its ground contact (the pivot, such as a +trunk base) and may carry an occupancy stamp: cells relative to a placement's +anchor and the physical type a brush gives each when it places the resource. +The stamp is applied only by an explicit authoring placement; a resource never +adds collision to a map by itself, and changing a stamp never changes cells an +earlier placement stamped. A resource whose art paints a door, such as a cave +mouth or a town's front door, lists those cells under **Doorway**, relative to +a placement's anchor, each with its **Entrance**: `on` (the default) when the +player walks onto the painted opening, `front` when the door is painted on a +solid face and entered from the cell directly below it. Validation then +requires an entrance (a transfer event) exactly there wherever the resource is +placed, and none on a `front` door itself, so correct the doorway here or the +art itself when they disagree, never the map's entrance. +A resource whose art has a feature visuals attach to, such as an aerial a +signal pulses on, lists it under **Attachments**: a **Stable Id**, the +**Terminal Cell X/Y** that shows the feature, relative to a placement's anchor, +and the **Art Point X/Y (0..1)** where it is on the image, measured like the +ground contact. When the art is replaced or re-cropped, update the art point +here once; every visual staged on the feature follows it. +Only the `resources` dictionary is edited; the +catalogue's cues and action prompt are kept exactly as written. A new resource +starts on the project's first image for the author to replace, an id is refused +as it is typed if the Engine would refuse it, and a save the Engine would refuse +writes nothing. A map's world object that names a resource shows a Field +Resource picker in place of its own pivot and art, which the resource owns. +Every world object offers that picker, a new one included: choosing a +resource makes the object a placement of it, dropping its own pivot, art and +variants, and choosing none gives it back an art of its own, starting empty +on the ground, as a newly created object does. Every world object, a placement +of a resource or one with its own art, also has Visible When: the saved world +conditions under which the game shows it, all of which must hold. None means +always shown; clearing the list removes it from the map. Hiding an object +changes only what is drawn: its cells, collision and events stay as authored, +and the map's own glyphs show where it stood. + +A resource shows an image or a sign board (Shows). A sign board names its +board image, cut into fixed left and right caps and a centre that repeats so +the board widens to fit its label, the text role its label is set in, the +label's padding and the board's width limits; cuts are in source pixels, +padding and widths in logical pixels. Switching what a resource shows keeps +the image chosen. Text Roles name the project's own font files, first choice +first, with a size and a colour: the Engine embeds no font, so a project uses +whichever fonts it has the right to. A new role starts on the project's first +font, and the file gains its schema id when it is first written. A role's +Glyph Effects give its letters an outline and a shadow together, each with its +own colour: a one-pixel shadow down and to the right lifts letters off a board, +and a light one cut the same way reads as engraving. Choosing None removes them. +Effects written in the file as constructor calls are shown but never rewritten. + +A sign is placed as a world object naming a sign-board resource, with the +text its board shows. Its label is translatable from the start: the text is +the default, under a translation key of the sign's own id +(`signs.`), which translations supply. A sign without text, or text on +what is not a sign, is refused before anything is written; changing a +placement's resource to a sign gives it a label, starting on the board's name +for the author to change under Sign Text, and changing it away removes it. So +a sign can also be made by creating an object at a cell and choosing its board +as the Field Resource. Neither the label nor the board changes the map's collision. The GUI +editor's Game view draws a placed sign's board and label as the game does, +laid out by the window in its text role; until the label is laid out, the +sign's cells show their glyphs. + +Forests are drawn with the tree brush. A tree type is any image resource with +an occupancy stamp, its trunk. Choose the types to vary between and drag over a +region: the brush plants whole trees across it, choosing type and spot in an +order fixed by the map and cell, so drawing the same region again plants +nothing new and drawing beside existing trees fills the gaps between them. +Crowns may overlap; trunks keep half a crown apart. A trunk goes only on +plain floor that no event, NPC or other trunk takes, never outside the +region, and never where making it solid would cut a walkable area in two, so +every cell the player could reach stays reachable, grass, exits and save +points included. Each trunk becomes solid in +the map's own collision, never through its image, so the brush asks you to +Separate collision first on a map that does not have it yet. Erasing removes +the trees standing in the region and makes their trunk cells walkable again, +unless another tree still stands there. The terminal sees the same forest on a +`canopy` gameplay layer the brush owns and redraws, on top of the map's other +layers: bright green `(` `)` crowns, as wide as the image above the trunk and +narrowing upwards, over gray `|` trunks. Its glyphs never decide collision, +which the map's own collision holds. Each tree covers the canopy cells it +draws, a crown shared by two trees going to the tree drawn first, so the +graphical field and the Game view show the tree's image there instead of its +glyphs, and the glyphs again if the image cannot be shown. A canopy left as a +decoration layer, which the terminal never draws, becomes gameplay the next +time trees are drawn on the map, even where none are planted. A drawing that would take the map past its 128 world objects is +refused with the number that still fit. Each drawing or erasing is one undo +step and is written on Save. The GUI editor's Trees tool chooses the types and +covers the region with any brush shape, Shift or the eraser erasing; in the +terminal editor, `Ctrl+P` offers **Map: Draw trees in the selection**, which +varies between every tree type, and **Map: Erase trees in the selection**. Why a category can still turn out read-only: a file the editor cannot -evaluate, a value it could not write back out, or a comment sitting inside -the returned data are each a reason, and the status line names it. You can +evaluate, a value it could not write back out (such as an object only its own +factory may build, which a rewritten file could not call), or a comment inside returned +data that holds objects or is not an array literal are each a reason, and the status line names it. You can still browse everything: an enemy shows its level, every stat, its sprite, its battle rewards, and its element affinities. -### How A File Of Constructor Calls Is Written - -`items.php` and `enemies.php` are not data, they are PHP code that *builds* -data: `new Item(...)` and `new Enemy(...)` calls with named arguments, -imports, comments, and enum expressions the author chose. Regenerating such -a file from loaded values would reorder arguments, spell out defaults nobody -wrote, and rewrite every entry to change one. So the editor does not -regenerate it. It edits the author's own source, entry by entry: - -- **A changed value** is patched where its argument sits. Every other byte of - the file - the other arguments, the other entries, the comments between - them - is the same afterwards. -- **A new entry** is written as a constructor call in the file's own - indentation, after the last entry. -- **A deleted entry** is cut whole, with its separator. -- **A deleted entry put back** by undo goes back exactly where it was when - the file has not been saved in between, and, when it has, is written back - ahead of the entry that follows it in the list - so the file reads in the - order the editor does. - -Every entry is found by the identity it declares - an item's stable id, an -enemy's name - looked up in a fresh reading of the file at the moment of -writing, never by where it happened to sit when it was loaded. That is what -lets three categories share one file: Items, Weapons and Armors are three -views of `items.php`, and saving one of them, or all of them with `Ctrl+A`, -reads the file once, composes every dirty category's changes against that -one reading, and writes it once. - -Where identity cannot prove the address, nothing is written and the status -line says why: two entries in the file declaring one id, an entry declaring -none, or a save that would leave two entries declaring one id. Give each -entry a distinct id, reload, and save again. +### How Data Files Are Written Files that are data - Troops, States, Permanent Growth - are regenerated as data, keeping everything from ` Enemy::class, 'data' => [...]]` with plain values: name, +level, sprite, stats, rewards, element affinities, state resistances, its +knowledge subject, and action patterns that name a skill from the skill +catalogue with a rating and an optional condition. Its drops are a list +under the Drops heading: each picks an item, weapon or armor and gives its +chance from 0 to 1. Validation reports a drop that names no item or one the +project does not define, as it does for any record's references. `enemies.php` is the +barrel that loads the folder, so the game and every other reader get the +same enemies. Editing a value changes only that value in that enemy's file, +keeping its imports and comments. A new enemy gets a file named after it +and starts on the project's first enemy sprite; a duplicate gets a file and +a name of its own. A file in the folder that is not an enemy record stays +in the list, read-only, saying why. + +A skill is one file under `assets/Data/Skills`, returning +`['class' => Skill::class, 'data' => [...]]`. Its kind is a value: `basic` +(an attack), `special` (an ability) or `magic` (a spell, which also states +its effect type). The rest is cost, cooldown, occasion, scope, how it is +invoked, its animation, and its effects: a list under the Effects heading, +each a type with that type's own values (a formula with its element and +variance for damage and recovery, a state for adding one, the states to +remove, a stat and its stages, or what a reveal uncovers about a foe and +its chance). A reveal's facts are picked from the Engine's own list: `hp`, +`mp`, `stats` and `affinities` (the element that strikes the foe hardest). Changing an effect's type, or a skill's +kind, drops the values only the old one read. Files are numbered +(`0001-attack.php`) because menus list skills in file order; a new or copied +skill takes the next number and goes last. `skills.php` is the barrel that +loads the folder. + +A skill whose values its author has not decided yet (its MP cost, when it +can be used, whom it reaches, its effects, or one effect's amount or spread), or whose effect the +Engine does not offer yet, lists those values under Awaiting Author +(`unassigned`) and leaves them out of the record. The Engine keeps it as a +draft: its name is taken and the mastery tree can place it, but nobody can +learn, cast or be given it, and nothing guesses the missing values. +Validation lists every draft and what it awaits. + +The mastery tree, in `assets/Data/mastery.php`, is shared by every +character: anyone can reach any node. Its regions (Mastery Regions) hold +nodes on their trunks, ungraded applications not yet placed on a branch. +Its branches (Mastery Branches) each grow from a region and practise one +element, one aspect, or the region's non-elemental practice. A branch may +instead grow Within Branch another branch of its region: the game shows it +as a sub-family inside that branch's family, with its own heading, Summary +and count, and where the outer branch has a foundation, the sub-family's +chain grows from it. Its node ids stay as they are, so saves are unaffected. +Their nodes +are listed in the order they progress, and each node's kind places it in +the chain. A foundation comes first and may grant a skill or nothing yet; +graded applications (I, II, III) climb from it; advanced applications +follow the highest grade, and an advanced application that Extends another +(a single-target spell's multi-target evolution) follows that one and +stands below it on the board; a special grade runs from its demi phase to +the full phase that extends it. Each node builds on the step before it, plus +anything it also requires. A node may carry a price and opening level for +everyone; without a price, only characters whose own map prices it can +learn it. Validation warns where the tree prices a node below the step it +builds on, or opens it earlier. A character's Mastery Map states their own +terms for some nodes: those replace the tree's terms for them in full, +including what the node builds on, and validation warns where their terms +leave out a chain step. A node whose skill is a draft, or that nothing +prices, is unavailable: the game offers it to nobody until its author +finishes it. So is a foundation that grants no skill while nothing built on +it can be learned yet. A character who learns a node built on such a +foundation counts the foundation as learned too. Each region and branch +has a Summary, and a region with trunk nodes also has a Trunk Summary for +the family those nodes form: the game's Info shows them on the +discipline, the family and its foundation. Validation warns where one is +missing, because Info then offers nothing there. + +Items, weapons and armors are one file each under `assets/Data/Items`, +`Weapons` and `Armors`, returning `['class' => InventoryItem::class, 'data' +=> [...]]` with a `kind` of `item`, `weapon`, `armor` or `accessory`, a +stable id, and only the values the record sets: the Engine's defaults fill +the rest. An item has its scope, occasion, battle animation and effects: a +list under the Effects heading, each a type (HP or MP recovery, resurrection, +HP damage, or a max HP or MP increase) with a value, a success rate and +whether the value is an amount or a percentage. Equipment has its type, +parameter changes, element and affinities, and its metadata (form, size, +material, slot, availability, acquisition policy, special property). An +armor that becomes an accessory drops its equipment type. Files are numbered +because shops and menus list definitions in file order, items first, then +weapons, then armors; a new or copied record takes the next number and goes +last. `items.php` is the barrel that loads all three folders. A record the +game cannot build, such as a sell rate outside 0 through 10000, is left out +of the game and reported by validation against its own file, in the Engine's +own words. + +Equipment is shown everywhere, in every renderer, by the one icon of its +type (one for all swords, one for all daggers), which the game's theme +binds. A weapon's or armor's own `icon` is legacy compatibility data: the +Weapons and Armors records show it as a read-only `Legacy Icon` row and keep +it exactly as written. A consumable item's `Icon` stays its own and +editable. + +A tileset is one file under `assets/Data/Tilesets`, named by the id maps +give it, so renaming a tileset keeps its file. It names its RPG Maker sheets +(each picked from the project's PNGs), the tiles drawn above characters and +those drawn as tables (tile identities, comma-separated), the shadow its raised +tiles cast (casters, width and opacity, all three or none), the tile that marks +missing art, and its pieces, keyed by piece id. A stamped piece has glyph +rows, an optional effect, and tile rows on each tile layer; a connected piece +(`Connects: lines`) has a glyph for each shape and a tile on each layer, one +for every shape or one per shape (`horizontal: 5888, vertical: 5890, corner: +5892`). A new piece gets an id no other piece has, and an id another piece +already has is refused, since keys are what maps stamp by. Validation reports +a tileset the Engine would refuse, whether or not a map uses it yet. + ### Project-Owned Parameters A weapon or armor's **Special Property** parameters and a Permanent Growth @@ -693,13 +1912,79 @@ pairs, and the line reads back exactly what was written: Status effects, read by the engine's `StateRegistry`. Fields: `Id`, `Name`, `Icon`, `Description`, `Duration Turns`, `Tick Formula`, -`Prevents Action`, `Persists After Battle`. +`Prevents Action`, `Persists After Battle`, `Disposition` (`harmful`, the +default, `beneficial` or `neutral`: whether it harms or enhances its bearer, +which battle poses follow), and `Confusion Chance %` (0 to 100: the chance +each turn that the bearer attacks a random battler of either side instead of +acting as it chose). `Id` is the only field the engine requires. Optional fields disappear from the file when you clear them, so a state with no duration lasts until it is cured rather than being written as `0`. `Tick Formula` is the same formula language skill effects use, with `$target` bound to the afflicted battler. +### Animations + +An animation names what plays on the caster and the target of a battle +action: its **Caster Effect** and **Target Effect**, each one of the +project's effect timelines (authored on the Cutscenes screen, where +terminal glyph frames are timeline tracks), picked from a list. `Id` is +read-only; a new animation takes the next number after the largest. Like +every Database category, edits are undoable and only the values you change +are rewritten in `assets/Data/animations.php`, so its comments stay. + +**Roles** say which battle actions play the animation when they name no +animation of their own. Enter opens a picker of every role the Engine +supports; each pick adds or removes one role: + +- `attack-sword`, `attack-staff`, `attack-flail` and the other weapon types: + a party member's basic attack with that weapon equipped; +- `attack-unarmed`: a party member's basic attack with no weapon; +- `attack`: enemy and other basic attacks; +- `skill` and `restorative`: skills and restorative magic with no animation + of their own. + +A role plays only when exactly one animation holds it, so the picker names +the animation that holds each role, and taking one held elsewhere is refused +until it is removed there. Validation reports a role the Engine does not +support (the runtime then skips the whole animation), a role held twice, and +a role the project's battles reach with no animation: the neutral attack, +the unarmed attack, and each weapon type its weapons use. + +An older animation that carries its own frames and cues keeps them exactly as +written, shown read-only as **Legacy Frames** and **Legacy Cues** beside its +**Legacy Position**; the Engine still plays them through its importer. New +animation is authored as effect timelines. + +Such a record can be converted to a timeline: **Shift+T** in the terminal, +or **Convert to timeline** on its page in the GUI editor. Both use the same +conversion, which lists what plays the record now (skills, items, roles and +field scripts) and asks for the timing its consumer needs. Nothing is filled +in for it: + +- Who plays the timeline: + - a battle paced by its phases; + - a battle at a fixed rate; + - a field script at a fixed rate. +- For a battle, whether the record names it as its **Target Effect** or its + **Caster Effect**. +- The timeline's name, its frames per second (for a fixed rate), the ticks + each original frame lasts, and the rest frame shown with reduced motion. +- Whether the record's flash cues become flash tracks. Battles have always + played them; field animations never did. + +Preview shows every file as it would be written: the new +`assets/Animations//.timeline.php`, and for a battle the record +naming it. In the terminal it opens a review of those files: Up/Down read +them a line at a time, Enter writes them, and Esc goes back to the choices. +The pane follows the cursor, so the selected choice and every reviewed line +are shown at any terminal size. Write applies exactly that preview as one +undo step. A changed +choice, or a file changed since, needs a fresh preview, and pending edits are +saved or undone first. The record's legacy frames and cues stay as written, +and field scripts keep playing the record until their own command names the +timeline. + ### Troops Encounter groups. Each troop has a `Name`, an optional `Escape Policy`, and a @@ -708,14 +1993,98 @@ list of members flattened into the settings pane as `Member 1 Enemy`, engine default (`allowed`); choose `forbidden` for a battle that must be won or resolved by its authored continuation. -`Shift+O` appends a member, `Shift+X` removes the last one. The `Enemy` value -must match a name in the Enemies category. +Members are composed as in RPG Maker's Troops tab. `Shift+O` opens the +project's enemies (type to narrow, `Enter` to choose) and adds a member +fighting the one chosen, with the cursor on it; `Esc` adds nothing. +`Shift+X` or `Del` removes the member the cursor is on, `Shift+C` clears every +member and `Shift+N` names the troop after its members (`Regular Bat x 2, +Marsh Wisp`), numbering a name another troop already has. Each is one undo +step. A new troop starts with no members. + +A new member starts at the blank terminal position and has no `Battle +Placement`. Set its terminal `X`/`Y` here. The graphical battle stands every +member without a placement itself, beside the placed ones, in a staggered +echelon spaced by the members' current art, front rank on the ground's near +edge. A placement is `x, depth, width, height` (and optionally a display +scale): the column the member's feet stand on and how far back from the +arena ground's near edge, in canvas units, so one formation stands on every +arena's own ground; width and height are its movement and legacy fitting +limits. Set it on the GUI editor's troop canvas (Align, Arrange +Automatically, or dragging a member) or type it into the row. A placement +written as a fixed canvas `y` (before October 2026) is refused with what to +do: place the member again. + +The terminal and graphical positions are independent: the terminal `X`/`Y` +never moves a graphical battler, and the graphical placement never changes +the terminal battle. + +`Battle Music` names a track in `assets/Audio/BGM` that the battle plays in +place of the project's battle theme; left unset, the project's theme plays. +Keys the editor does not show, such as a troop's `id`, are kept as they are. + +A member's `Cannot Fall Below HP %` (the Engine's `knockoutFloorPercent`, +0 to 99) holds it above that share of its HP against every kind of battle +damage, so an event page can end the fight before an ordinary knockout. + +`Event Pages` are the troop's battle events, as in RPG Maker. `Shift+O` on the +heading adds a page that runs on turn 1. A page has a `Name`, a `Span` +(`battle`: once per battle; `turn`: once per turn; `moment`: at every check +where its conditions hold) and conditions, every one of which must hold: +`Turn` with `Then Every N Turns`, `Enemy` (chosen from the troop's own members, +as `1 · Djin`) with `Enemy HP At Or Below %`, `Party Member` with `Party Member +HP At Or Below %`, and `World Conditions` (switches, variables, story events). +A blank or 0 leaves a condition out. `Enter` on its `Commands` opens them; the +command types offered are the ones a battle page can run, including `concede` +(an enemy yields: it counts as defeated, stays on screen and does not fade out +like a knockout) and `end_battle` (victory, escape or defeat). A member a page +names cannot be removed until the page names another, and pages follow their +members when another member is removed. Saving refuses a page the Engine would +refuse, with the Engine's own message, and validation reports the same. A troop may also declare a `Classification` - `ordinary` or `boss` - which battle-entry rules match against. An omitted classification is the engine's `ordinary` default; the picker shows that without writing the key into older data. +### Counter Attacks + +An actor, an enemy, a state or a non-magic skill can grant a **Counter +Attack**: the skill its bearer answers with when a physical hit lands on +it, after the attacker has returned, never chaining into another counter. +It is off unless chosen. The picker offers only skills the Engine accepts +for a counter (a battle-usable basic or special skill aimed at one living +opponent), and choosing none removes the grant, as the Engine reads one +left out. A state grants it while active, and a skill once learned; spells +do not grant one. Validation reports a grant the Engine would refuse, with +the Engine's own reason. + +### Battle Art + +The art an actor or enemy fights with in a graphical battle is bound to it as +data in `assets/Data/Presentation/battlers.php`, the Engine's battler +bindings file. Per actor (by definition id) or enemy (by name) it holds: + +- Image: base artwork, picked from the project's PNGs. +- Ground point: where the battler stands on its image, from 0 to 1 across and + down. It defaults to bottom centre. +- Poses: each role the battle shows (idle, attack, guard, knockout and the + rest), a still or a sheet of frames with columns, rows, frames, frames per + second, looping and a rest frame. +- Body profile: its size against the scale reference, and the share of its + image that size measures. + +The file can also name the scale reference: the actor every battler's size is +measured against, and that actor's height. Image sizes are always read from +the files, so replacing an image needs no edit here. + +This is graphical presentation, so it is set in the GUI editor, on the actor's +or enemy's own page, and the terminal editor does not list it. Each battler +has one owner. Art the project's `battle.php` registers in code stays there: +the editor says so and does not rewrite program code. A battler's art is set +here once it moves to `battlers.php`. A save that the battle could not read is +refused before anything is written, and so is one that binds a battler +`battle.php` also binds. + ### Battle Entry Rules the engine applies as a battle begins, in @@ -773,16 +2142,98 @@ definitions claim fails loudly rather than resolving to either), stats that are canonical but not stage-capable, and quest writes. A missing file means no rules and no finding, exactly as the runtime treats it. +### Income + +Recurring pay for the party, in `assets/Data/income.php`: a salary, a +stipend, rent. A project without the file has no income; saving the first +schedule creates it. The Engine's income guide (`docs/income.md` in the +Engine) describes the runtime. + +Each schedule has: + +- a `Name`, shown as the title of its notices; the `Id` follows the name until + a script names the schedule, and then stays, so `payday`, `collect_income` + and `adjust_income` keep resolving. A schedule a script names cannot be + deleted; the status line lists the scripts; +- `Runs When`, the shared world-condition editor: while the conditions do not + hold, nothing is counted and `payday` pays nothing; +- `Counts`, the cadence: `active_time` (seconds of play, menus and idle time + excluded), `steps`, `battles` (battles won) or `event` (only `payday`); +- `Pays Every`, the seconds, steps or won battles per payment. The `event` + cadence has none, and changing to it drops the value; +- `Idle After (s)`, for `active_time` only: how long without input before play + stops counting. The Engine's 120 shows until set; +- `Pay`, the amount per period; +- `Delivery`: `deposit` (straight into party gold, the default) or `hold` + (kept until a `collect_income` command collects it); +- `Pay Rates`, an ordered table flattened into the pane as `Rate 1 When`, + `Rate 1 Pay`, and so on; `Shift+O`/`Shift+X` add and remove a row. The first + row whose conditions hold when a payment is made sets that payment, so a + rank variable, a story event or a quest can set the pay. `Pay` applies when + no row holds. + +Validation reads each schedule as the game will and names every one the game +would refuse. The notices' wording lives in Terms (`messages.income.*`). + ### Terms The `vocab` and `messages` trees of the project's `config.php`, flattened to one row per term with its dotted path - `vocab.game.new_game`, `messages.confirm.quit`, and so on. -Current limit: the category is read-only when `config.php` contains comments -inside the returned array, because rewriting the file would drop them. Move -such comments above the `return` statement and the category becomes editable - -everything before `return` is preserved byte-for-byte on save. +Terms and Configuration share one source-preserving configuration owner. +Saving either writes all pending configuration edits in one atomic operation. +Only edited literal values are patched; comments, spacing, enum references and +unrelated executable expressions remain untouched. An opaque term expression +is individually read-only, and ambiguous parent arrays or nonliteral returned +configuration are refused rather than regenerated. A config changed on disk +must be reloaded before saving, so newer external edits are not overwritten. + +### Configuration + +The rest of the project's `config.php`: the `save`, `accessibility`, `ui`, +`field`, `graphics`, `audio` and `inn` settings, one row per setting with its dotted +path. A switch is a switch, a number a number, and a setting authored as an +enum case (a selection colour, a window position) is a choice of that enum, +written back as the case. A setting the engine reads with a default is listed +even where the file leaves it out, showing that default; choosing the default +writes nothing. + +#### Save Point Script + +`save.point_script` is the event script that runs when the player steps onto a +save point, picked from the project's event scripts (None keeps the Engine's +save point notice, worded in Terms as `messages.save.point_prompt` and +`messages.save.point_title`). Validation names a script that does not exist. + +#### Volumes + +`audio.master_volume` (default `75`) and, beneath it, `audio.music_volume`, +`audio.sfx_volume` and `audio.voice_volume` (default `100` each) are the +volumes the game starts at, each a whole number from `0` to `100`. A sound +plays at the master volume times its bus's volume times its own volume +(`audio.sound_volumes.`), so a game sets how loud music sits beside +sound effects and voices without editing any file. Players change all four +in the game's Options and Config; their choices stay in their player +settings, and these values only decide where a new player starts. A value +outside the range is refused before anything changes. + +#### Always Dash + +`field.always_dash` is the first value of the player's Always Dash option, a +switch (off by default, as in RPG Maker). Players change it in the game's +Options and Config; with it on they dash without holding Dash. It applies +only to a game whose Dash is on in Database > System. + +#### Field Zoom + +Open **Database > Configuration > graphics.field.zoom**. Enter a finite number +from `1` through `8` (`2.5` is valid); the default is `1`. This writes the +game's `config.php` at `graphics.field.zoom`, not `system.php` or +`ichiloto.json`. It magnifies the GPUI field only: terminal maps, menus and UI +sizing do not change. `Ctrl+S` saves, `Ctrl+Z` undoes, and `Ctrl+Y` redoes. Undoing the first +edit restores an originally absent setting without leaving a default entry. +The same pending Terms edits are included when this configuration is saved. ### Knowledge @@ -932,11 +2383,17 @@ Optional party banter, one file per skit under `assets/Data/Skits`. The engine scans the directory and keys skits by their `id` field, not by filename, so renaming an `Id` in the editor does not require renaming the file. -Fields: `Id`, `Title`, `Where (map id)`, `Conditions`, `Speed (chars/sec)`, -then one pair of `Beat N Speaker` / `Beat N Text` rows per beat. +Fields: `Id`, `Title`, `Where (map id)`, `Backdrop`, `Conditions`, +`Speed (chars/sec)`, then each beat's `Actor` or `Non-actor Speaker`, its `Emotion` and its `Text`. +The emotion is chosen among the speaker's emotions (see +[Dialogue Portraits and Emotions](#dialogue-portraits-and-emotions)). - `Where` gates the skit to one map id, matched case-insensitively. Leave it empty to make the skit available anywhere. +- `Backdrop` is for a skit whose content needs one particular setting + wherever it plays. Leave it `None` and the skit shows the backdrop of the + place it plays in: the map's [Skit Backdrop](#skit-backdrop), else its + region's. Validation warns where a skit would show none. - `Conditions` is the shared world-condition line described under [Condition Lines](#condition-lines). - `Shift+O` and `Shift+X` add and remove beats. @@ -946,13 +2403,56 @@ then one pair of `Beat N Speaker` / `Beat N Text` rows per beat. Current behavior: a skit plays at most once per save file, and the engine announces availability rather than interrupting - the player presses `T`. +### Dialogue Portraits and Emotions + +The game's dialogue catalogue, `assets/Data/Presentation/dialogue.php`, says +whose artwork a line shows. Two pages edit it. + +**Dialogue Portraits** holds one record per speaker identity. `Speaks As` +says whether it is an actor's identity, chosen among the project's actors +by stable id, or a non-actor character (a parent, a shopkeeper) with a +stable id of its own. Each identity has a `Dialogue Portrait` (the dialogue +box's) and a `Skit Bust` (the skit stage's), and an `Emotions` list: each +emotion has a `Name` and its own portrait and bust. Images are chosen with +the shared project image picker and stored as paths under `assets`; a missing +emotion image falls back to Neutral's and then the identity's own, as the +game draws it. `Shift+O` and `Shift+X` add and remove emotions. + +**Speaker Names** binds the labels lines are spoken under ("Mom", "Fira Nel") +to an identity. A line spoken under a label shows that identity's artwork; a +label the page does not list shows none. Several labels may share one +identity. + +Every line the game reads an emotion for has an `Emotion` row: an event or +cinematic `text` command, an NPC's dialogue line, a skit beat, and a map +Dialogue or Shop event's page. It is chosen among the emotions of the line's +speaker: an actor id names its identity directly, and a label is resolved +through Speaker Names (an NPC line with no speaker of its own speaks under +the NPC's name). `Neutral` is always offered and is what a line without an +emotion shows. A line that holds an emotion its speaker lacks keeps it, and +an `Emotion Warning` row under it says so; the game shows the identity's +Neutral artwork until that emotion exists. + +A catalogue that builds its identities with PHP code (a loop over +directories) is shown read-only on Dialogue Portraits, saying so, since a +save would replace that code with its result; Speaker Names stays editable +when its labels are plain data. Validation reports a speaker name bound to +no identity, an image the game cannot draw, and a line emotion its speaker +lacks. + ### Common Events Cutscene and event command lists, one file per script under `assets/Events`. A map's `ScriptEventTrigger` runs the script whose `scriptId` matches the filename stem. -The entry list shows script ids. The settings pane flattens the command list: +The entry list shows script ids. A script made from the Terminal is named +`new-event-script`; the GUI editor's session (`database.create` with an +`identity`) and editor change batches can name it at creation, in id form +(`payday-notice`), and that name becomes its file. A name another script or +file already has is refused. + +The settings pane flattens the command list: each command contributes a `Command N Type` row plus the rows that type uses. Changing the type changes the rows beneath it. @@ -971,6 +2471,7 @@ Supported command types, matching the engine's interpreter: | `play_sound` | Sound | | `play_music` | Music | | `accept_quest` | Quest Id | +| `common_event` | Event Script | | `knowledge` | Operation, Subject, Report, Replacement Report, Observation, Outcome, Source, Confidence | | `move_player` | X, Y | | `move_route` | Subject, NPC Id, Wait, Seconds Per Step, Speed, Steps | @@ -979,6 +2480,28 @@ Supported command types, matching the engine's interpreter: | `recover_party` | None | | `branch` | Conditions, Then (fixed), Else (fixed) | +Registered commands follow the built-in ones in the `Type` options: the +Engine's `shop`, `inn`, `payday`, `collect_income` and `adjust_income`, then any the project declares in +`assets/Data/script-commands.php` (see the Engine's story events guide). Their +rows come from their declarations: + +| Type | Fields | +| --- | --- | +| `shop` | Buy Rate, Sell Rate, Merchandise (Item, Price) | +| `inn` | Question, Speaker, Cost, Wake At X / Y, Rest Music, Result Variable | +| `payday` | Schedule (picked from Income), Result Variable | +| `collect_income` | Schedule (picked from Income), Result Variable | +| `adjust_income` | Schedule (picked from Income), Amount (negative for a deduction) | + +A command's first list, such as a shop's merchandise, is edited like a +route's steps: with the settings cursor on one of its entry rows, `Shift+O` +adds an entry and `Shift+X` removes one. A further list in a project command is shown +read-only and kept as written. The Editor reads the declarations without +running the project's handler code; the game checks the handlers when it +starts. Validation reports a malformed declaration file, a registered command +missing what its declaration requires, and any resource it names that the +project does not have. + A `knowledge` command records what the party has learned. `Operation` is chosen from the vocabulary the runtime itself defines, and each operation reads only the fields it needs, so the others are left empty and are not @@ -1011,11 +2534,76 @@ the map data. Routes in this phase are sequential and awaited, so `Wait` must remain true. Set either seconds-per-step (with an optional per-step override) or speed in steps per second. -Current limit: nested arms are shown but not edited. A `choice` command's -`Options` and a `branch` command's `Then` / `Else` appear as fixed rows, and -their contents round-trip untouched when you save. Editing a nested arm means -editing the file directly - flattening a command tree into one settings pane -would be unreadable, and dropping it on save would be worse. +The shared route fields also preserve `waypoints` and `retrace` as distinct +authored modes. A waypoint's omitted X or Y keeps the subject's coordinate for +that leg; adding a point leaves both axes blank until authored, not zero. +Incomplete new or changed routes refuse Save without discarding the draft or +undo history. Unchanged legacy data survives unrelated edits. + +A waypoint, an independent `stage_actor` and a staged cast member may instead +stand relative to a map subject, the Engine's `at`/`offset` position. `Relative +To` chooses the subject kind from the Engine's exported list: the player, an NPC, +a world object (graphical editor only, as for every subject picker), a staged +actor (cinematics only), a party actor or an event marker. A named subject's id +is picked from what exists (the map's NPCs, world objects and markers, the +cinematic's cast); the player and the leading party actor take none. `Offset X` +and `Offset Y` are whole cells, zero when left out. Choosing a subject removes +the point's X and Y; clearing it from a waypoint is refused rather than inventing +a coordinate. A visual bound to a real subject inherits its position and is never +offered a subject or offset; binding one removes them. A subject still waiting +for its id refuses Save. The map placement view shows each relative point at its +subject's cell on the owner's map (an event's or NPC's own map, a cinematic's +Start Map), or says why it cannot: the player, party actors and staged actors are +placed only while the scene runs, and a common event runs on whichever map calls +it. A relative point is changed through its subject and offset, never by a map +click. + +An independent staged visual relative to a world object may also sit on one of +the **Attachments** its field resource declares, such as an aerial: the +`Attachment` row offers that object's attachments. The visual then shows on the +attachment's terminal cell and, in the graphical game, centred on its point of +the object's current art, so replacing the art carries it along. It takes no +offset, and choosing another object or subject clears the attachment. +Validation reports a visual whose attachment the resource no longer declares. + +A common event (a reusable event script) is edited with the full cinematic +command vocabulary, staging included, since cinematic sessions call it. Its +map-local choices (world objects, their attachments) are on the map its callers +run it on: a map whose events or NPCs call it, a cinematic's Start Map, or the +map a calling script runs on. When its callers run it on several maps, or none +yet, it offers no map-local choice. A transfer earlier in a caller is not +followed here; validation follows it. + +The same waypoints and independent staged actors also name the way their +character faces once there, the Engine's end facing. `Facing` fixes a cardinal; +`Face Toward` picks a subject from the same list as `Relative To`, then its id in +the `Face Toward …` row (the player and the leading party actor take none). The +character turns toward that subject's cell when the leg or staging begins; an +even diagonal faces north or south. Choosing one clears the other. A waypoint +with neither keeps its last step's heading (`(last step)`); a staged actor with +neither faces south. A visual bound to a real subject inherits its heading and +offers neither row. A subject still waiting for its id refuses Save. + +`Remember As` declares a session-local identity. `Recorded Route` selects only +declarations in that script invocation and its referenced common events, +including nested command arms, not other NPCs, dialogue variants, map events or +cinematics. Ordinary NPC/map/common-event sessions and cinematics all support +recording real subjects. Runtime verifies completion, subject/map/generation, +endpoint and one-use consumption; the reference choice does not guarantee a +conditional record has executed. Retrace uses actual successful movement, not +authored reverse geometry, and records disappear at completion or cancellation. + +Graphical clients request these choices with the explicit owning row context: +`references.list` keeps its optional `record` argument as `{category,index,frame}` +for Database, `{kind:'npc',index,frame}` for NPC, or +`{kind:'event',marker,path}` for an inline event, with the owning `map` for the +latter two. Existing `{category,index}` cutscene requests remain compatible. +Omitting the owner does not create a global recorded-route catalogue. This +transport contract does not require graphical controls in the Terminal editor. + +A `choice` command's options and a `branch` command's `Then` / `Else` arms +are not flattened into this list. Each arm opens as its own frame, described +under [Command Frames](#command-frames). `start_battle` may occur in the middle of a script. The event suspends until the existing battle return path restores the field, then continues with the @@ -1090,10 +2678,11 @@ Unparseable entries are dropped rather than written back as garbage. `F4` opens the Cutscenes screen over the main shell, the way `Ctrl+D` opens the Database; `F4` or `Esc` closes it, `Ctrl+D` from inside goes straight -across to the Database, and `Ctrl+P` offers `Cutscenes: Cinematic` and -`Cutscenes: Summon`. Five panes: **Types** (Cinematic or Summon), the asset -**list**, the **record pane** (the asset's fields, grouped), the **Command -Tree** (for a summon, the **Timeline**) and the **Preview**. `Tab` and +across to the Database, and `Ctrl+P` offers `Cutscenes: Cinematic`, +`Cutscenes: Summon` and `Cutscenes: Effect`. Five panes: **Types** +(Cinematic, Summon or Effect), the asset **list**, the **record pane** (the +asset's fields, grouped), the **Command Tree** (for a summon or an effect, +the **Timeline**) and the **Preview**. `Tab` and `Shift+Tab` cycle the panes; `/` filters the list; `Shift+A` creates, `Shift+D` duplicates, `Delete` removes (with the places that reference the asset named first); `Ctrl+S` saves the selected asset, `Ctrl+A` saves every @@ -1107,6 +2696,7 @@ Four things look alike and are not. Keep them apart: | Common Event | a reusable command list, called by id from maps and cinematics | Database › Common Events | | Cinematic Cutscene | a staged story sequence: cast, camera, parallel lanes, skip, finalizer | Cutscenes › Cinematic | | Summon Cutscene | a frame-driven battle presentation: tracks, keyframes, cues | Cutscenes › Summon | +| Effect | an effect timeline battle animations, the field and cinematics play | Cutscenes › Effect | | Skit | an optional conversation overlay | Database › Skits | | Animation | a reusable visual asset a command plays | Database › Animations | @@ -1119,9 +2709,12 @@ assets/Cutscenes/Cinematics//.data.php assets/Cutscenes/Cinematics//.script.php assets/Cutscenes/Summons//.data.php assets/Cutscenes/Summons//.timeline.php +assets/Animations//.timeline.php ``` -The stable id *is* the folder name; the display name is a field. A new +An effect is its timeline file alone, exactly as the engine's effect library +reads it: no data file, and an id of lowercase letters, digits, `_` and `-` +(no dots). The stable id *is* the folder name; the display name is a field. A new asset's id is yours to choose until its first save makes the folder; after that it is read-only (duplicate under the new id and delete the old one to migrate). The list shows `*` for unsaved work and a folder the engine cannot @@ -1150,7 +2743,9 @@ backslashes, trailing spaces and wide glyphs all survive, a data-only edit leaves the script or timeline file byte-identical, a no-op save writes nothing, and multi-line text is written as a nowdoc. A value shared through one source variable is edited in place when only the selected entry uses it, -and otherwise retargeted to a new variable so nothing else changes. +and otherwise retargeted to a new variable so nothing else changes. A +variable the file builds with its own code is never replaced by a copy of +its result: an edit under it is refused, naming the variable. A save is one transaction over the pair, and so is a first save, an overwrite and a deletion. Each proposed file is staged beside its @@ -1195,13 +2790,21 @@ Lane 2`). `Esc` pops exactly one frame. | `camera` | Operation (`detach`, `attach`, `reset`, `focus`, `pan`, `route`, `track`, `shake`, `restore`), Target (kind, id or x/y), Seconds, Magnitude; a `route` owns Points | | `stage_actor` | the staged-actor fields | | `show_actor`, `hide_actor`, `remove_actor` | Actor (from the cast) | -| `field_animation` | Animation, Target (kind, id or x/y), Seconds Per Frame | +| `field_animation` | Effect (picker), or Animation and Seconds Per Frame; Target (kind, id or x/y) | | `title_card`, `narration` | Title, Text (multiline), Seconds | | `transition` | Style (`fade`, `wipe`, `none`), Direction (`in`, `out`), Seconds | | `clear_presentation` | none | | `cinematic_music` | Track, Loop, Fade In, Fade Out, On Completion (`continue`, `stop`, `restore_previous`) | | `move_route` | Subject (`player`, `npc`, `staged_actor`), NPC Id or Staged Actor, Seconds Per Step or Speed, Wait, Steps | +A `field_animation` plays either an effect timeline from `assets/Animations`, +chosen by its stable id, or a legacy animation from the Animations database. +The effect owns its frame rate, so choosing an Effect removes the Animation +and Seconds Per Frame rows and their values; clearing the Effect brings them +back. Validation plays the effect through the Engine for both presentations +and reports one that is missing, cannot be read, or loops: the command waits +for its effect to end, and loops belong to a map's own field effects. + Every other type from the Common Events table is available too, with the same rows. `Shift+O` adds: on a route, lane or point row, another step, lane or point; on a command that has none of those yet, its first; otherwise the @@ -1224,7 +2827,9 @@ where a field is unset (a wait of 0.5s, a route step of 0.15s, a narration of 2.5s, a transition of 0.24s). A parallel block is as long as its longest lane; a branch or choice as its longest arm. Dialogue, choices, battles and common events cannot be timed from the asset and are marked `+input`, -`+battle` and `+?` instead of guessed. `Up` / `Down` move over the rows and +`+battle` and `+?` instead of guessed. A field effect is as long as its +timeline, the longer of its two presentations, and is marked `+?` when its +timeline cannot be read. `Up` / `Down` move over the rows and `Enter` opens the row's command in the tree; a running preview marks the rows it is on. @@ -1263,11 +2868,18 @@ The preview starts from the map event that triggers the cinematic when one exists, otherwise from the start map at its first open tile. It writes nothing: not your saves, not your files. -`Ctrl+T` plays the saved cinematic in the real game through the playtest -overlay: the start map is copied into the throwaway root with one extra -event, an automatic single-use `CinematicEventTrigger` on the spawn tile, -so the game launches the real asset through its real trigger the moment -the playtest begins. Your map files are not modified. +Missing or malformed preview maps are reported beside the controls; the stage +remains usable on explicitly identified undefined terrain. The diagnostic +follows the current map and clears after a successful transfer. GPUI restarts, +resizes and reopened views reconnect to the new scene automatically, even when +the grid is unchanged; a retired view cannot supply its picture or feedback to +the replacement. An effect without a selected start map now previews on +diagnosed undefined terrain instead of refusing to open. + +`Ctrl+T` plays the saved cinematic in the real game: a new game starts on +the cinematic's origin cell, and the Engine plays the cinematic once the +player has arrived, through the same controller a trigger would start it +with. No map file is copied or modified for it. #### Skip and the finalizer @@ -1311,22 +2923,78 @@ strengths, weaknesses, free-form attributes, authoring metadata), **Availability** (conditions through the shared condition editor; an omitted policy is open and is written as nothing), **Wielders** (mode `all`, `roles` or `characters`, with roles picked from classes and -characters from actors; tenancy `shared` or `exclusive`), **Playback** +characters picked by stable actor id, as the battle matches them, so +renaming an actor never changes who may call it; tenancy `shared` or +`exclusive`), **Playback** (default speed, allow skip, loop preview, transitions in and out, effect timing by `end`, `cue` or `frame`, target presentation) and **Timeline** -(format version, FPS, length in frames, editor metadata, and the Tracks -and Cues frames). `allowSkip` is authored data; whether a battle honours it -is the engine's business, and this manual claims nothing more. +(the Sequence row, format version, FPS, length in frames, rest frame, +editor metadata, and the Tracks and Cues frames). `allowSkip` is authored +data; whether a battle honours it is the engine's business, and this manual +claims nothing more. + +**Sequence** works as an effect's does: a summon whose timeline every +renderer shares stays that way unless you choose `separate`, which gives it +a terminal and a graphical sequence, each a copy of the shared one (undo +puts the shared file back). The definition (identity, wielders, playback +and the rest) stays one for both; the timeline rows, Tracks and Cues edit +the sequence chosen, and the other is kept exactly as written. A key a +sequence did not hold, such as a first rest frame, is written into that +sequence. + +#### Stage + +A summon's graphical sequence may have a **stage**: a presentation space of +its own, in stage units, shown in place of the arena from its start frame +until its restore frame, when the arena, the formation and the battlers' +current state return. The terminal sequence never has one. In the graphical +sequence, the **Stage** row is `none` or `added`; adding gives the sequence +the smallest stage the engine reads (a `1280, 720` canvas shown from the +first frame to the last, its camera on the canvas's centre at its own +scale), and `none` takes it away, each one undo step. + +With a stage, its rows are **Stage Canvas** (`width, height`), **Stage Start +Frame**, **Stage Restore Frame** and **Stage Background** (`black`, +`white` or `#RRGGBB`; empty is black), and three frames to open: + +- **Stage Subjects**: each subject has an id, a `Position` (`x, y`), a + `Size` (`width, height`, the box its art is fitted to, not a copy of an + image's size) and a `Pivot` (the point of that box at its position, `x, + y` from 0 to 1; empty is the bottom centre). `Shift+O` on a subject row + adds a named **point** to it, such as `chest` or `ground`: an id and an + `X` and `Y` from 0 to 1 across and down its box. +- **Stage Camera**: keys in frame order from frame 0, each a `Focus` + (`x, y`, the stage point at the centre of the view), a `Zoom` from 0.125 + to 4 and an `Easing` to the next key (`linear`, `hold` or `smoothstep`; + empty is linear). +- **Stage Covers**: full-screen colour keys in frame order, from frame 0 to + a fully clear key on the last frame, each a `Color`, an `Opacity` from 0 + to 1 and an `Easing`. + +An image track whose `Anchor` is `stage` is placed on the stage: its +`Subject` and `Subject Point` are picked from the stage's subjects and +their points (none: the stage origin, or the subject's pivot), with an +`Offset` and a `Size` in stage units (empty: no offset, and the subject's +size), and a `Z Index` among the stage's art. Battler rows (cells, facing, +battler attachment) do not apply on the stage. Its keyframes add an +`Offset` and an `Opacity` (empty is 1). The sequence's rest frame is also +the stage's reduced-motion view, so it must fall inside the stage's frames +with art showing; a save the engine refuses names the stage row it refuses, +such as `stage.camera[initial].zoom`. #### Tracks, keyframes and cues Open `Tracks` and each track is a row (`Id`, `Type`: `glyph`, `text`, -`flash`, `shake`) followed by its keyframes: frame, duration, position, +`image`, `flash`, `shake`; `Presentation`: `all`, `terminal` or `graphical`, the +renderers that draw it) followed by its keyframes: frame, duration, position, content, asset id, color, visible, z-index, blend mode, easing and a -free-form payload. `Content` opens the **multiline editor** (`Enter` on the +free-form payload; an image track has the image rows an effect's has. +`Content` opens the **multiline editor** (`Enter` on the row), which keeps every space, backslash, blank line, tab and wide glyph exactly as typed or pasted; `Ctrl+S` there commits, `Esc` cancels. Open -`Cues` for the cue rows: id, frame, type and payload. `Shift+O` adds a +`Cues` for the cue rows: id, frame, type (including `restoreBattlefield`, +which clears flash, shake and effect art and redraws the field) and +payload. `Shift+O` adds a track, a keyframe under the cursor's track, or a cue; `Shift+X` and `Delete` remove. The Timeline pane lists tracks, keyframes and cues as rows: `[` / `]` reorder, `Shift+D` duplicates (a copied track or cue gets a @@ -1369,11 +3037,104 @@ multi-pick over the project's summons where each pick toggles a member in or out, undoable and dirty-tracked, written as a list of stable ids (and removed entirely when emptied). Under it, one verdict row per assignment, judged by the same rules the validator applies: the summon must exist, the -actor must be eligible under its wielder policy (by character, by role, or -open to all), a story-locked summon cannot be a starting assignment, each +actor must be eligible under its wielder policy (by character id, by role, +or open to all), a story-locked summon cannot be a starting assignment, each id appears once, and an exclusive summon has at most one starting holder across the cast. +### Effects + +An effect is edited like a summon's timeline, over the keys the engine's +effect library reads and nothing else. The record pane groups **Identity** +(the id), **Sequence**, **Timing** (FPS, length in frames, playback `once` +or `loop`, loop from, rest frame), **Impact (battle)** (when a battle +command's result lands: at the `end`, a `frame` or a `cue`) and +**Timeline** (the Tracks and Cues frames). Tracks are `glyph`, `text`, +`image`, `flash` or `shake`, each with its `Presentation` (`all`, +`terminal` or `graphical`), an anchor (`target`, `caster` or `screen`) and, +for a battle stroke drawn one way, `Facing` (`west` or `east`; the empty +choice removes it, and the stroke stays as drawn). An image track names its +PNG through the asset picker, its sheet columns and rows, its cell size, its +`Fit` (`stretch` fills the cells; `contain` keeps the sheet cell's own +proportions inside them; the empty choice removes it, so it stretches), its +depth and its `Pivot` (the point of the sheet cell placed at the effect's +position, two finite coordinates `x, y` from 0 to 1). The GUI's image-cell +picker sets that point through the same undoable record edit. Empty removes +the authored pivot: field images use bottom-centre (`0.5, 1`), while battle +images keep their centred default (`0.5, 0.5`). Opening or saving an effect +does not materialize either default in the file; an effect shared between +field and battle keeps each consumer's default until a pivot is authored. +The picker marks an explicit pivot, or the default for the current preview +context when omitted, and labels which it is. While a shared effect's preview +context is still loading, no default is guessed. Stage images retain their +centred default. In battle only, `Attachment` selects `center`, +`head` or `ground` on the battler; empty removes it and uses the centre. +Field image tracks do not accept battler attachments. Image keyframes give +the sheet frame, the position and, in battle, +`Flip Horizontally` and `Flip Vertically` for an authored reverse stroke. +Glyph and text keyframes give frame, duration, position `x` and `y`, +content (the multiline editor), asset id, color, visibility, z-index and a +payload. Cues are `playSound` on the field and any battle cue in battle. +Impact timing, facing, image attachment, flips, flashes and shakes +are battle-only; +validation names the place that uses an effect where they are refused. + +**Sequence** says how the effect is drawn. A flat effect has one sequence +for the terminal and graphical renderers alike, and stays flat unless you +choose `separate`, which gives it a terminal and a graphical sequence, each +a copy of the flat one (undo puts the flat file back). An effect with +separate sequences is edited one at a time: choose `terminal` or +`graphical`, and every row, the Timeline pane and the preview are that +sequence's; the other is kept exactly as written. Undo returns to the +sequence an edit was made in. The TUI has no graphical workflow: an image is +chosen by path and its frames by number, and pictures belong to the GUI +editor. + +An effect may instead own a graphical stage. In the GUI, its Stage, subject, +attachment and camera controls use the same record services and Engine stage +contract as summons. The stage owns its surroundings: it is never previewed +against a fabricated battle or field. Its independent Terminal sequence keeps +its own tracks, cues and clock without needing to decode graphical images. +An incomplete stage can be edited and undone, but the Engine must admit it +before Save; removing it preserves the sequence's tracks and cues. + +`Shift+O` on a Timeline row inserts the blank the effect's own lists take: +an image keyframe after an image keyframe, starting where the selected one +ends, and a track whose keyframe has a position `x` and `y`. A save is +compiled by the engine in its declared stage, or for battle and the field +when no stage is declared. Save is refused before writing unless each sequence +has a valid context; a paired Terminal sequence retains its independent +admission. `Delete` names everything that plays the effect first: battle +animations, the field presentation's cues and action prompt, maps' field +effects, tileset pieces, and the `field_animation` commands of map events, +event scripts and cinematics. + +#### Effect preview + +`Space` on the Preview pane compiles the sequence being edited, unsaved, +and plays it through the engine's shared effect playhead, with the summon +preview's keys (step, keyframe boundaries, `Home` / `End`, speed, `R`, `L`, +`X`); `O` loops, and an effect authored to loop starts looping, from its +loop frame. The frame is drawn as a terminal consumer places it: through the +engine's composition for that presentation, glyph and text at their anchor, +`C` marking the caster and `T` the target (`screen` draws from the top +left). Beside it, the context and what the terminal cannot draw: image +frames with their flips, flashes and shakes, each with its subject. + +| Key (Preview focused, effect) | Action | +| --- | --- | +| `B` | Play as battle or as the field compiles it (recompiles; a refusal is reported) | +| `D` | Put the battle caster on the other side | + +The preview starts as battle when a battle animation uses the effect or +nothing on the field does. In battle a stroke with a facing is turned toward +its target exactly as the battle turns it, so `D` shows both directions. +An owned stage instead starts in its declared context; `B` does not change its +ownership. The GUI GPUI tab paints the Engine's seekable stage canvas through +the runtime's shared painter. A stage error is shown rather than replaced by +a field frame, and late seek replies cannot overwrite a newer frame. The +Terminal tab shows its separately authored sequence, not a graphical imitation. + ### Validation `ichiloto validate` and the editor's validation cover both forms: missing or @@ -1413,7 +3174,7 @@ fields are preserved and are not errors. | --- | --- | --- | | Help | `?` | Generated from the binding table; scrolls with arrows | | Command palette | `Ctrl+P` | Fuzzy search over actions, tools, maps, categories, and event markers | -| Character map | `@` | Insert glyphs the keyboard reserves; lists the map's symbols, the project's collision vocabulary, and the reserved glyphs | +| Character map | `c` in Normal mode, or *Tool: Character Map* in the command palette | Insert glyphs the keyboard reserves; lists the map's symbols, the project's collision vocabulary, and the reserved glyphs | | Status detail | `Ctrl+E` | Full text of the last message, and the log file path | Current limit: the help overlay and command palette cannot open while a picker @@ -1447,6 +3208,30 @@ matches. `Ctrl+A` saves every dirty map and every editable database in one pass. Read-only categories are never included - they hold no edits. +### Saves follow what a map makes solid + +When a map is saved after an edit takes floor that the player could stand on +in the saved map, a save standing on such a cell would load inside it. That +includes walkable floor no route reaches today, since an older layout may have +reached it. A cell is +taken when it becomes something that blocks movement (furniture, a wall, +painted collision, trees) or when an NPC is added or moved onto it. Walkable +grass, exits and save points take nothing, and an NPC that stays put moves no +save. With a save compatibility +manifest (`assets/Data/save-compatibility.php`), `Ctrl+S` first shows each such +cell and where saves on it will land: the nearest plain floor the player can +reach today, found by walking from the cell, never an event or NPC cell. +**Save and move saves** writes the map and a `relocations` step that raises +the manifest's content version by one; **Cancel** saves nothing. The step is +its own undo: `Ctrl+Z` after the save removes it from the manifest, and undoing +the edit itself is then saved as usual. `Ctrl+A` leaves such a map unsaved and +names it, so its save is always reviewed on its own. A cell no open floor can +be reached from refuses the save until a path is opened or the edit undone. A +project without a manifest is saved at once, as before. The GUI editor shows +the same review when it saves. A cell that a resize cuts off the map is not +covered by this review; removing rows or columns lands saves through its own +review instead. + Saving writes through a temporary file and a rename, so an interrupted save cannot truncate your work. When a save regenerates a data file, everything from ` require …` in place - cannot take an edit without destroying the expression, so that one edit is refused with the -map, file and path named, and nothing changes. The refusal is that narrow: a -value written as a *variable reference* can be repointed (the entry gets the -literal; the shared variable stays for its other users), an opaque sibling +map, file and path named, and nothing changes. A value written as a +*variable* follows how the file writes that variable. One written as data +(assigned once, by a literal, and named nowhere else but by plain +references) is edited where it is assigned when only this entry names it; +when several entries share it, the edited entry gets its own copy and the +variable stays for the others, unless the shared value holds comments, +variables or expressions a copy would lose, which is refused. A variable the +file builds with its own code (a `require`, a call, a loop that fills it) is +refused the same way as an expression: its code is never swapped for a +literal of what it produced. The refusal is that narrow: an opaque sibling never blocks an edit beside it, and browsing, previewing and validation always work. Only a data file whose whole source cannot be parsed for preservation makes the map's *data* read-only - the grids stay editable, and @@ -1520,6 +3312,16 @@ command palette. It shows the current and proposed ids, requires an explicit are **not** migrated: anything naming the old id keeps naming it, and the prompt says so before you confirm. +A map may live inside another map's folder (a building's rooms inside it) or +inside a folder that is no map. The editor session creates, copies and moves +a map at a folder path the author chooses: `map.create` takes `id`, and +`map.duplicate` and `map.move` take `to`. A chosen path is checked before +anything is written: each part between slashes is lowercase letters and digits +joined by single hyphens, nothing may already stand there, and no map nested +directly in a map's folder may be called `layers` or `graphics`. A move to a +chosen path is reviewed exactly as a derived one is. Deleting a nested map +also removes the folders above it that it leaves empty. + ### Dirty means "differs from the last save" An asset is dirty exactly when its content differs from what the last @@ -1542,11 +3344,53 @@ Before a map save the editor runs a validation pass and warns - never blocks - on dangling destination references, event markers without definitions, and spawn points outside the map. The warnings appear in the status footer; `Ctrl+E` shows the full text. The project-wide pass (`ichiloto validate`) also -covers every NPC field, as described under [NPC Mode](#npc-mode). +covers every NPC field, as described under [NPC Mode](#npc-mode). The GUI's +map inspector checks the open map the same way on request (**Check this map**, +`map.validate`), unsaved edits included, and lists every problem with what to +do about it, errors first; **Show** moves to the first cell a problem names. + +Skills are read as one catalogue from their record files, as the game reads +them: a skill name defined twice, or a record the game cannot read, is an +error naming its file, and save aliases for spells must point at spells and +those for abilities at abilities. + +Save migration steps are read as the Engine reads them: each declares a +project class, or declared position edits, `mapShifts` for inserted rows and +columns, `mapRemovals` for removed ones, `relocations` for cells authored +content now occupies (map, cells, and a landing that is not one of them) and +`mapRetirements` for a retired map whose saves move onto another map (map, +into, a landing `to`, and optional landing rectangles). A malformed step is +an error, and so is a retirement whose saves end on no map of the project: +its `into`, followed through later retirements and map aliases, must be a +current map. Relocations are written by hand when an NPC or furniture moves +onto open floor; add them to a new step, never to an earlier one. + +Reachability is checked as the game moves the player, from where the game +starts and every way onto each map it can reach, never from where content +stands: moving an NPC or rebuilding a room is fine unless it traps something. +An event no reachable cell lies in, or an arrival outside the map or on a +wall, is an error; a talkable NPC with no reachable cell beside it, and a map +nothing reachable leads to yet, are warnings. Doors and edge triggers that +name a map the project does not have are errors of their own. On a map with +a kind, a cell the player can reach that no tile covers is a warning: the +game shows the dark background there, so paint its floor or close it off. + +Effect timelines in `assets/Animations` are checked as each consumer plays +them: an animation's `sourceEffect` and `targetEffect` in battle, and the field +presentation's cue and action prompt effects, maps' `fieldEffects` and tileset +pieces' effects on the field. Each is compiled for the terminal and for the +graphical presentation, so an effect only one renderer would refuse is still +an error; until it is fixed that presentation keeps its fallback. + +Screen transitions in `assets/Data/Presentation/transitions.php` are checked +as the Engine plays them, including treatments nothing selects yet: a file the +Engine refuses, a battle choice naming no treatment, or an image that is +missing or cannot be loaded is an error. Until it is fixed the graphical +renderers enter battle with a direct cut; the terminal intro is unaffected. An inventory id two definitions claim is reported once, naming every claimant with the category it was authored in, the aliases it brought, and -its entry in `items.php` - the game refuses the whole catalogue until one +its record file - the game refuses the whole catalogue until one of them is renamed, and until then nothing under any claimant is offered by a picker, resolved by a reference, or accepted as a save alias target. @@ -1574,27 +3418,23 @@ is printed in the `?` overlay. ## Playtesting -`Ctrl+T` launches the game on the selected map, spawning at the canvas cursor. -The editor hands the terminal to the game and takes it back when the game -exits. +`Ctrl+T` starts a new game on the selected map at the canvas cursor, past the +title. The editor hands the terminal to the game and takes it back when the +game exits. -How it avoids touching your project: the editor builds a temporary project root -of symlinks back to your real files, and replaces exactly two entries - a -generated `assets/Data/system.php` carrying the playtest spawn, and an empty -`.data/` so playtest saves cannot overwrite your save slots. Maps and every -other asset are symlinks, so the playtest runs against your live files. The -overlay is deleted when the game exits. +How it avoids touching your project: the game runs from your project's own +root and reads your live files, and the editor tells the Engine, through its +launch context, where to start and where to keep the playtest's player data. +That data lives in a fresh directory outside the project, holding a copy of +your player settings (so your volume and mute carry over) and nothing else: +settings changed during the playtest, its saves and its logs stay there, and +your own saves are never read or written. The directory is deleted when the +game exits. The Engine checks the start before the game takes any input, and +refuses a cell that is outside the map, blocks movement or has an NPC on it. The map must be saved first: the game reads the file on disk, so the editor refuses to playtest a dirty map rather than silently running the old version. -Current limits, both engine-side: - -- The engine offers no starting-map override, which is why the overlay exists. -- The game still boots to its title screen, so a playtest is `Ctrl+T` then - "New Game". Booting straight into the field would need an engine hook in - `GameLoader::loadNewGame()` and `Game::start()`. - `Ctrl+T` needs the console binary, which it looks for in this order: the project's own `vendor/bin/ichiloto`, any `vendor/bin/ichiloto` above the installed editor package, then `ichiloto` on your `PATH`. Set diff --git a/docs/roadmap.md b/docs/roadmap.md index 02e24f77..b25577b1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -117,13 +117,59 @@ preview; helpful empty states; live resize handling. ## The plan +### Scenario blueprint authoring + +Planned 15 October 2026; not implemented. The coordinating +[Scenario Director village plan](../../engine/docs/roadmap.md#scenario-director-village-implementation-plan) +owns milestone order and gates. This section owns **SD3**, using the free Engine +contracts from SD0–SD2; GUI panels are tracked in the GUI plan. + +- [ ] Project the proposed single scenario catalog through existing record + schemas/source-preserving PHP services: world/conflict, factions, origins, + situations, placement references and policies. Preserve unrelated source and + refuse unsupported edits before mutation. +- [ ] Add shared list/read/create/revise/validate/preview operations to + EditorSession and SessionHost, with expected revisions, structured diagnostics + and explicit capability description. Resource references use real selectors. +- [ ] Qualify linked edits and grouped undo in the same backing catalog with + SharedFileTransaction/history. That mechanism does not provide arbitrary + multi-file atomic saves; a map-plus-catalog transaction needs shared + qualification before it can be offered. +- [ ] Expose gameplay records, conditions, consequences and textual region + selection through the TUI. No graphical art or GUI licence is a prerequisite. +- [ ] Provide an isolated campaign-preview service over the real Engine runtime. + Start with synthetic proposals; connect the Director later without changing + document ownership. Preview does not write installation assets or canon. +- [ ] Verify round trips, stale revisions, refusal, undo/redo, external changes, + cross-frontend field preservation and cancelled preview cleanup with synthetic + projects. Author both village origins without manual PHP. + +Basic contracts/editing/validation remain free. Maintained Director generation, +coherence and generation-testing workflows belong to the optional addon; native +visual authoring belongs to the GUI frontend. No second AI parser, writer or +document session may bypass these services. + +**SD3 gate:** author the origins and custody/negotiation situation through the +TUI and host, undo/redo linked changes, save/reopen safely and execute a preview. +Detailed effort and dependencies remain in the coordinating plan. + ### Renderer-neutral presentation authoring (planned) Runtime presentation support is not complete Editor authoring support. The TUI must expose the same editable data and operations as a future graphical Editor; graphical previews are an enhancement, not a prerequisite for authoring a game. -The current database/reference catalogues do not yet expose the shared menu -theme or PNG portrait selection. This remains an implementation gap. +The graphical Database's Menu Theme category edits the shared menu theme +(`assets/Data/Presentation/menus.php`) through its source-preserving owner +`MenuPresentationSettings`, which shares `ProjectSettingsFile` with +`config.php`. It offers the minimap atlas (corner sizes, margin, marker size, frame) and icon +bindings, with the Engine's defaults and validation. The palette, metrics, row +metrics and artwork, frames, cursor, portraits and notifications, and PNG +portrait selection, remain an implementation gap; each +joins Menu Theme through that owner as the Engine exposes its rules. Icon +binding is authored for the roles the catalogue already binds and for every +atlas marker role the Engine draws (`map.player`, each kind, `map.category.` +for generic and project categories); adding other new semantic roles waits on +the Engine listing them. Acceptance criteria for the presentation-authoring slice: @@ -147,10 +193,242 @@ Acceptance criteria for the presentation-authoring slice: stable IDs, same-path replacement PNGs, missing/invalid/out-of-root assets, unsupported PHP and failed writes. Unrelated data must remain unchanged. +Dialogue portraits and emotions (status, 2026-10-18): the Engine's dialogue +catalogue (`assets/Data/Presentation/dialogue.php`, `ichiloto.dialogue/1`) is +authored in both editors. Dialogue Portraits edits its identities (`actors` +keyed by stable actor id, `resources` for non-actor characters) as one page, +each with a dialogue portrait, a skit bust and keyed emotions with their own +pictures, chosen with the shared PNG picker and stored asset-relative. Speaker +Names edits `speakers`. Every line the Engine reads `emotion` from (event and +cinematic `text`, NPC lines, skit beats, Dialogue and Shop event pages) offers +the emotions of the identity its speaker resolves to through the Engine's own +`resolveSpeakerId`; an emotion the identity lacks is kept and warned about, in +the row and in validation. A catalogue that builds its identities with PHP code +is read-only on Dialogue Portraits, saying so (Last Legend's builds them in a +loop over directories, so its identities are authored there once the Game +writes them as data); its literal speaker names stay editable. The GUI shows an +identity's pictures and its emotions as a grid to choose from. Remaining: +- Character forms (an identity's vampiric or transformed artwork beside its + usual, chosen from game state): waits on the Engine contract requested on + 2026-10-18. A form will be another artwork set under the identity, reusing + `DialoguePortraitFields::getArtworkFields()` and the emotions list, and each + line's emotion choices will come from the identity's form as the Engine + resolves it. +- The Engine's skit beat check reads emotions from `actors` only, so a + non-actor skit speaker shows Neutral whatever the beat asks for; reported to + the Engine. +- The Engine states the catalogue's schema id and keys only inside + `DialoguePresentationCatalog::load()`; `DialogueCatalogFile` names them once + until the Engine exports them. + +Effect timelines (planned, follows the Engine's effect presentation contract in +its docs/effect-animation.md): an effect is either one flat sequence, still +supported, or `presentations` with two complete, independent sequences, +`terminal` and `graphical`. Each owns its own fps, length, rest frame, tracks and +cues; both are required, never nested, merged or partially overridden. Within a +sequence each track carries `presentation` (`all` by default, `terminal` or +`graphical`; image tracks are inherently graphical), so a terminal glyph track +and its PNG counterpart coexist without hiding glyphs wherever an image exists; +narrative text is never suppressed by an image. + +Authoring requirements: +- Both sequences and every track's `presentation` round-trip source-preserving + through the existing database and timeline editing paths; an effect authored + flat stays flat unless the author converts it. +- Validation checks BOTH choices through the Engine's own load and compile for + each presentation: the terminal sequence without depending on any PNG, the + graphical one with its resources required and diagnosed when missing. +- Rest-frame validity belongs to each image sequence: one image covers rest and + later independently timed tracks may be dormant (a second slash), so + validation never requires every image track to cover rest. +- The TUI edits these as ordinary fields and keeps its terminal editing + behaviour; it gains no graphical workflow. Rich graphical sequence authoring + (previews, image track timing) belongs to the GUI editor. + +Status, 2026-10: validation, references and TUI authoring are in place. +Validation compiles every effect a project uses (battle animations, field +presentation, map field effects, tileset pieces, and the effect a +`field_animation` names) through the Engine for both presentations, and +reports an effect a waiting `field_animation` plays that loops. Cinematic +`field_animation` commands pick an effect by its stable id, from the +timelines the Engine lists. Summon timelines choose each track's +`presentation`. The Cutscenes screen's Effect type creates, edits, deletes +and previews standalone effect timelines under `assets/Animations` (Andrew's +go, 2026-10-03): the summon editor's record pane, Timeline pane and undo, +the existing source-preserving writer, no second format, and every save +compiled by the Engine before it is written. A flat effect stays flat +unless the author separates it; an effect with `presentations` is edited one +sequence at a time and the other is kept as written. The preview plays the +edited sequence through the Engine's shared playhead and composition, as +battle or the field places it, with the caster on either side. Remaining +for the GUI editor: drawing image frames, sheet and cell picking by eye, and +image timing; the TUI only names them. + +Battle effect orientation and weapon roles (Engine contract in progress, +2026-10): a battle track may declare `facing` (`east` or `west`), the +direction its stroke is drawn in; playback mirrors it for an attack the other +way, while undirected effects such as magic and rings stay fixed. Image +keyframes may set `flipX` / `flipY` for an authored reverse stroke, such as a +cross slash's second stroke. A basic attack's animation role follows the +attacker's equipped weapon type (`attack-sword`, `attack-staff`, and so on; +`attack-unarmed` with no weapon; neutral `attack` for other battlers), bound +in the animation database's `roles`. Authoring requirements: +- `facing`, `flipX` and `flipY` round-trip source-preserving with every other + timeline field; validation compiles them through the Engine for both + presentations, as it does every effect. The TUI edits them as ordinary + fields in effect timeline authoring, and its preview turns a stroke as + the battle does; no graphical workflow. +- The animation database offers `roles` from the Engine's supported role + list, refuses a role bound twice before saving, and reports a weapon type + the project's weapons use that no animation is bound to. + +Status, 2026-10: the Editor side of both is in place. Effect timeline +authoring edits `facing`, `flipX` and `flipY` (above). The animation database +shows one on/off row per supported role, naming the animation that holds it, +and writes only `roles` over the authored entry; validation reports an +unsupported role, a role bound twice and a reached role with no animation. + This scope does not replace the map source-integrity work or authorize a new GUI implementation. Keep runtime availability and TUI authoring completion separate until both have been verified. +### Current records across readers (status 2026-10-19) + +Every Editor reader of a catalogue or data record reads the workspace's +current state through `ProjectWorkspace::readCurrentData()` and +`ProjectCatalogs`, falling back to disk only for records with no unsaved edit: +tilesets, field resources, place categories, text roles, dialogue, permanent +growth, knowledge, Optimize policy, battler bindings, player presentation, +skills, item record problems, summons and effect timelines. Code-owned files +(`battle.php`, `transitions.php`) and files written at once (save +compatibility, animation conversion) read disk by design. + +The map world and graphics checks pass the current tileset to +`MapGraphics::fromSources` (Engine f473173). + +Remaining, waiting on the Engine taking caller-supplied data: +- [ ] Battle previews and battle test choices: `ItemStore`, `EnemyStore` and + `SummonCutsceneLibrary` read disk, and `SkillCatalog::getProjectCatalog()` + caches per asset root inside the Editor's process. +- [ ] GUI sign previews: `WorldSignPlayback` loads the text catalogue itself. +- [ ] Cinematic preview field: `MapManager`, `FieldEffectManager`, + `KnowledgeCatalog::fromProject()` and `PlayerPresentationConfig::load()` read + the project from disk. + +### Registered command lists (planned) + +The Engine lets a registered script command declare any number of `list` +fields. The TUI record editor gives each command entry one nested list, so it +edits a command's first list field as entries and shows any further list +read-only, kept as written. This remains an implementation gap for project +commands with more than one list; the Engine's `shop` and `inn` have at most +one. + +Acceptance criteria: + +- Edit every declared list of a registered command through the existing + source-preserving sub-list path, each with its own add, remove and undo. +- Keep the settings ids unambiguous when several lists share an entry. + +### Spell and ability authoring (planned) + +The Engine reads a project's skills from one catalogue spread across +`assets/Data/skills.php`, `abilities.php` and `magic.php`: a skill is found by +its name in any of them, and its kind comes from its class. The Editor's +validation and reference pickers read that same catalogue, but the Skills +database edits `skills.php` only. Spells and abilities authored in `magic.php` +or `abilities.php` can be referenced and validated, not yet edited. This +remains an implementation gap. + +Acceptance criteria: + +- Browse and edit every skill in the catalogue, whichever file authors it, + through the existing source-preserving database path. A new skill is written + to the file its kind belongs in by the project's convention, never duplicated. +- Keep names unique across the three files; refuse a save that would define a + name twice, as validation already reports it. +- Round-trip comments, expressions and unknown fields in all three files, and + refuse unsupported source rather than flattening it. + +### Database completion across TUI and GUI (in progress, 2026-10-04) + +Every Database category is edited through one shared service, so the +terminal editor and the GUI editor read and write it the same way. The GUI +session reaches a category through `DatabaseCategory`: schema-driven ones +(`RecordSchemaCatalog` over `ProjectRecordDatabase`) through `RecordCategory`, +actors through `ActorCategory` over `ActorAuthoring`, the actor service the +terminal editor uses too. + +Progress: Classes, Quests, System, a new Configuration category, Actors, +Types, Skills, Tilesets, Items, Weapons and Armors are done in both editors; +the GUI session now serves every category. +An identity freeze that also repairs other files is asked about and written +at once in both editors: a document reports the file set it needs +(`SourceSetRequired`) and the workspace's owner writes it as one +`SourceSetCommand`. The session's map revisions count on across the reload, +so a revision given before it never names the reloaded map. + +Order of work, each a complete slice before the next: + +1. Classes, Quests, System, Actors and Skills move onto the shared record + service. The terminal keeps its workflow and every field and preview it has + now (experience and stat curves, actor stats); the GUI gains the category. +2. Data the record files hold but no editor reaches: done. Item effects, + scope, occasion and animation, and skill effects and kind, are edited. Enemy drops are done, + and every schema record's references, list entries included, are now + validated. Enemy `position` and `battleAnimation` are not authored: troops + own battle positions (`Troop` overwrites the enemy's), and no runtime code + reads an enemy's `battleAnimation`; attacks animate through their skills. + The rest of `system.php` is done (System and Configuration). +3. Skills are one declarative record per numbered file under + `assets/Data/Skills` (done; `abilities.php` and `magic.php` are gone). + Items, weapons and armors are too, under `assets/Data/Items`, `Weapons` + and `Armors`, with `items.php` as their barrel (done). No catalogued + category stores `new Class(...)` entries any more, so the object-entry + write path (constructor-call entries in `ProjectRecordDatabase` and + `SharedFileTransaction`, with their identity addressing and refusals) + serves no category; removing it, while keeping the `PhpSourceDocument` + reading `ActorReferenceSource` uses, is open. Shared array lists keep + their synthetic coverage in `SharedListFoldTest`. +4. Tilesets are done in both editors: sheets, missing art, above and table + tiles, shadows, and pieces keyed by id, stamped or connected, with their + tile layers. Tile identities are typed in both editors; picking them on a + sheet is GUI work for its visual authoring. Types is done: it edits + the elements `system.php` declares, which the element pickers now read + (they read an unused enum under `assets/Data/Types` before). +5. The manual's category table has a GUI column (done) and states each category's + real coverage. +6. Troops as RPG Maker's Troops tab (in progress, 2026-10-17). Done in both + editors through `TroopAuthoring`: add a member by choosing its enemy, + remove, clear, auto-name, `Battle Music`, keys the editor does not show + (`id`, `events`) kept, a new troop or member never assuming a game's + enemy, and validation of a member with no battle placement. The GUI page + (canvas, member and enemy lists, member-driven inspector) works without a + graphical battle too. The enemy arrangement rule landed with PT-44 + (2026-10-20): Engine `BattleStaging` stands unplaced members itself, Align + writes its arrangement, Arrange Automatically clears placements, and + dragging a member places every member as it stands, each one undo step; + placements are stage places (`x, depth`) on each arena's ground. Arenas + are authorable data (`Battle Arenas`, `Default Battle Arena`, GUI + editor). A new member stands on the terminal field where the Engine + places it beside the members there (`TerminalBattleEffects::findTroopPlace()`, + read from their sprites through `EnemySprites`), in both editors + (2026-10-21). A troop composed and kept in the battle test is added by + `TroopAuthoring::addTroop()`: as a new record in the editor that launched + the test (handed back through the launch's data root), or written straight + into the project by `ProjectTroopKeeper` for the Console. Battle event pages are + done (2026-10-20): `Event Pages` beside the members, with conditions, span + and battle commands through the shared command frames, members chosen from + the troop, pages renumbered when a member is removed, `Cannot Fall Below + HP %` per member, and saves and validation checked by the Engine's + `BattleEventPage`. + +Acceptance criteria: + +- Every category opens, edits, saves and undoes in both editors through the + same service, with source-preserving saves. +- No field or preview the terminal editor offers today is lost. + ### Phase 1 — The responsiveness sprint (quick wins, no redesign) ✅ *shipped 2026-08* > Status: implemented. `InputDecoder` (src/IO/InputDecoder.php, unit-tested) > replaces the blocking reader: non-blocking drained stdin, offline escape @@ -535,6 +813,11 @@ stay maintainable at similar scale: > `suspendForChildProcess()`/`resumeAfterChildProcess()` to hand the tty over > and take it back. The editor refuses to playtest a *dirty* map rather than > silently running the on-disk version. +> *2026-10-13:* the Engine now takes a launch context (start mode, start cell, +> cinematic, battle test and an isolated data root), and its asset loaders +> refuse paths that resolve outside `assets`, which the overlay's symlinks +> did. `PlaytestOverlay` is retired: every playtest runs from the real project +> root through `PlaytestLaunch`, with no generated `system.php` or copied map. > **The manual is real**: `docs/manual.md` (531 lines) plus six task guides > under `docs/guides/` (1,096 lines total) — index, make-a-new-map, > add-an-npc, wire-a-quest, author-a-cutscene, write-a-skit — following the @@ -681,6 +964,21 @@ Runtime-authored cue conditions use the same fail-closed world-condition vocabulary as trigger conditions; strict validation accepts and checks that shared contract even though nested cue-condition authoring remains deferred. +October 10 G4 authoring adds explicit Story, Route and Unclassified (clear kind) +choices to the same inspector in Terminal and GUI. Clearing removes only kind; +omission retains legacy guidance without inferred classification or authored +defaults. Invalid kinds are rejected even when the symbol is blank. Actual +Terminal controls and the GUI session wire are checked; native picker pixels +remain unobserved. + +Objective waymarks (PT-19, 20 October 2026) extend the same quest surface: each +objective has a headed `Waymarks` list whose entries pick a map and then one of +its events, NPCs, world objects or landmarks (a map-scoped reference: the +picker reads the map the entry names, in both editors), plus a `Shows When` +condition line. The `Field Cues` category edits the game's cue treatment, cue pointers and waymark style in +`field.php`. Story cue kind no longer draws edge arrows in the Engine; the +inspector keeps offering it as classification. + ## Sequencing notes - Phase 1 is days of work and transforms perceived quality; do it first and ship it alone. @@ -707,6 +1005,16 @@ saved wholesale is byte-for-byte unchanged (pinned by test against a disposable copy of the full game), and file-per-entry categories write only dirty, new, or deleted entries. +October 10 source-history correction keeps the original map rewrite basis +separate from the last successful save checkpoint. Removing a field, saving, +undoing and saving again restores its original expression, reference, comments +and key position. Layer undo restores edits without resetting that checkpoint, +so incomplete route/rest drafts cannot bypass save validation. Ordered lists, +external-source refusal and transactional read-back checks remain intact. +Affected Editor families pass 406 tests / 2,636 assertions; the two existing +conditional-cue validator regressions pass separately / three assertions. +These are live-Engine synthetic checks, not native or whole-project acceptance. + ## Map NPC authoring — shipped 2026-08 Genuine map-local NPCs — the engine's `npcs` collection, not painted glyphs, @@ -922,6 +1230,23 @@ Cinematic story cutscenes and summon presentations are authored on their own screen (`F4`), previewed through the engine itself, validated, and saved without rewriting a byte the author did not change. +October 10 G4 checkpoint adds declared owned-stage effects through the existing +Engine admission and canvas projection. GUI stage authoring uses shared record +services, reference selectors and undo; stage ownership cannot fall back to +battle or field surroundings. Paired Terminal sequences retain their independent +admission and clock. Saved cutscene undo now retains source templates and their +values separately from persisted conflict and changed-reference checkpoints: +removed arrays, nowdocs and comments are restored for effects, summons and +cinematics without weakening external-change or proposed-source refusal. +The complete Editor Unit run against live Engine passes 2,084 tests / 13,807 +assertions under strict warning/risky/deprecation/notice/empty-suite gates, +with 15 optional real-Game cases skipped because no Game source was pinned. +The original production all-effects preview regression separately passes +one test / 56 assertions on current Game; it was not weakened or replaced. +GUI's complete offline release suite passes 140 tests. These checks do not +close native visual acceptance, other platforms, or the separately assigned +retained-underlay terminal/collision restoration correction. + **A cutscene is a pair, and the pair is one asset.** `CutsceneAsset` reads `.data.php` with `.script.php` or `.timeline.php`, knows which keys belong to which file, is dirty by content, and saves atomically: @@ -934,10 +1259,11 @@ folder, two folders differing only by case), hands each type to **Source preservation, at the token level.** `PhpArraySourceDocument` maps a returned-array file to byte spans — prelude variables, nowdocs and heredocs included — and `ArraySourceWriter` turns an array diff into the fewest -edits: moves keep their bytes, a single-reference nowdoc is edited in place, -a shared one is retargeted to a new variable, multi-line text is written as a -nowdoc, and an expression it cannot express is refused by path rather than -flattened. Every rewrite is evaluated and compared before it is written. +edits: moves keep their bytes, a single-reference nowdoc or literal variable +is edited where it is assigned, a shared one is retargeted (a new variable +for text, a copy of plain data otherwise), multi-line text is written as a +nowdoc, and an expression or a variable the file builds with its own code is +refused by path rather than flattened. Every rewrite is evaluated and compared before it is written. **The tree is the truth; the rest are projections.** The command tree is edited through the record pane's frames (the same frames Common Events use, @@ -954,7 +1280,8 @@ presentation that waits for the author) and hands the asset to the engine's pause or step, read-only views of lanes, checkpoints, failures (mapped back to outline rows through the engine's own command paths) and final state, a watched-versus-skipped comparison, and a playtest overlay whose start map -carries an automatic `CinematicEventTrigger`. `SummonPreviewSession` does +carries an automatic `CinematicEventTrigger` (since 2026-10-13, a launch +context naming the cinematic instead). `SummonPreviewSession` does the same over `SummonPlaybackSession`: the playhead, frame clock, active segments and cue schedule are the engine's; the editor draws the frame by the battle field's rules and logs the cues it crossed. @@ -998,8 +1325,9 @@ line and leaves comments, fully-qualified class names, nested constructors and emoji exactly as authored. A file it cannot read by argument name is refused rather than rewritten, and the ordinary writer takes over. -**An actor is more than its class.** A durable definition id (with the row -saying what a save resolves without one), actor-natural adjustments that keep +**An actor is more than its class.** An explicit, immutable definition id +(assigned at creation; missing legacy ids are validation errors and must be +frozen from the current name before renaming), actor-natural adjustments that keep their sign and drop zeroes, and named natural variants with a default — where the rows edit whichever set the runtime has in force. Beside them, what each stat actually comes to, resolved by `StatResolver` and @@ -1007,6 +1335,25 @@ each stat actually comes to, resolved by `StatResolver` and with what the cap threw away. Accuracy and Critical are absent by design; the runtime does not resolve them as layers. +Legacy actor identity repair is shared by the TUI and CLI through +`ActorIdentityMigration`. It offers a one-time freeze of the current name, not +free-text identity retargeting. Read-only project plans include starting-party, +skit, presentation and battle-entry-rule actor-reference repairs, even when +IDs were frozen earlier. Confirmation applies all listed files transactionally; +the reversible plan guards undo/redo against outside changes. Project repair +writes immediately after confirmation, unlike the retained single-actor freeze +which stays deferred until save when no other reference files need changing. +Both TUI and CLI use that plan; unresolved references are validation errors. +Actor-reference issues carry the shared `UNRESOLVED_ACTOR_REFERENCE` code so +callers need not match diagnostic prose. Skit diagnostics identify the actual +source filename, not its sorted record index. Migration failures retain their +original exception as the cause and add file context only once. +Existing actor saves now preserve authored source instead of regenerating the +file: comments, imports and unchanged expressions survive repair and rename. +Unsupported source edits and externally changed files are refused before writes. +Display-name and filename lookup aliases are removed; only definition IDs bind +references, independently of names, filenames and artwork. + **A catalogue in a file that holds several.** `RecordProjection` is the seam for a data file shaped for the runtime rather than for an editor. A category declares how its records are read out of the whole payload and folded back diff --git a/docs/website/guide/tooling-loop/the-database.md b/docs/website/guide/tooling-loop/the-database.md index 36ad0e66..37a7eec9 100644 --- a/docs/website/guide/tooling-loop/the-database.md +++ b/docs/website/guide/tooling-loop/the-database.md @@ -10,6 +10,33 @@ readTime: 4 Every field edit, entry creation, and deletion is undoable. Undo is identity-pinned, so `Ctrl+Z` applies to the entry you edited even after you have moved on to another one. +## Animation references + +Skills and Items have an **Animation** resource picker. It shows each +animation's name and numeric id and stores the id, so renaming an animation +does not break the reference. Skill `Ctrl+G` follows that id to the animation. +The edit participates in the existing undo/redo and safe-save paths; an +unsupported source form is refused rather than flattened. Skill saves now patch +only changed constructor arguments, preserving effects, weapon requirements, +comments and other authored expressions. This replaces the old skill exporter, +which incorrectly regenerated non-damage effects as damage effects. Changing a +skill's class or regenerating effects without their original source is refused +before writing; edit those expressions in source instead. + +An unsupported skill expression is read-only, not a reason to block other +skills in the same file. Refusals identify the skill. The form omits Type +and any fields the selected constructor cannot save, including Effect Type +on non-magic skills. Leading documentation and same-line trailing comments +travel with a skill when it is moved, deleted or restored. + +Choose **(Legacy fallback)** for a skill or **(None)** for an item to clear +the reference, removing the authored argument rather than writing a null +placeholder. Skills then retain their +deprecated name-based selection, which project validation reports as a +warning when it matches. Items without a reference retain their existing +no-animation behavior. A missing explicit id omits the animation, not the +gameplay effect, and does not silently substitute a name-matched animation. + ## Repeating fields Quest objectives, skit beats, troop members, and event commands are all *sub-lists*, flattened into the field pane as numbered rows. Two keys manage them everywhere: @@ -33,17 +60,15 @@ The types are `quest`, `switch`, `event`, `item`, `key_item`, and `variable`. Un Whether a category can be written is *detected*, not assumed. When a category loads, the editor asks two questions about its file: can every value be written back out losslessly, and would a rewrite drop a comment? Only when both answers are safe does the category accept edits. -Editable today: Actors, Classes, Skills, Troops, States, Animations, Quests, Skits, Common Events, System, and Terms. - -Browsable but not writable: Items, Weapons, Armors, and Enemies. +Editable today: Actors, Classes, Skills, Items, Weapons, Armors, Enemies, Troops, States, Animations, Quests, Skits, Common Events, System, and Terms. -The reason is worth understanding, because it is a property of your project rather than a missing feature. `items.php` and `enemies.php` are not data files — they are PHP that *builds* data, constructing effect objects inline and sharing skills between enemies through local variables. Regenerating such a file from the values the editor loaded would mean inventing source, and anything the editor did not recognise would be silently lost. +Enemies are one data file each under `assets/Data/Enemies`, and `enemies.php` loads them. An enemy's action patterns name skills from your skill catalogue, so you pick the skill rather than rebuild it. -So the editor shows you the values and refuses to write. An enemy displays its level, every stat, its sprite, its battle rewards, and its element affinities — you simply edit the file itself when you want to change them. +Items, weapons and armors are one data file each under `assets/Data/Items`, `Weapons` and `Armors`, numbered in the order shops list them, and `items.php` loads all three. An item's effects are a list you edit row by row; a change to one record touches only its own file, keeping your imports and comments. Re-author one of those files as a plain array and the editor picks it up as editable automatically. Nothing in the editor needs to change. -`Types` is read-only for a related reason: those files are PHP enum declarations, not data the engine loads. `Tilesets` is empty because the engine has no tileset system — map tiles are painted directly on the canvas. +`Types` holds your elements: the list in `system.php` that the game knows every element by, and that skills, equipment and affinities pick from. System and Types share `system.php`, and each saves only its own settings. `Tilesets` edits each tileset's sheets, tile flags, shadow and the pieces maps are built from, one file per tileset. ## What saving preserves diff --git a/phpunit.xml b/phpunit.xml index 1fdec1e9..2a250d59 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ diff --git a/scripts/install-git-guards.sh b/scripts/install-git-guards.sh new file mode 100755 index 00000000..0dd03144 --- /dev/null +++ b/scripts/install-git-guards.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu + +repo=$(git rev-parse --show-toplevel) +cd "$repo" +hooks_dir=$(git rev-parse --git-path hooks) +case "$hooks_dir" in /*) ;; *) hooks_dir="$repo/$hooks_dir" ;; esac + +# Retain core.hooksPath and every existing hook, including safeguard symlinks. +# An unknown hook needs a reviewed composition; this installer cannot replace it. +for name in pre-commit pre-merge-commit pre-push; do + source="$repo/.githooks/$name" + destination="$hooks_dir/$name" + [ -f "$source" ] && [ -x "$source" ] || { + printf 'Install blocked: missing executable guard %s\n' "$source" >&2 + exit 1 + } + if [ -e "$destination" ] || [ -L "$destination" ]; then + if [ ! -f "$destination" ] || [ ! -x "$destination" ] || ! cmp -s "$source" "$destination"; then + printf 'Install blocked: preserve and review existing hook %s\n' "$destination" >&2 + exit 1 + fi + fi +done + +mkdir -p "$hooks_dir" +for name in pre-commit pre-merge-commit pre-push; do + destination="$hooks_dir/$name" + if [ ! -e "$destination" ] && [ ! -L "$destination" ]; then + ln -s "$repo/.githooks/$name" "$destination" + fi +done +printf 'Git guards verified in %s; existing hooks and core.hooksPath preserved.\n' "$hooks_dir" diff --git a/src/Actors/ActorAuthoring.php b/src/Actors/ActorAuthoring.php new file mode 100644 index 00000000..85b810f7 --- /dev/null +++ b/src/Actors/ActorAuthoring.php @@ -0,0 +1,771 @@ + Which variant each actor's rows are editing. */ + private array $variantSelections = []; + + /** @var array Which growth each actor's preview assumes. */ + private array $growthSelections = []; + + /** @var array Which slot each actor's Optimize preview fills. */ + private array $optimizeSlots = []; + + public function __construct(private readonly bool $graphical = false) {} + + /** + * Applies one actor row: a choice of what the panes show, which records + * nothing, or a value of the actor, as one undo step. A value the actor's + * own rules refuse - renaming an actor that has no identity yet, an + * attack style the Engine does not know - leaves the actor as it was. + * + * @param ProjectWorkspace $workspace The project. + * @param int $index The actor. + * @param string $field The row's field id. + * @param string $rawValue The value as entered. + * @param string $label The row's label, which names the undo step. + * @throws RecordRefusal When the actor is gone or the value is refused. + */ + public function applyField(ProjectWorkspace $workspace, int $index, string $field, string $rawValue, string $label): RecordChange + { + $actor = $workspace->actorDatabase->getActorByIndex($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + + // Choices about the panes, not values the project stores. + if ($field === self::VARIANT_FIELD) { + $this->variantSelections[$actor->getDefinitionId()] = trim($rawValue); + + return new RecordChange(null, $index); + } + + if ($field === self::GROWTH_FIELD) { + $this->growthSelections[$actor->getDefinitionId()] = trim($rawValue); + + return new RecordChange(null, $index); + } + + if ($field === self::OPTIMIZE_SLOT_FIELD) { + $this->optimizeSlots[$actor->getDefinitionId()] = trim($rawValue); + + return new RecordChange(null, $index); + } + + $before = $actor->getData(); + + try { + if (str_starts_with($field, 'images.field2d.')) { + if (! $this->graphical || ! $actor->hasDefinitionId()) { + throw new RuntimeException('Field art requires the graphical editor and an established actor identity.'); + } + if (array_key_exists('images', $before) && ! is_array($before['images'])) { + throw new RuntimeException('Actor images is not an editable role map; correct its source before binding field art.'); + } + $after = $before; + $binding = CharacterSheetFields::applyField($before['images']['field2d'] ?? null, + substr($field, strlen('images.field2d.')), $rawValue, $workspace->projectRoot . '/assets'); + if ($binding === null) { + unset($after['images']['field2d']); + } else { + $after['images']['field2d'] = $binding; + } + // Prove literal source and external-file freshness before making an undo step. + $actor->getProposedSource($after); + $actor->restoreData($after); + } else { + $workspace->actorDatabase->setField($index, $field, self::coerceActorFieldValue($field, $rawValue)); + } + } catch (RuntimeException|InvalidArgumentException $refused) { + $actor->restoreData($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + $after = $actor->getData(); + + if ($after === $before) { + return new RecordChange(null, $index); + } + + return new RecordChange(new GenericCommand( + sprintf('%s edit', $label), + static fn() => $actor->restoreData($after), + static fn() => $actor->restoreData($before), + ), $index); + } + + /** + * Creates a blank actor at the end of the list, under a fresh identity, + * as one undo step. Its file is written on save. + * + * @return RecordChange The new actor's index. + */ + public function createActor(ProjectWorkspace $workspace): RecordChange + { + $database = $workspace->actorDatabase; + $index = $database->addActor(); + $actor = $database->getActorByIndex($index) + ?? throw new RecordRefusal('The new actor could not be created.'); + + return new RecordChange(new GenericCommand( + 'Create actor', + static fn() => $database->insertActor($index, $actor), + static fn() => $database->removeActor($index), + ), $index); + } + + /** + * Deletes an actor as one undo step. Its file goes on save, which is + * what makes the undo honest. The change's index is the actor to select + * next: the one before it, or null when none is left. + * + * @throws RecordRefusal When the actor is gone. + */ + public function deleteActor(ProjectWorkspace $workspace, int $index): RecordChange + { + $database = $workspace->actorDatabase; + $actor = $database->removeActor($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + + return new RecordChange(new GenericCommand( + sprintf('Delete actor %s', $actor->getName()), + static fn() => $database->removeActor($index), + static fn() => $database->insertActor($index, $actor), + ), $database->getActors() === [] ? null : max(0, $index - 1)); + } + + /** + * Freezes the current name of an actor authored without an id as its + * permanent id, as one undo step written on save. Later renames keep + * that identity, so saves and references stay valid. + * + * @throws RecordRefusal When the actor is gone, already has an id, or its name would collide. + */ + public function freezeIdentity(ProjectWorkspace $workspace, int $index): RecordChange + { + $actor = $workspace->actorDatabase->getActorByIndex($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + $before = $actor->getData(); + + try { + ActorIdentityMigration::freezeCurrentName($workspace->actorDatabase, $actor); + } catch (RuntimeException $refused) { + $actor->restoreData($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + $after = $actor->getData(); + + return new RecordChange(new GenericCommand( + 'Freeze actor identity', + static fn() => $actor->restoreData($after), + static fn() => $actor->restoreData($before), + ), $index); + } + + /** + * Returns the project-wide repair an actor's identity freeze needs when + * freezing its name is not the whole of it: the other files that name + * actors by what the repair changes are rewritten with it, at once. + * Returns null when the freeze alone is the repair. + * + * @throws RecordRefusal When the repair cannot be planned. + */ + public function planIdentityRepair(ProjectWorkspace $workspace, ProjectActor $actor): ?ActorIdentityMigrationPlan + { + try { + $plan = ActorIdentityMigration::planProject($workspace->projectRoot); + } catch (Throwable $failure) { + throw new RecordRefusal(sprintf('The actor identity repair could not be planned: %s', $failure->getMessage()), previous: $failure); + } + + return $plan->getChangedPaths() === [$actor->path] ? null : $plan; + } + + /** + * Describes a project-wide identity repair as the file set it writes, + * for whoever owns the workspace to confirm and write as one step. + */ + public function describeIdentityRepair(ProjectWorkspace $workspace, ProjectActor $actor, ActorIdentityMigrationPlan $plan): SourceSetRequired + { + $paths = array_map( + static fn(string $path): string => ltrim(substr($path, strlen(rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR))), DIRECTORY_SEPARATOR), + $plan->getChangedPaths(), + ); + + return new SourceSetRequired( + $plan->getSourceSet(), + 'Migrate actor identities and references', + 'this actor migration', + sprintf('Freeze "%s" as its permanent id, repairing actor references in %d files?', $actor->getName(), count($paths)), + $paths, + ); + } + + /** + * The rows that say which actor this is to a save. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function getActorIdentityFields(ProjectActor $actor): array + { + $missingId = ! array_key_exists('id', $actor->getData()); + return [ + ['label' => 'Identity', 'value' => '', 'editable' => false, 'field' => ''], + [ + 'label' => 'Definition Id', + 'value' => $actor->hasDefinitionId() ? $actor->getDefinitionId() : '', + 'editable' => $missingId, + // Without an id the row's edit is the one-time freeze of its name. + 'action' => $missingId ? sprintf('Freeze "%s" as the permanent id', $actor->getName()) : null, + 'field' => 'id', + 'displayDefault' => $missingId + ? sprintf('Enter to freeze the current name (%s) as its permanent id', $actor->getName()) + : 'Malformed explicit id: correct the authored actor file; automatic replacement is not allowed.', + ], + ]; + } + + /** + * The rows for an actor's own nature: the adjustments it makes to its + * class baseline, and the named variants of that nature. + * + * While an actor declares variants the runtime reads the selected + * variant's adjustments and ignores the fixed ones, so the rows edit + * whichever set is actually in force. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function actorNatureFields(ProjectActor $actor): array + { + $variants = $actor->getNaturalVariants(); + $selected = $this->getSelectedVariantId($actor); + $rows = [['label' => 'Nature', 'value' => '', 'editable' => false, 'field' => '']]; + + if ($variants !== []) { + $rows[] = [ + 'label' => 'Default Variant', + 'value' => (string) $actor->getDefaultNaturalVariantId(), + 'options' => array_keys($variants), + 'field' => 'defaultNaturalVariantId', + ]; + $rows[] = [ + 'label' => 'Editing Variant', + 'value' => $selected ?? '', + 'options' => array_keys($variants), + 'field' => self::VARIANT_FIELD, + ]; + } + + // Both layers are real: the runtime adds the selected variant on top + // of the fixed adjustments rather than replacing them, so both are + // shown and both are editable. + $inForce = $actor->getNaturalAdjustmentsFor($selected); + $fixed = $actor->getActorNaturalAdjustments(); + $rows = [...$rows, ...$this->actorAdjustmentRows( + $variants === [] ? 'Adjustments' : 'Fixed, always applied', + 'actorNaturalAdjustments', + $fixed, + )]; + + if ($variants !== [] && $selected !== null) { + $rows = [...$rows, ...$this->actorAdjustmentRows( + sprintf('Variant %s, added on top', $selected), + sprintf('naturalVariants.%s', $selected), + $variants[$selected] ?? [], + )]; + $rows[] = [ + 'label' => ' In force', + 'value' => $this->describeAdjustments($inForce), + 'editable' => false, + 'field' => '', + ]; + } + + return $rows; + } + + /** + * The rows for one layer of an actor's nature. + * + * @param string $heading What the layer is. + * @param string $prefix The payload path the rows write to. + * @param array $adjustments The layer's adjustments. + * @return array> The rows. + */ + private function actorAdjustmentRows(string $heading, string $prefix, array $adjustments): array + { + $rows = [['label' => ' ' . $heading, 'value' => '', 'editable' => false, 'field' => '']]; + + foreach (ActorStatPreview::statKeys() as $key) { + $amount = $adjustments[$key] ?? 0; + $rows[] = [ + 'label' => ' ' . ucfirst(strtolower((string) preg_replace('/(? (string) $amount, + 'control' => new InputControl(InputControlType::INTEGER, (string) $amount), + 'field' => $prefix . '.' . $key, + ]; + } + + return $rows; + } + + /** + * Describes what an actor's nature comes to once composed. + * + * @param array $adjustments The composed adjustments. + * @return string The description. + */ + private function describeAdjustments(array $adjustments): string + { + $parts = []; + + foreach ($adjustments as $key => $amount) { + if ($amount !== 0) { + $parts[] = sprintf('%+d %s', $amount, $key); + } + } + + return $parts === [] ? 'nothing adjusted' : implode(', ', $parts); + } + + /** + * The read-only rows showing what each stat actually comes to. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function actorStatPreviewFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + if (! ActorStatPreview::isAvailable()) { + return []; + } + + $catalog = $workspace->catalogs->loadPermanentGrowth(); + $assumed = $this->assumedGrowthFor($actor); + $rows = [ + ['label' => 'Resolved Stats', 'value' => '', 'editable' => false, 'field' => ''], + [ + // Earned growth is save state. What a preview can do is + // assume some of it, say that it is assuming, and write + // nothing. + 'label' => ' Assumed Growth', + 'value' => $assumed, + 'options' => [ + self::NO_ASSUMED_GROWTH, + self::ALL_ASSUMED_GROWTH, + ...$catalog->ids(), + ], + 'field' => self::GROWTH_FIELD, + 'displayDefault' => 'assumed for this preview only; the party earns growth in play', + ], + ]; + + $permanent = match ($assumed) { + self::NO_ASSUMED_GROWTH => [], + self::ALL_ASSUMED_GROWTH => $catalog->totalsFor(), + default => $catalog->totalsFor([$assumed]), + }; + + foreach (ActorStatPreview::resolve($actor, $this->getSelectedVariantId($actor), $permanent) as $row) { + $rows[] = [ + 'label' => ' ' . $row['stat'], + 'value' => ActorStatPreview::describeRow($row), + 'editable' => false, + 'field' => '', + ]; + } + + return [...$rows, ...$this->actorOptimizeFields($workspace, $actor)]; + } + + /** + * Returns which permanent growth this actor's preview assumes. + * + * @param ProjectActor $actor The actor. + * @return string The selection. + */ + private function assumedGrowthFor(ProjectActor $actor): string + { + return $this->growthSelections[$actor->getDefinitionId()] ?? self::NO_ASSUMED_GROWTH; + } + + /** + * Returns which slot this actor's Optimize preview fills. + * + * @param ProjectActor $actor The actor. + * @return string The semantic slot. + */ + private function optimizeSlotFor(ProjectActor $actor): string + { + $slots = EquipmentOptimizationPolicy::slotKeys(); + $selected = $this->optimizeSlots[$actor->getDefinitionId()] ?? ''; + + return in_array($selected, $slots, true) ? $selected : ($slots[0] ?? 'weapon'); + } + + /** + * The read-only rows showing what Optimize would choose, and why. + * + * @param ProjectActor $actor The actor. + * @return array> The rows. + */ + private function actorOptimizeFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + if (! EquipmentOptimizationPolicy::isAvailable()) { + return []; + } + + $slot = $this->optimizeSlotFor($actor); + $rows = [ + ['label' => 'Optimize Preview', 'value' => '', 'editable' => false, 'field' => ''], + [ + // Which policy is scoring is the first thing to know: a + // project that has declared none is not being scored by its + // own rules at all. + 'label' => ' Policy', + 'value' => EquipmentOptimizationPolicy::describeSource($workspace), + 'editable' => false, + 'field' => '', + ], + [ + 'label' => ' Slot', + 'value' => $slot, + 'options' => EquipmentOptimizationPolicy::slotKeys(), + 'field' => self::OPTIMIZE_SLOT_FIELD, + ], + ]; + $ranked = EquipmentOptimizationPolicy::rank($workspace, $actor, $slot); + + if ($ranked === []) { + $rows[] = [ + 'label' => ' (nothing)', + 'value' => 'no equipment this project has fits that slot', + 'editable' => false, + 'field' => '', + ]; + + return $rows; + } + + foreach ($ranked as $position => $candidate) { + $rows[] = [ + 'label' => sprintf(' %d. %s', $position + 1, $candidate['name']), + 'value' => sprintf( + '%d · %s', + $candidate['value'], + EquipmentOptimizationPolicy::describeRow($candidate), + ), + 'editable' => false, + 'field' => '', + ]; + } + + return $rows; + } + + /** + * Returns which natural variant the actor rows are editing. + * + * @param ProjectActor $actor The actor. + * @return string|null The variant id, or null when the actor has none. + */ + public function getSelectedVariantId(ProjectActor $actor): ?string + { + $variants = $actor->getNaturalVariants(); + + if ($variants === []) { + return null; + } + + $selected = $this->variantSelections[$actor->getDefinitionId()] ?? null; + + return $selected !== null && isset($variants[$selected]) + ? $selected + : ($actor->getDefaultNaturalVariantId() ?? array_key_first($variants)); + } + + /** + * The rows an actor shows, in the order the editors list them. + * + * @return array> + */ + public function describeFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + return [ + ...$this->getActorIdentityFields($actor), + [ + 'label' => 'Name', + 'value' => $actor->getName(), + 'control' => new InputControl(InputControlType::TEXT, $actor->getName()), + 'editable' => $actor->hasDefinitionId(), + 'field' => 'name', + ], + [ + 'label' => 'Description', + 'value' => $actor->getDescription(), + 'control' => new InputControl(InputControlType::TEXT, $actor->getDescription()), + 'field' => 'description', + ], + [ + // The character-class reference: a name from + // assets/Data/classes.php, written to the actor's + // data['class'] key (the engine's ClassStore hydrates it + // into a CharacterRole). ←/→ cycles the picker; Ctrl+G jumps + // to the class entry. + 'label' => 'Class', + 'value' => $actor->getClassName() === '' ? ProjectActor::CLASS_NONE : $actor->getClassName(), + 'options' => $this->getActorClassOptions($workspace), + 'field' => 'class', + ], + [ + // The character's own weapon, part of who they are and with + // no stats: what their attack looks like when no weapon is + // equipped. An equipped weapon's type takes its place. + 'label' => 'Attack Style', + 'value' => $actor->getAttackStyle() === '' ? 'Unarmed' : $actor->getAttackStyle(), + 'options' => [ProjectActor::ATTACK_STYLE_UNARMED, ...array_map( + static fn(WeaponType $type): string => $type->value, WeaponType::cases())], + 'field' => 'attackStyle', + 'hint' => 'own weapon when none is equipped; no stats', + ], + [ + // The basic skill the Attack command uses; a battle-usable + // basic skill from the catalogue, or the Engine's own attack. + 'label' => 'Attack Skill', + 'value' => $actor->getAttackSkill() === '' ? self::BUILT_IN_ATTACK : $actor->getAttackSkill(), + 'reference' => 'attack_skills', + 'allowsNone' => true, + 'noneLabel' => self::BUILT_IN_ATTACK, + 'field' => 'attackSkill', + ], + [ + // The skill this actor responds with when a physical hit + // lands on it; off unless chosen, picked from the skills the + // Engine's counter rule accepts. + 'label' => 'Counter Attack', + 'value' => $actor->getCounterAttackSkill() === '' ? self::NO_COUNTER : $actor->getCounterAttackSkill(), + 'reference' => 'counter_skills', + 'allowsNone' => true, + 'noneLabel' => self::NO_COUNTER, + 'field' => 'counterAttack', + ], + ...$this->actorSummonFields($workspace, $actor), + ...($this->graphical ? $this->getFieldAppearanceFields($workspace, $actor) : []), + [ + 'label' => 'Level', + 'value' => (string) $actor->getLevel(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getLevel()), + 'field' => 'level', + ], + [ + 'label' => 'Current Exp', + 'value' => (string) $actor->getCurrentExp(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getCurrentExp()), + 'field' => 'currentExp', + ], + [ + 'label' => 'Current HP', + 'value' => (string) $actor->getStat('currentHp'), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentHp')), + 'field' => 'currentHp', + ], + [ + 'label' => 'Current MP', + 'value' => (string) $actor->getStat('currentMp'), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentMp')), + 'field' => 'currentMp', + ], + [ + 'label' => 'Current AP', + 'value' => (string) $actor->getStat('currentAp'), + 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentAp')), + 'field' => 'currentAp', + ], + ...$this->actorNatureFields($actor), + ...$this->actorStatPreviewFields($workspace, $actor), + ]; + } + + private function getFieldAppearanceFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + $binding = $actor->getImages()['field2d'] ?? null; + $rows = [['label' => 'Field Appearance', 'value' => 'Actor role: images.field2d. Independent of terminal and battle art.', 'editable' => false]]; + foreach (CharacterSheetFields::getFields($binding, 'images.field2d', 'Actor Field Sheet') as $field) { + $key = substr($field->key, strlen('images.field2d.')); + $value = is_array($binding) ? ($binding[$key] ?? null) : null; + $shown = $value ?? ($field->reference === null ? $field->displayDefault : ''); + $rows[] = [ + 'label' => $field->label, 'field' => $field->key, + 'value' => is_string($shown) || is_int($shown) ? (string) $shown : '(malformed)', + 'editable' => $actor->hasDefinitionId(), + ...($field->reference !== null ? ['reference' => $field->reference, 'allowsNone' => true, 'noneLabel' => $field->noneLabel()] : []), + ...($field->options !== [] ? ['options' => $field->options] : []), + ...($field->reference === null && $field->options === [] ? ['control' => new InputControl($field->type, + is_string($shown) || is_int($shown) ? (string) $shown : '')] : []), + 'displayDefault' => $field->displayDefault, + ]; + } + if (! $actor->hasDefinitionId()) { + $rows[] = ['label' => 'Field Art Identity', 'value' => 'Establish the actor\'s permanent id before binding artwork.', 'editable' => false]; + } + + return CharacterSheetFields::describeFields($rows, $binding, 'images.field2d', $workspace->projectRoot . '/assets'); + } + + /** + * Returns the actor's starting summon assignments: a multi-pick over the + * project's summons, and one verdict row per assignment judged by the + * same rules the validator applies (existence, wielder eligibility, + * story locks, duplicates, exclusive tenancy across the cast). + * + * @return array> + */ + private function actorSummonFields(ProjectWorkspace $workspace, ProjectActor $actor): array + { + $assignments = $actor->getSummons(); + $list = is_array($assignments) ? array_values(array_filter(array_map(static fn(mixed $id): string => is_string($id) ? trim($id) : '', $assignments), static fn(string $id): bool => $id !== '')) : []; + $rows = [ + [ + 'label' => 'Summons', + 'value' => implode(', ', $list), + 'reference' => 'summons', + 'multi' => true, + 'noneLabel' => '(None)', + 'field' => 'summons', + ], + ]; + $diagnostics = SummonAssignmentDiagnostics::fromLibrary($workspace->cutscenes); + $holders = []; + + foreach ($workspace->actorDatabase->getActors() ?? [] as $other) { + if ($other === $actor) { + continue; + } + + foreach ((array) $other->getSummons() as $id) { + if (is_string($id) && trim($id) !== '') { + $holders[strtolower(trim($id))][] = $other->getName(); + } + } + } + + foreach ($diagnostics->forActor($actor->getRuntimeId(), $actor->getClassName(), $assignments) as $row) { + $verdict = SummonAssignmentDiagnostics::describe($row); + $id = strtolower($row['id']); + + if ($row['problems'] === [] && $diagnostics->isExclusive($id) && isset($holders[$id])) { + $verdict = sprintf('✗ %s: exclusive, also held by %s.', $row['id'], implode(', ', $holders[$id])); + } + + $rows[] = ['label' => ' ' . $verdict, 'value' => '', 'editable' => false, 'field' => '']; + } + + return $rows; + } + + /** + * Returns the actor class picker options. + * + * The list is the project's own class names from + * `assets/Data/classes.php`, prefixed with the "none" sentinel that + * clears the reference. + * + * @return string[] + */ + private function getActorClassOptions(ProjectWorkspace $workspace): array + { + return [ + ProjectActor::CLASS_NONE, + ...$workspace->getRecordDatabase('classes')?->getEntryLabels() ?? [], + ]; + } + + /** + * Returns an edited actor value as the field's own type. + * + * Most actor numbers are quantities that cannot go below zero, but an + * actor's nature is an adjustment: being slower than the class baseline + * is a legitimate thing to author, so those keep their sign. + * + * @param string $field The field identifier. + * @param string $rawValue The raw edited value. + * @return string|int The coerced value. + */ + private static function coerceActorFieldValue(string $field, string $rawValue): string|int + { + if (in_array($field, ['name', 'description', 'class', 'attackStyle', 'attackSkill', 'counterAttack', 'id', 'defaultNaturalVariantId', 'summons'], true)) { + return trim($rawValue); + } + + if (str_starts_with($field, 'actorNaturalAdjustments.') || str_starts_with($field, 'naturalVariants.')) { + return intval(trim($rawValue)); + } + + return max(0, intval($rawValue)); + } +} diff --git a/src/Actors/ActorCategory.php b/src/Actors/ActorCategory.php new file mode 100644 index 00000000..94c3bdaf --- /dev/null +++ b/src/Actors/ActorCategory.php @@ -0,0 +1,183 @@ +key = 'actors'; + } + + public function isEditable(): bool + { + return true; + } + + public function getReadOnlyReason(): ?string + { + return null; + } + + public function isDirty(): bool + { + return $this->workspace->actorDatabase->isDirty(); + } + + public function supportsRecordCreation(): bool + { + return true; + } + + public function supportsRecordDuplication(): bool + { + return false; + } + + public function supportsRecordDeletion(): bool + { + return true; + } + + public function supportsDurableReorder(): bool + { + return false; + } + + public function getRecordLabels(): array + { + return array_map(static fn(ProjectActor $actor): string => $actor->getName(), $this->workspace->actorDatabase->getActors()); + } + + public function getRecordRows(int $index, array $frame): array + { + self::refuseFrame($frame); + + return $this->authoring->describeFields($this->workspace, $this->requireActor($index)); + } + + public function describeFrame(array $frame): ?string + { + return null; + } + + public function getListNoun(array $frame): ?string + { + return null; + } + + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem + { + return null; + } + + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange + { + self::refuseFrame($frame); + $actor = $this->requireActor($index); + + if ($fieldId === 'id' && ! array_key_exists('id', $actor->getData())) { + // An actor authored without an id: the edit is the one-time + // freeze of its name, as the terminal's Enter on the row is. + // When other files name actors by what it changes, the freeze is + // that whole repair, written at once by the workspace's owner. + $repair = $this->authoring->planIdentityRepair($this->workspace, $actor); + + if ($repair !== null) { + throw $this->authoring->describeIdentityRepair($this->workspace, $actor, $repair); + } + + return $this->authoring->freezeIdentity($this->workspace, $index); + } + + return $this->authoring->applyField($this->workspace, $index, $fieldId, $value, $label); + } + + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange + { + throw new RecordRefusal('An actor has no list to add to.'); + } + + public function removeItem(int $index, array $frame, string $fieldId): RecordChange + { + throw new RecordRefusal('An actor has no list to remove from.'); + } + + public function createRecord(?string $identity = null): RecordChange + { + if ($identity !== null) { + throw new RecordRefusal('A new actor is named once it is made, not for another record.'); + } + + return $this->authoring->createActor($this->workspace); + } + + public function duplicateRecord(int $index, ?string $identity = null): RecordChange + { + throw new RecordRefusal('Actors cannot be duplicated: each one is a character with its own identity. Create a new actor instead.'); + } + + public function deleteRecord(int $index): RecordChange + { + return $this->authoring->deleteActor($this->workspace, $index); + } + + public function moveRecord(int $index, int $step): RecordChange + { + throw new RecordRefusal('Each actor is its own file; the list shows them in file order, which a move cannot change.'); + } + + public function getBackupPaths(): array + { + return array_values($this->workspace->actorDatabase->getBackupPaths()); + } + + public function save(): void + { + try { + $this->workspace->actorDatabase->save(); + } catch (RuntimeException $failure) { + // The actors that could not be written stay unsaved, and the + // message names the first one and why. + throw new RecordRefusal($failure->getMessage(), previous: $failure); + } + } + + /** @throws RecordRefusal */ + private function requireActor(int $index): ProjectActor + { + return $this->workspace->actorDatabase->getActorByIndex($index) + ?? throw new RecordRefusal(sprintf('actors has no record %d.', $index)); + } + + /** + * @param list $frame + * @throws RecordRefusal + */ + private static function refuseFrame(array $frame): void + { + if ($frame !== []) { + throw new RecordRefusal('Actors have no command frames.'); + } + } +} diff --git a/src/Actors/ActorIdentityIndex.php b/src/Actors/ActorIdentityIndex.php new file mode 100644 index 00000000..62e2b4ce --- /dev/null +++ b/src/Actors/ActorIdentityIndex.php @@ -0,0 +1,53 @@ +getActors() as $actor) { + $data = $actor->getData(); + $id = array_key_exists('id', $data) ? $data['id'] : ($data['name'] ?? null); + if (! is_string($id) || trim($id) === '') { + throw new RuntimeException("{$actor->path}: actor identity must be a non-empty string."); + } + $id = trim($id); + $key = strtolower($id); + if (isset($this->ids[$key])) { + throw new RuntimeException("{$actor->path}: actor id \"{$id}\" conflicts with another actor."); + } + $this->ids[$key] = $id; + foreach ([$actor->id, $actor->getName()] as $alias) { + $this->aliases[strtolower(trim($alias))][$id] = true; + } + } + } + + public function resolveReference(mixed $reference, string $where, bool $optionalSpeaker = false): ?string + { + if (! is_string($reference) || trim($reference) === '') { + if ($optionalSpeaker) { return null; } + throw new RuntimeException("{$where}: actor reference must be a non-empty string."); + } + $key = strtolower(trim($reference)); + // An explicit ID is never hijacked by another actor's name or file. + if (isset($this->ids[$key])) { return $this->ids[$key]; } + $ids = array_keys($this->aliases[$key] ?? []); + if (count($ids) > 1) { + throw new RuntimeException("{$where}: ambiguous legacy actor reference \"{$reference}\"."); + } + if ($ids !== []) { return $ids[0]; } + if ($optionalSpeaker) { return null; } + throw new RuntimeException("{$where}: unresolved actor reference \"{$reference}\"."); + } +} diff --git a/src/Actors/ActorIdentityMigration.php b/src/Actors/ActorIdentityMigration.php new file mode 100644 index 00000000..9c8a2dbb --- /dev/null +++ b/src/Actors/ActorIdentityMigration.php @@ -0,0 +1,113 @@ + Only absent IDs, never malformed authored IDs. */ + public static function getPendingActors(ProjectActorDatabase $database): array + { + return array_values(array_filter($database->getActors(), + static fn(ProjectActor $actor): bool => ! array_key_exists('id', $actor->getData()))); + } + + /** Freeze in memory only; the editor records getData/restoreData in its undo history. */ + public static function freezeCurrentName(ProjectActorDatabase $database, ProjectActor $actor): void + { + if (! in_array($actor, $database->getActors(), true)) { + throw new RuntimeException('The actor does not belong to this project database.'); + } + if (array_key_exists('id', $actor->getData())) { + throw new RuntimeException('Only an absent actor id can be migrated; established or malformed ids cannot be replaced.'); + } + $name = $actor->getData()['name'] ?? null; + if (! is_string($name) || trim($name) === '') { + throw new RuntimeException("{$actor->path} has no non-empty current name to freeze."); + } + $id = trim($name); + foreach ($database->getActors() as $other) { + if ($other === $actor) { continue; } + $otherId = array_key_exists('id', $other->getData()) ? $other->getDefinitionId() : trim($other->getName()); + if (strtolower($otherId) === strtolower($id)) { + throw new RuntimeException("Cannot freeze actor id \"{$id}\": it conflicts with {$other->path}."); + } + } + $data = $actor->getData(); + $data['id'] = $id; + $actor->getProposedSource($data); + $actor->setField('id', $id); + } + + /** Read-only plan; callers show changed paths and obtain confirmation before apply. */ + public static function planProject(string $projectRoot): ActorIdentityMigrationPlan + { + return ProjectDirectoryContext::run($projectRoot, self::buildPlan(...)); + } + + private static function buildPlan(string $projectRoot): ActorIdentityMigrationPlan + { + $database = ProjectActorDatabase::fromProject($projectRoot); + $index = new ActorIdentityIndex($database); + $watched = $originals = $proposals = $before = $after = []; + foreach ($database->getActors() as $actor) { + $watched[$actor->path] = (string) file_get_contents($actor->path); + if (array_key_exists('id', $actor->getData())) { continue; } + $before[$actor->path] = ActorReferenceInventory::getComparableValue(PhpDataFile::evaluateIsolated($actor->path, $projectRoot)); + try { + self::freezeCurrentName($database, $actor); + $proposals[$actor->path] = $actor->getProposedSource(); + } catch (Throwable $failure) { + throw self::createContextualFailure($actor->path, $failure); + } + $originals[$actor->path] = $watched[$actor->path]; + $after[$actor->path] = $before[$actor->path]; + $after[$actor->path]['data'] = $actor->getData(); + } + foreach (ActorReferenceInventory::getSourcePaths($projectRoot) as $path) { + $source = (string) file_get_contents($path); + $watched[$path] = $source; + $payload = PhpDataFile::evaluateIsolated($path, $projectRoot); + $relative = substr($path, strlen($projectRoot) + 1); + $changes = []; + foreach (ActorReferenceInventory::getReferences($payload, $relative) as $reference) { + $target = $index->resolveReference($reference['reference'], $path . ':' . implode('.', $reference['path']), $reference['kind'] === 'speaker'); + if ($target !== null && ($target !== $reference['reference'] || $reference['kind'] === 'speaker')) { + $changes[] = [...$reference, 'target' => $target]; + } + } + if ($changes === []) { continue; } + try { + $after[$path] = ActorReferenceSource::getUpdatedValue($payload, $changes); + $proposals[$path] = ActorReferenceSource::rewrite($source, $changes, $payload); + } catch (Throwable $failure) { + throw self::createContextualFailure($path, $failure); + } + $originals[$path] = $source; + $before[$path] = ActorReferenceInventory::getComparableValue($payload); + } + return new ActorIdentityMigrationPlan($projectRoot, $watched, $originals, $proposals, $before, $after); + } + + /** Explicit confirmed operation. Returns every changed actor and reference file. */ + public static function migrateProject(string $projectRoot): array + { + return self::planProject($projectRoot)->apply(); + } + + private static function createContextualFailure(string $path, Throwable $failure): RuntimeException + { + $message = $failure->getMessage(); + if (! str_contains($message, $path)) { $message = "{$path}: {$message}"; } + return new RuntimeException($message, previous: $failure); + } +} diff --git a/src/Actors/ActorIdentityMigrationPlan.php b/src/Actors/ActorIdentityMigrationPlan.php new file mode 100644 index 00000000..1bb6bd24 --- /dev/null +++ b/src/Actors/ActorIdentityMigrationPlan.php @@ -0,0 +1,37 @@ +sources = new SourceSetPlan($root, $watched, $originals, $proposals, $beforeValues, $afterValues, + static fn(): array => [...(glob($root . '/assets/Data/Actors/*.php') ?: []), ...ActorReferenceInventory::getSourcePaths($root)], + static fn(string $path, string $staged): mixed => PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + 'actor migration', + 'Project actor/reference files', + ); + } + + public function getSourceSet(): SourceSetPlan { return $this->sources; } + public function getChangedPaths(): array { return $this->sources->getChangedPaths(); } + public function getOriginalSources(): array { return $this->sources->getOriginalSources(); } + public function getProposedSources(): array { return $this->sources->getProposedSources(); } + public function apply(): array { return $this->sources->apply(); } + public function revert(): void { $this->sources->revert(); } +} diff --git a/src/Actors/ActorReferenceInventory.php b/src/Actors/ActorReferenceInventory.php new file mode 100644 index 00000000..55822596 --- /dev/null +++ b/src/Actors/ActorReferenceInventory.php @@ -0,0 +1,122 @@ + */ + public static function getReferences(mixed $payload, string $relativePath): array + { + $payload = self::getComparableValue($payload); + if (! is_array($payload)) { return []; } + $references = []; + if ($relativePath === 'assets/Data/system.php') { + foreach ((array) ($payload['startingParty'] ?? []) as $index => $id) { + $references[] = ['path' => ['startingParty', $index], 'reference' => $id, 'kind' => 'value', 'caseInsensitive' => true]; + } + } + if (str_starts_with($relativePath, 'assets/Data/Presentation/')) { + // Dialogue artwork, menu portraits and battle artwork/poses are keyed by actor ID. + foreach (['actors', 'portraits', 'actorPoses'] as $field) { + foreach (array_keys((array) ($payload[$field] ?? [])) as $id) { + $references[] = ['path' => [$field, $id], 'reference' => $id, 'kind' => 'key']; + } + } + foreach (array_keys((array) ($payload['results']['portraits'] ?? [])) as $id) { + $references[] = ['path' => ['results', 'portraits', $id], 'reference' => $id, 'kind' => 'key']; + } + // Battle scale: its reference body and party profiles; enemy profiles are enemy identities. + $scale = $payload['scale'] ?? null; + if (is_array($scale)) { + if (array_key_exists('referenceActorId', $scale)) { + $references[] = ['path' => ['scale', 'referenceActorId'], 'reference' => $scale['referenceActorId'], 'kind' => 'value']; + } + foreach (array_keys((array) ($scale['actors'] ?? [])) as $id) { + $references[] = ['path' => ['scale', 'actors', $id], 'reference' => $id, 'kind' => 'key']; + } + } + // Battler bindings (battlers.php) key actors under `actors`, read + // above, and name the scale reference actor; enemies are enemy identities. + $reference = $payload['reference'] ?? null; + if (is_array($reference) && array_key_exists('actor', $reference)) { + $references[] = ['path' => ['reference', 'actor'], 'reference' => $reference['actor'], 'kind' => 'value']; + } + // Speaker labels bind to an actor or to a non-actor artwork resource. + $resources = (array) ($payload['resources'] ?? []); + foreach ((array) ($payload['speakers'] ?? []) as $label => $identity) { + if (! is_string($identity) || ! array_key_exists($identity, $resources)) { + $references[] = ['path' => ['speakers', $label], 'reference' => $identity, 'kind' => 'value']; + } + } + return $references; + } + if (str_starts_with($relativePath, 'assets/Data/Skits/')) { + foreach ((array) ($payload['beats'] ?? []) as $index => $beat) { + if (! is_array($beat)) { continue; } + $key = array_key_exists('actor', $beat) ? 'actor' : 'speaker'; + if (array_key_exists($key, $beat)) { + $references[] = ['path' => ['beats', $index, $key], 'reference' => $beat[$key], 'kind' => $key === 'actor' ? 'value' : 'speaker']; + } + } + } + if (str_starts_with($relativePath, 'assets/Data/battle-entry-rules.php')) { + $rules = isset($payload['rules']) ? $payload['rules'] : [$payload]; + foreach ((array) $rules as $index => $rule) { + if (! is_array($rule)) { continue; } + $prefix = isset($payload['rules']) ? ['rules', $index] : []; + foreach (['actors', 'effects'] as $field) { + foreach ((array) ($rule[$field] ?? []) as $entryIndex => $entry) { + if (is_array($entry) && array_key_exists('actor', $entry)) { + $references[] = ['path' => [...$prefix, $field, $entryIndex, 'actor'], 'reference' => $entry['actor'], 'kind' => 'value', 'caseInsensitive' => true]; + } + } + } + } + } + // A summon's character wielders, which the Engine matches by stable actor id. + if (str_starts_with($relativePath, 'assets/Cutscenes/Summons/') && str_ends_with($relativePath, '.data.php')) { + $wielders = $payload['wielders'] ?? null; + if (is_array($wielders) && is_array($wielders['characters'] ?? null)) { + foreach ($wielders['characters'] as $index => $id) { + $references[] = ['path' => ['wielders', 'characters', $index], 'reference' => $id, 'kind' => 'value', 'caseInsensitive' => true]; + } + } + } + // Troops use enemy IDs; event actorId/actor target staged subjects. + // Display names are not this identity contract. + return $references; + } + + /** Strip object identity, not authored values, for isolated readback comparisons. */ + public static function getComparableValue(mixed $value): mixed + { + return PhpDataFile::getComparableValue($value); + } + + /** Only runtime content locations, never saves, unrelated PHP or map glyph layers. */ + public static function getSourcePaths(string $root): array + { + $paths = []; + foreach (['assets/Data/system.php', 'assets/Data/battle-entry-rules.php', 'assets/Data/troops.php', + 'assets/Data/Presentation/battle.php', 'assets/Data/Presentation/battlers.php', 'assets/Data/Presentation/dialogue.php', + 'assets/Data/Presentation/menus.php'] as $relative) { + if (is_file($root . '/' . $relative)) { $paths[] = $root . '/' . $relative; } + } + foreach (['assets/Data/Skits', 'assets/Events', 'assets/Maps', 'assets/Cutscenes/Summons'] as $directory) { + if (! is_dir($root . '/' . $directory)) { continue; } + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root . '/' . $directory, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), in_array($directory, ['assets/Maps', 'assets/Cutscenes/Summons'], true) ? '.data.php' : '.php')) { + $paths[] = $file->getPathname(); + } + } + } + sort($paths, SORT_STRING); + return $paths; + } +} diff --git a/src/Actors/ActorReferenceSource.php b/src/Actors/ActorReferenceSource.php new file mode 100644 index 00000000..ae9fb70f --- /dev/null +++ b/src/Actors/ActorReferenceSource.php @@ -0,0 +1,167 @@ +entryAt($path); + if ($entry === null || $entry->keyIsOpaque) { + throw new SourcePreservationRefusal('Actor reference at ' . implode('.', $path) . ' is not an editable literal.'); + } + if ($change['kind'] === 'key' || $change['kind'] === 'speaker') { + $tokens = \PhpToken::tokenize('start, $entry->value->start - $entry->start)); + $keyToken = null; + foreach ($tokens as $token) { + if ($token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { continue; } + $keyToken = $token; + break; + } + if ($keyToken === null || ! $keyToken->is(T_CONSTANT_ENCAPSED_STRING)) { + throw new SourcePreservationRefusal('Actor reference key is not a string literal.'); + } + $start = $entry->start + $keyToken->pos - strlen('text), var_export($change['kind'] === 'speaker' ? 'actor' : $change['target'], true)]; + } + if ($change['kind'] !== 'key') { + if ($entry->value->kind !== SourceNode::SCALAR) { + throw new SourcePreservationRefusal('Actor reference at ' . implode('.', $path) . ' is an expression or variable; refusing to flatten it.'); + } + $edits[] = [$entry->value->start, $entry->value->end, var_export($change['target'], true)]; + } + } + return $document->withEdits($edits)->source; + } + + /** Presentation constructors retain imports, comments, argument order and unrelated expressions. */ + private static function rewriteReturnedConstructor(string $source, array $changes, mixed $value): string + { + $depth = 0; + $start = $end = null; + foreach (\PhpToken::tokenize($source) as $token) { + if ($token->is(T_RETURN) && $depth === 0) { + $start = $token->pos + strlen($token->text); + continue; + } + if ($token->text === ';' && $depth === 0 && $start !== null) { $end = $token->pos; break; } + if (in_array($token->text, ['(', '[', '{'], true)) { $depth++; } + if (in_array($token->text, [')', ']', '}'], true)) { $depth--; } + } + if ($start === null || $end === null) { throw new SourceUnreadable('Cannot locate the returned presentation constructor.'); } + $expression = substr($source, $start, $end - $start); + $rewritten = self::rewriteConstructor($expression, $changes, $value); + // Preserve whitespace/comments surrounding the constructor itself. + $original = PhpSourceDocument::parse('entrySource(0); + if ($original === null) { throw new SourceUnreadable('Presentation must return a supported constructor literal.'); } + $offset = strpos($expression, $original); + return substr($source, 0, $start) . substr_replace($expression, $rewritten, $offset, strlen($original)) . substr($source, $end); + } + + /** Arguments are matched against the Engine constructor, so new catalog fields need no copied signature here. */ + private static function rewriteConstructor(string $expression, array $changes, mixed $value): string + { + $document = PhpSourceDocument::parse('entryClasses()[0] ?? ''; + if (! is_string($class) || ! class_exists($class) + || strcasecmp(self::getShortName($written), self::getShortName($class)) !== 0) { + throw new SourcePreservationRefusal('Unsupported presentation constructor; refusing to guess its arguments.'); + } + $parameters = array_map(static fn(ReflectionParameter $parameter): string => $parameter->getName(), + new ReflectionClass($class)->getConstructor()?->getParameters() ?? []); + // The menu theme derives every property from its single data argument. + $isMenu = $class === MenuPresentationCatalog::class; + $groups = []; + foreach ($changes as $change) { + if ($isMenu) { array_unshift($change['path'], 'data'); } + $argument = array_shift($change['path']); + $groups[$argument][] = $change; + } + foreach ($groups as $argument => $edits) { + if (! in_array($argument, $parameters, true)) { + throw new SourcePreservationRefusal("{$class} has no constructor argument {$argument} holding actor references."); + } + $literal = $document->getConstructorArgumentSource(0, $argument, $parameters); + if ($literal === null) { throw new SourcePreservationRefusal('Missing actor reference argument ' . $argument . '.'); } + $updated = self::rewriteArgument($literal, $edits, $isMenu ? null : ($value[$argument] ?? null), $argument); + $document = $document->getWithConstructorArgument(0, $argument, $updated, $parameters); + } + return $document->entrySource(0) ?? throw new SourcePreservationRefusal('Cannot preserve the presentation constructor.'); + } + + /** A nested constructor, a single string literal or an array literal; never a variable or computed value. */ + private static function rewriteArgument(string $literal, array $edits, mixed $value, string $argument): string + { + $tokens = array_values(array_filter(\PhpToken::tokenize(' ! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + if (($tokens[0] ?? null)?->is(T_NEW)) { return self::rewriteConstructor($literal, $edits, $value); } + $refusal = "Actor references in {$argument} are an expression or variable; refusing to flatten them."; + if (count($edits) === 1 && $edits[0]['path'] === []) { + if (count($tokens) !== 1 || ! $tokens[0]->is(T_CONSTANT_ENCAPSED_STRING)) { + throw new SourcePreservationRefusal($refusal); + } + $start = $tokens[0]->pos - strlen('text)); + } + $wrapper = ' $oldValue) { + $copy[$oldKey === $key ? $newKey : $oldKey] = $oldKey === $key && $change['kind'] === 'speaker' ? $change['target'] : $oldValue; + } + $parent = $copy; + } else { $parent[$key] = $change['target']; } + unset($parent); + } + return $value; + } +} diff --git a/src/AnimationPreviewRenderer.php b/src/AnimationPreviewRenderer.php deleted file mode 100644 index f53bd40f..00000000 --- a/src/AnimationPreviewRenderer.php +++ /dev/null @@ -1,100 +0,0 @@ -, cue: AnimationCue|null} - */ - public static function build(Animation $animation, int $frameIndex, int $width, int $height): array - { - $origin = self::resolveOrigin($animation->position, $width, $height); - $cells = []; - - foreach (self::buildTargetCells($origin['x'], $origin['y'], $animation->position) as $cell) { - $cells[] = $cell; - } - - foreach ($animation->getFrame($frameIndex)->getCells() as $cell) { - $cells[] = [ - 'x' => $origin['x'] + $cell->x, - 'y' => $origin['y'] + $cell->y, - 'symbol' => $cell->symbol, - 'color' => $cell->color, - ]; - } - - return [ - 'cells' => array_values(array_filter( - $cells, - static fn(array $cell): bool => $cell['x'] >= 0 - && $cell['x'] < $width - && $cell['y'] >= 0 - && $cell['y'] < $height - )), - 'cue' => $animation->getCue($frameIndex), - ]; - } - - /** - * Returns the anchor point for the preview target. - * - * @param AnimationTargetPosition $position The animation position. - * @param int $width The preview width. - * @param int $height The preview height. - * @return array{x: int, y: int} - */ - public static function resolveOrigin(AnimationTargetPosition $position, int $width, int $height): array - { - $x = intdiv(max(1, $width), 2); - $y = intdiv(max(1, $height), 2); - - return match ($position) { - AnimationTargetPosition::HEAD => ['x' => $x, 'y' => max(1, $y - 2)], - AnimationTargetPosition::FEET => ['x' => $x, 'y' => min(max(1, $height - 2), $y + 2)], - AnimationTargetPosition::SCREEN, - AnimationTargetPosition::CENTER => ['x' => $x, 'y' => $y], - }; - } - - /** - * Returns the preview target silhouette. - * - * @param int $x The origin x coordinate. - * @param int $y The origin y coordinate. - * @param AnimationTargetPosition $position The target anchor. - * @return array - */ - private static function buildTargetCells(int $x, int $y, AnimationTargetPosition $position): array - { - if ($position === AnimationTargetPosition::SCREEN) { - return []; - } - - return [ - ['x' => $x, 'y' => $y - 1, 'symbol' => 'o', 'color' => null], - ['x' => $x - 1, 'y' => $y, 'symbol' => '/', 'color' => null], - ['x' => $x, 'y' => $y, 'symbol' => '|', 'color' => null], - ['x' => $x + 1, 'y' => $y, 'symbol' => '\\', 'color' => null], - ['x' => $x - 1, 'y' => $y + 1, 'symbol' => '/', 'color' => null], - ['x' => $x + 1, 'y' => $y + 1, 'symbol' => '\\', 'color' => null], - ]; - } -} diff --git a/src/Animations/AnimationConversionEditor.php b/src/Animations/AnimationConversionEditor.php new file mode 100644 index 00000000..10043b55 --- /dev/null +++ b/src/Animations/AnimationConversionEditor.php @@ -0,0 +1,413 @@ + 'Battle, paced by its phases', + 'battle_fixed' => 'Battle, at a fixed rate', + 'field' => 'Field script, at a fixed rate', + ]; + + /** The record keys a battle plays a timeline through. */ + public const array BINDINGS = ['targetEffect' => 'Target effect', 'sourceEffect' => 'Caster effect']; + + /** Choosing the conversion's settings. */ + public const string MODE_CHOICES = 'choices'; + + /** Reading the previewed files before writing them. */ + public const string MODE_REVIEW = 'review'; + + /** Rows typed into, by name. */ + private const array TYPED = ['timeline', 'fps', 'ticks', 'rest']; + + private bool $isOpen = false; + private string $mode = self::MODE_CHOICES; + private int $recordIndex = 0; + private int $animationId = 0; + private string $name = ''; + /** @var array What converting touches, as the service describes it. */ + private array $facts = []; + private ?string $consumer = null; + private string $binding = 'targetEffect'; + /** @var array The typed rows' text. */ + private array $typed = []; + private bool $includeFlash = true; + /** The cursor's line in the mode's lines. */ + private int $cursor = 0; + /** @var list The control each line of the choices belongs to, as last laid out; null for facts. */ + private array $lineControls = []; + /** A control the cursor goes to once the choices are next laid out. */ + private ?string $cursorControl = null; + private ?SourceSetPlan $plan = null; + private ?string $error = null; + + /** + * Opens the form on a legacy animation record. + * + * @param array $facts What converting touches ({@see LegacyAnimationConversion::describe()}). + */ + public function open(int $recordIndex, array $facts): void + { + $this->isOpen = true; + $this->mode = self::MODE_CHOICES; + $this->recordIndex = $recordIndex; + $this->animationId = intval($facts['id'] ?? 0); + $this->name = strval($facts['name'] ?? ''); + $this->facts = $facts; + $this->consumer = null; + $this->binding = 'targetEffect'; + // The name is the record's, in lowercase words; the timing is the author's. + $this->typed = ['timeline' => trim(strtolower(preg_replace('/[^A-Za-z0-9]+/', '-', $this->name) ?? ''), '-'), 'fps' => '', 'ticks' => '', 'rest' => '']; + $this->includeFlash = true; + $this->cursor = 0; + $this->plan = null; + $this->error = null; + } + + public function close(): void + { + $this->isOpen = false; + $this->plan = null; + $this->facts = []; + } + + public function isOpen(): bool + { + return $this->isOpen; + } + + public function getMode(): string + { + return $this->mode; + } + + public function getRecordIndex(): int + { + return $this->recordIndex; + } + + public function getName(): string + { + return $this->name; + } + + /** @return list The controls shown now, in order; the binding and rate only where the consumer has them. */ + public function getControls(): array + { + $battle = $this->consumer !== null && $this->consumer !== 'field'; + + return array_values(array_filter([ + 'consumer', + $battle ? 'binding' : null, + 'timeline', + $this->consumer !== null && $this->consumer !== 'battle_phase' ? 'fps' : null, + 'ticks', + 'rest', + 'flash', + 'preview', + $this->plan !== null ? 'review' : null, + ])); + } + + /** + * The control under the cursor, on any of its wrapped lines, or null on + * a line of facts, a message or a reviewed file. Before the choices are + * first laid out, each control is one line. + */ + public function getSelectedControl(): ?string + { + if ($this->mode !== self::MODE_CHOICES) { + return null; + } + + return $this->lineControls === [] ? ($this->getControls()[$this->cursor] ?? null) : ($this->lineControls[$this->cursor] ?? null); + } + + public function getCursor(): int + { + return $this->cursor; + } + + /** + * The last line a scrolling pane must show so the whole selected + * control is on screen: the end of its wrapped lines, or the cursor's + * own line elsewhere. A control taller than the pane shows from its start. + */ + public function getCursorEnd(int $visibleRows): int + { + $control = $this->getSelectedControl(); + $end = $this->cursor; + while ($control !== null && ($this->lineControls[$end + 1] ?? null) === $control) { + $end++; + } + + return min($end, $this->cursor + max(1, $visibleRows) - 1); + } + + /** + * The mode's lines at a width, with the cursor's line marked: the + * choices with the facts after them, or every reviewed file. Each line is + * one the cursor can rest on, so following the cursor shows them all. + * + * @return list + */ + public function getLines(int $width, string $projectRoot): array + { + $lines = $this->mode === self::MODE_REVIEW ? $this->getReviewLines($width, $projectRoot) : $this->getChoiceLines($width); + $this->cursor = max(0, min($this->cursor, count($lines) - 1)); + foreach ($lines as $index => $line) { + $lines[$index] = ($index === $this->cursor ? '> ' : ' ') . $line; + } + + return $lines; + } + + /** + * Moves the cursor a step, within as many lines as the mode has at that + * width. Among the choices a step is a whole control, however many + * lines it wraps to, landing on its first line; through facts and a + * review it is a line. + */ + public function move(int $delta, int $lineCount): void + { + $last = max(0, $lineCount - 1); + $step = $delta <=> 0; + foreach (range(1, abs($delta)) as $ignored) { + $from = $this->cursor; + $control = $this->mode === self::MODE_CHOICES ? ($this->lineControls[$from] ?? null) : null; + $at = max(0, min($last, $from + $step)); + // Off the rest of this control's lines when going down; onto the first line of the one above when going up. + while ($step > 0 && $control !== null && $at < $last && ($this->lineControls[$at] ?? null) === $control) { + $at++; + } + if ($step < 0 && $this->mode === self::MODE_CHOICES && ($this->lineControls[$at] ?? null) !== null) { + while ($at > 0 && ($this->lineControls[$at - 1] ?? null) === $this->lineControls[$at]) { + $at--; + } + } + // A last control taller than the rest stays where it is rather than landing mid-way. + $this->cursor = $step > 0 && $control !== null && ($this->lineControls[$at] ?? null) === $control ? $from : $at; + } + } + + /** Steps the selected choice: the consumer, the binding or the flash policy. */ + public function cycle(int $step): void + { + switch ($this->getSelectedControl()) { + case 'consumer': + $keys = array_keys(self::CONSUMERS); + $at = $this->consumer === null ? ($step > 0 ? -1 : 0) : (int) array_search($this->consumer, $keys, true); + $this->consumer = $keys[(($at + $step) % count($keys) + count($keys)) % count($keys)]; + // Battles have always played flash cues; field animations never did. + $this->includeFlash = $this->consumer !== 'field'; + break; + case 'binding': + $this->binding = $this->binding === 'targetEffect' ? 'sourceEffect' : 'targetEffect'; + break; + case 'flash': + $this->includeFlash = ! $this->includeFlash; + break; + default: + return; + } + $this->forgetPreview(); + } + + /** Types into the selected text control; any other line takes no typing. */ + public function type(string $text): void + { + $control = $this->getSelectedControl(); + if (! in_array($control, self::TYPED, true)) { + return; + } + $this->typed[$control] .= $text; + $this->forgetPreview(); + } + + public function backspace(): void + { + $control = $this->getSelectedControl(); + if (in_array($control, self::TYPED, true) && $this->typed[$control] !== '') { + $this->typed[$control] = mb_substr($this->typed[$control], 0, -1); + $this->forgetPreview(); + } + } + + /** + * Plans the conversion for the choices shown, holds that plan, and opens + * it for review from its first line. + * + * @return bool Whether a plan is held; otherwise the error says why. + */ + public function preview(ProjectWorkspace $workspace): bool + { + $this->plan = null; + try { + if ($this->consumer === null) { + throw new InvalidArgumentException('Choose who plays the timeline: a battle or a field script.'); + } + $whole = static fn(string $text, string $what): int => preg_match('/\A\d+\z/', trim($text)) === 1 + ? intval(trim($text)) : throw new InvalidArgumentException(sprintf('%s needs a whole number.', $what)); + $this->plan = LegacyAnimationConversion::plan( + $workspace, + $this->animationId, + trim($this->typed['timeline']), + $this->consumer === 'battle_phase' ? EffectCadence::BATTLE_PHASE : EffectCadence::FIXED, + $this->consumer === 'battle_phase' ? null : $whole($this->typed['fps'], 'Frames per second'), + $whole($this->typed['ticks'], 'Ticks per original frame'), + $whole($this->typed['rest'], 'Rest frame'), + $this->includeFlash, + $this->consumer === 'field' ? null : $this->binding, + ); + $this->error = null; + } catch (InvalidArgumentException $refusal) { + $this->error = $refusal->getMessage(); + } + if ($this->plan !== null) { + $this->review(); + } + + return $this->plan !== null; + } + + /** Opens the held plan for review from its first line. */ + public function review(): void + { + if ($this->plan !== null) { + $this->mode = self::MODE_REVIEW; + $this->cursor = 0; + } + } + + /** Back from review to the choices, the cursor on the review control's first line once laid out. */ + public function returnToChoices(): void + { + $this->mode = self::MODE_CHOICES; + $this->cursorControl = 'review'; + } + + /** The plan previewed for the choices shown, which writing applies exactly. */ + public function getPlan(): ?SourceSetPlan + { + return $this->plan; + } + + public function setError(?string $error): void + { + $this->error = $error; + } + + /** Why the last preview or write could not be made, if it could not. */ + public function getError(): ?string + { + return $this->error; + } + + /** + * The controls, a message if any, then the record's facts, every one + * wrapped to the pane so none is cut short; each line remembers the + * control it belongs to. + * + * @return list + */ + private function getChoiceLines(int $width): array + { + $width = max(8, $width - 2); + $lines = []; + $this->lineControls = []; + foreach ($this->getControls() as $control) { + $label = match ($control) { + 'consumer' => sprintf('Played by: %s', $this->consumer === null ? '(choose with Left/Right)' : self::CONSUMERS[$this->consumer]), + 'binding' => sprintf('Binds as: %s', self::BINDINGS[$this->binding]), + 'timeline' => sprintf('Timeline name: %s', $this->typed['timeline']), + 'fps' => sprintf('Frames per second: %s', $this->typed['fps']), + 'ticks' => sprintf('Ticks per original frame: %s', $this->typed['ticks']), + 'rest' => sprintf('Rest frame (from 0): %s', $this->typed['rest']), + 'flash' => sprintf('Flash cues: %s', $this->includeFlash ? 'become flash tracks' : 'left out'), + 'preview' => '[ Preview the files ]', + 'review' => sprintf('[ Review and write %d files ]', count($this->plan?->getChangedPaths() ?? [])), + }; + foreach (SettingsPaneLayout::wrapProse($label, $width) as $line) { + $lines[] = $line; + $this->lineControls[] = $control; + } + } + $facts = $this->facts; + $plays = static fn(mixed $list): string => is_array($list) && $list !== [] ? implode(', ', array_map(strval(...), $list)) : 'nothing'; + $prose = [ + ...($this->error === null ? [] : ['', $this->error]), + '', + sprintf('%d authored frames over %d (%s anchored), %d cues, %d with a flash.', + intval($facts['frames'] ?? 0), intval($facts['maxFrames'] ?? 0), strval($facts['position'] ?? ''), + intval($facts['cues'] ?? 0), intval($facts['flashes'] ?? 0)), + sprintf('Battle plays it through: %s.', $plays($facts['battle'] ?? [])), + sprintf('Field scripts that play it: %s. They keep playing the record until their command names the timeline.', $plays($facts['field'] ?? [])), + ]; + foreach ($prose as $paragraph) { + $lines = [...$lines, ...($paragraph === '' ? [''] : SettingsPaneLayout::wrapProse($paragraph, $width))]; + } + $this->lineControls = array_pad($this->lineControls, count($lines), null); + if ($this->cursorControl !== null) { + $at = array_search($this->cursorControl, $this->lineControls, true); + $this->cursor = is_int($at) ? $at : 0; + $this->cursorControl = null; + } + + return $lines; + } + + /** @return list Every file the held plan would write, path then source, wrapped. */ + private function getReviewLines(int $width, string $projectRoot): array + { + $width = max(8, $width - 2); + $root = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $sources = $this->plan?->getProposedSources() ?? []; + $lines = SettingsPaneLayout::wrapProse(sprintf('Review the %d files Enter writes; Esc returns to the choices.', count($sources)), $width); + foreach ($sources as $path => $source) { + $lines[] = ''; + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(str_starts_with($path, $root) ? substr($path, strlen($root)) : $path, $width)]; + foreach (explode("\n", rtrim($source, "\n")) as $line) { + // Code keeps its indentation; a line wider than the pane continues below it. + $lines = [...$lines, ...($line === '' ? [''] : SettingsPaneLayout::wrapProse(' ' . $line, $width))]; + } + } + + return $lines; + } + + private function forgetPreview(): void + { + $this->plan = null; + $this->error = null; + $this->mode = self::MODE_CHOICES; + } +} diff --git a/src/Animations/LegacyAnimationConversion.php b/src/Animations/LegacyAnimationConversion.php new file mode 100644 index 00000000..03245aa5 --- /dev/null +++ b/src/Animations/LegacyAnimationConversion.php @@ -0,0 +1,240 @@ +/.timeline.php`, + * made by the Engine's {@see LegacyAnimationMigration}, and, when a battle + * should play it, the record's caster or target effect naming it. + * + * Timing belongs to the consumer, so nothing here supplies a rate: the + * author chooses battle-phase cadence (a battle paces it) or a fixed rate, + * the ticks each original frame lasts, and the rest frame shown with + * reduced motion. The record's legacy frames and cues stay as written; the + * compatibility reader still plays them wherever nothing names the + * timeline. Field scripts that play the record keep doing so until their + * own command is pointed at the timeline; a field consumer's timing may + * differ from a battle's, and one record may need several timelines. + * + * Planning writes nothing. It refuses, before any write, a record with + * nothing to convert, a timeline name already taken, a binding the record + * already has, a timing the Engine cannot compile for its consumer, and an + * animations file whose source the editor cannot edit in place. + */ +final class LegacyAnimationConversion +{ + /** Where the record says a battle plays a timeline. */ + public const array BINDINGS = ['sourceEffect', 'targetEffect']; + + private const string ANIMATIONS = 'assets/Data/animations.php'; + + /** + * What converting the record would touch: its legacy content, its + * current bindings, and every consumer that plays it now. + * + * @return array{id: int, name: string, frames: int, maxFrames: int, position: string, cues: int, flashes: int, + * sourceEffect: ?string, targetEffect: ?string, battle: list, field: list} + * @throws InvalidArgumentException When there is no such record, or it has nothing to convert. + */ + public static function describe(ProjectWorkspace $workspace, int $id): array + { + $entry = self::requireEntry(self::readEntries($workspace->projectRoot), $id)[1]; + $animation = Animation::fromArray($entry); + $cues = array_filter(array_map(static fn(int $frame): mixed => $animation->getCue($frame), range(1, $animation->maxFrames))); + + return [ + 'id' => $id, + 'name' => $animation->name, + // The frames authored; the rest of the frame count are blank holds. + 'frames' => count(array_filter((array) ($entry['frames'] ?? []), is_array(...))), + 'maxFrames' => $animation->maxFrames, + 'position' => $animation->position->value, + 'cues' => count($cues), + 'flashes' => count(array_filter($cues, static fn($cue): bool => $cue->flashColor !== null && $cue->flashDurationFrames > 0)), + 'sourceEffect' => is_string($entry['sourceEffect'] ?? null) ? $entry['sourceEffect'] : null, + 'targetEffect' => is_string($entry['targetEffect'] ?? null) ? $entry['targetEffect'] : null, + 'battle' => self::findBattleConsumers($workspace, $id, $animation), + 'field' => self::findFieldConsumers($workspace, $id, $animation->name), + ]; + } + + /** + * Plans the conversion without writing. + * + * @param EffectCadence $cadence Battle-phase cadence (paced by a battle) or a fixed rate. + * @param int|null $fps The fixed rate; required for fixed cadence, refused for battle-phase. + * @param int $ticksPerFrame How many timeline frames each original frame lasts. + * @param int $restFrame The original frame (zero-based, flash tails included) shown with reduced motion. + * @param bool $includeFlash Whether the record's flash cues become flash tracks. + * @param string|null $binding `sourceEffect` or `targetEffect` when a battle should play it, or null. + * @throws InvalidArgumentException With the reason, when the conversion cannot be made as asked. + */ + public static function plan(ProjectWorkspace $workspace, int $id, string $timelineId, EffectCadence $cadence, ?int $fps, + int $ticksPerFrame, int $restFrame, bool $includeFlash, ?string $binding): SourceSetPlan + { + $root = $workspace->projectRoot; + $entries = self::readEntries($root); + [$index, $entry] = self::requireEntry($entries, $id); + if ($binding !== null && ! in_array($binding, self::BINDINGS, true)) { + throw new InvalidArgumentException(sprintf('A battle plays a timeline as the record\'s %s, not %s.', implode(' or ', self::BINDINGS), $binding)); + } + if ($binding !== null && is_string($entry[$binding] ?? null) && $entry[$binding] !== '') { + throw new InvalidArgumentException(sprintf('%s already plays %s as its %s; convert it for another consumer or clear that first.', + strval($entry['name'] ?? $id), $entry[$binding], $binding)); + } + if ($binding === null && $cadence === EffectCadence::BATTLE_PHASE) { + throw new InvalidArgumentException('Only a battle paces a timeline by its phases; bind it to the record for battle, or give it a fixed rate.'); + } + EffectTimelineLibrary::assertId($timelineId); + $folder = $root . '/assets/' . EffectTimelineLibrary::DIRECTORY . '/' . $timelineId; + $timelinePath = $folder . '/' . $timelineId . '.timeline.php'; + if (file_exists($folder)) { + throw new InvalidArgumentException(sprintf('A timeline named %s already exists; choose a name of its own.', $timelineId)); + } + + $data = LegacyAnimationMigration::getTimelineData($timelineId, Animation::fromArray($entry), $cadence, $ticksPerFrame, $restFrame, $fps, $includeFlash); + if ($binding === null) { + // A field consumer compiles it outside battle, where battle-only keys are refused. + new EffectTimelineLibrary('')->compile($timelineId, $data); + } + $timelineSource = " PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)); + + // The record changes only when a battle is to play the timeline. + $record = $binding === null ? [] : [$path => $source]; + + return new SourceSetPlan( + $root, + [$path => $source], + [...$record, $timelinePath => null], + [...($binding === null ? [] : [$path => $proposed]), $timelinePath => $timelineSource], + [...($binding === null ? [] : [$path => PhpDataFile::getComparableValue($entries)]), $timelinePath => null], + [...($binding === null ? [] : [$path => PhpDataFile::getComparableValue($after)]), $timelinePath => PhpDataFile::getComparableValue($data)], + static fn(): array => is_file($path) ? [$path] : [], + $readBack, + 'animation conversion', + 'The animations file', + ); + } + + /** @return list The animations file's records, as the runtime reads them. */ + private static function readEntries(string $root): array + { + $file = PhpDataFile::load($root . '/' . self::ANIMATIONS, $root); + if (! is_array($file->payload) || ! array_is_list($file->payload)) { + throw new InvalidArgumentException(sprintf('%s is not a list of animation records the editor can read.', self::ANIMATIONS)); + } + + return $file->payload; + } + + /** + * @param list $entries + * @return array{0: int, 1: array} The record's position and data. + */ + private static function requireEntry(array $entries, int $id): array + { + foreach ($entries as $index => $entry) { + if (is_array($entry) && ($entry['id'] ?? null) === $id) { + if (array_filter((array) ($entry['frames'] ?? [])) === [] && array_filter((array) ($entry['cues'] ?? [])) === []) { + throw new InvalidArgumentException(sprintf('%s has no legacy frames or cues to convert.', strval($entry['name'] ?? $id))); + } + + return [$index, $entry]; + } + } + + throw new InvalidArgumentException(sprintf('There is no animation %d.', $id)); + } + + /** The animations file with the record naming the timeline, edited in place. */ + private static function bindInSource(string $source, int $index, string $binding, string $timelineId): string + { + try { + $document = PhpArraySourceDocument::parse($source); + } catch (SourceUnreadable $unreadable) { + throw new InvalidArgumentException(sprintf('%s cannot be edited in place: %s', self::ANIMATIONS, $unreadable->getMessage()), previous: $unreadable); + } + $record = $document->nodeAt([$index]); + if ($record === null || $record->kind !== SourceNode::ARRAY || $record->hasOpaqueKey) { + throw new InvalidArgumentException(sprintf('Animation record %d is not a literal array the editor can edit in place.', $index + 1)); + } + + try { + return $document->withEdits([$document->insertEntryEdit([$index], count($record->entries), $binding, var_export($timelineId, true))])->source; + } catch (RuntimeException $failure) { + throw new InvalidArgumentException(sprintf('%s cannot take the binding in place: %s', self::ANIMATIONS, $failure->getMessage()), previous: $failure); + } + } + + /** @return list The skills and items that play the record in battle, and the roles it holds. */ + private static function findBattleConsumers(ProjectWorkspace $workspace, int $id, Animation $animation): array + { + $consumers = []; + foreach ($workspace->getRecordDatabase('skills')?->getRecords() ?? [] as $record) { + try { + $skill = SkillRecord::readSkill((array) $record->toArray()); + } catch (InvalidArgumentException) { + continue; + } + if ($skill->animationId === $id) { + $consumers[] = sprintf('skill %s', $skill->name); + } + } + foreach ($workspace->getRecordDatabase('items')?->getRecords() ?? [] as $item) { + if ($item->get('animationId') === $id) { + $consumers[] = sprintf('item %s', strval($item->get('name'))); + } + } + foreach ($animation->roles as $role) { + $consumers[] = sprintf('every action with role %s', $role); + } + + return $consumers; + } + + /** @return list The scripts whose field animations play the record, by id or by name. */ + private static function findFieldConsumers(ProjectWorkspace $workspace, int $id, string $name): array + { + $uses = []; + EffectValidator::visitFieldAnimations($workspace, $workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [], + static function (array $command, string $where) use (&$uses, $id, $name): void { + // A legacy reference names the record by id or by name. + if (in_array($command['animation'] ?? $command['id'] ?? null, [$id, $name], true)) { + $uses[] = $where; + } + }); + + return array_values(array_unique($uses)); + } +} diff --git a/src/Assets/AssetImport.php b/src/Assets/AssetImport.php new file mode 100644 index 00000000..809a6536 --- /dev/null +++ b/src/Assets/AssetImport.php @@ -0,0 +1,141 @@ +width > PngAssetPreflight::MAX_DIMENSION || $pixels->height > PngAssetPreflight::MAX_DIMENSION) { + throw new InvalidArgumentException(sprintf('%s is %d x %d pixels; the game draws images of at most %d pixels each way.', + $label, $pixels->width, $pixels->height, PngAssetPreflight::MAX_DIMENSION)); + } + $assets = rtrim($projectRoot, '/') . '/assets'; + $path = self::resolveDestination($assets, $destination); + $target = $assets . '/' . $path; + $existing = is_file($target) ? file_get_contents($target) : null; + if ($existing === false || is_dir($target)) { + throw new InvalidArgumentException("assets/{$path} cannot be read, so it is not replaced."); + } + if ($existing === $bytes) { + return ['path' => $path, 'width' => $pixels->width, 'height' => $pixels->height, 'plan' => null]; + } + if ($existing !== null && ! $replace) { + throw new InvalidArgumentException("assets/{$path} already holds another image. Choose another name, or replace it."); + } + $readBack = static fn(string $path, string $staged): string => self::hashPixels((string) file_get_contents($staged), $path); + $before = $existing === null ? null : self::hashPixels($existing, $path); + $plan = new SourceSetPlan($projectRoot, $existing === null ? [] : [$target => $existing], [$target => $existing], [$target => $bytes], + [$target => $before], [$target => $pixels->getPixelHash()], + static fn(): array => $existing === null ? [] : [$target], $readBack, 'image import', 'Imported image'); + + return ['path' => $path, 'width' => $pixels->width, 'height' => $pixels->height, 'plan' => $plan]; + } + + /** + * An asset-relative image path for a destination given relative to the + * project's assets or as an absolute path inside them. + * + * @throws InvalidArgumentException When it leaves the assets or is not a PNG's name. + */ + public static function resolveDestination(string $assets, string $destination): string + { + $destination = trim(str_replace(DIRECTORY_SEPARATOR, '/', $destination)); + if (str_starts_with($destination, '/')) { + $root = realpath($assets); + $folder = self::findExistingAncestor(dirname($destination)); + $resolved = $folder === null ? false : realpath($folder); + if ($root === false || $resolved === false || ($resolved !== $root && ! str_starts_with($resolved, $root . '/'))) { + throw new InvalidArgumentException('Images are brought into the project\'s assets folder. Choose a place inside it.'); + } + $destination = ltrim(substr($resolved, strlen($root)) . substr(dirname($destination), strlen($folder)) . '/' . basename($destination), '/'); + } + try { + SpriteValidation::validateAssetPath($destination); + } catch (InvalidArgumentException) { + throw new InvalidArgumentException("{$destination} is not a place inside the assets folder."); + } + if (strtolower(pathinfo($destination, PATHINFO_EXTENSION)) !== 'png' || pathinfo($destination, PATHINFO_FILENAME) === '') { + throw new InvalidArgumentException("{$destination} is not a PNG's name. Name the image with .png at the end."); + } + + return $destination; + } + + private static function readSource(string $source): string + { + if (! is_file($source) || ! is_readable($source)) { + throw new InvalidArgumentException(sprintf('%s cannot be read.', $source)); + } + if ((int) filesize($source) > PngAssetPreflight::MAX_FILE_BYTES) { + throw new InvalidArgumentException(sprintf('%s is larger than the %d MiB the game reads.', basename($source), + intdiv(PngAssetPreflight::MAX_FILE_BYTES, 1024 * 1024))); + } + $bytes = file_get_contents($source); + if ($bytes === false) { + throw new InvalidArgumentException(sprintf('%s cannot be read.', $source)); + } + + return $bytes; + } + + private static function findExistingAncestor(string $folder): ?string + { + while (! is_dir($folder)) { + $parent = dirname($folder); + if ($parent === $folder) { + return null; + } + $folder = $parent; + } + + return $folder; + } + + /** The pixels of an image already there, or its bytes' hash when they cannot be read as one. */ + private static function hashPixels(string $bytes, string $path): string + { + try { + return PngPixels::decode($bytes, $path)->getPixelHash(); + } catch (InvalidArgumentException) { + return hash('sha256', $bytes); + } + } +} diff --git a/src/Assets/PngPixels.php b/src/Assets/PngPixels.php new file mode 100644 index 00000000..2537447a --- /dev/null +++ b/src/Assets/PngPixels.php @@ -0,0 +1,347 @@ + $kept Chunk types and data to write again, in file order. + */ + private function __construct( + public readonly int $width, + public readonly int $height, + private string $rgba, + private readonly array $kept = [], + ) { + } + + /** A blank image: every pixel transparent. */ + public static function createBlank(int $width, int $height): self + { + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException('An image is at least one pixel each way.'); + } + + return new self($width, $height, str_repeat("\0", $width * $height * 4)); + } + + /** + * Reads a PNG's bytes. + * + * @param string $label How the image is named in a refusal (its asset path). + * @throws InvalidArgumentException When the bytes are not a PNG this reads, saying why. + */ + public static function decode(string $bytes, string $label): self + { + if (! str_starts_with($bytes, self::SIGNATURE)) { + throw new InvalidArgumentException("{$label} is not a PNG image."); + } + $offset = strlen(self::SIGNATURE); + $header = null; + $palette = ''; + $transparency = null; + $data = ''; + $kept = []; + $ended = false; + while ($offset + 12 <= strlen($bytes)) { + $length = unpack('N', substr($bytes, $offset, 4))[1]; + $type = substr($bytes, $offset + 4, 4); + if ($offset + 12 + $length > strlen($bytes)) { + throw new InvalidArgumentException("{$label} is cut short: its {$type} chunk runs past the end of the file."); + } + $chunk = substr($bytes, $offset + 8, $length); + $crc = unpack('N', substr($bytes, $offset + 8 + $length, 4))[1]; + if ((crc32($type . $chunk) & 0xFFFFFFFF) !== $crc) { + throw new InvalidArgumentException("{$label} is damaged: its {$type} chunk does not match its checksum."); + } + $offset += 12 + $length; + match ($type) { + 'IHDR' => $header = self::readHeader($chunk, $label), + 'PLTE' => $palette = $chunk, + 'tRNS' => $transparency = $chunk, + 'IDAT' => $data .= $chunk, + default => null, + }; + if (in_array($type, self::KEPT_CHUNKS, true)) { + $kept[] = [$type, $chunk]; + } + if ($type === 'IEND') { + $ended = true; + break; + } + } + if (! $ended) { + throw new InvalidArgumentException("{$label} is cut short: the file ends before its image does."); + } + if ($header === null) { + throw new InvalidArgumentException("{$label} has no image header."); + } + $raw = @zlib_decode($data); + if ($raw === false) { + throw new InvalidArgumentException("{$label}'s pixels cannot be read: their compressed data is damaged."); + } + $rows = self::unfilter($raw, $header, $label); + + return new self($header['width'], $header['height'], self::expand($rows, $header, $palette, $transparency, $label), $kept); + } + + /** + * The image as PNG bytes: 8-bit RGBA, with the colour and density chunks + * of the image it was read from. + */ + public function encode(): string + { + $stride = $this->width * 4; + $scanlines = ''; + for ($y = 0; $y < $this->height; $y++) { + $scanlines .= "\0" . substr($this->rgba, $y * $stride, $stride); + } + $chunks = self::writeChunk('IHDR', pack('NNCCCCC', $this->width, $this->height, 8, 6, 0, 0, 0)); + foreach ($this->kept as [$type, $chunk]) { + $chunks .= self::writeChunk($type, $chunk); + } + + return self::SIGNATURE . $chunks . self::writeChunk('IDAT', (string) gzcompress($scanlines, 9)) . self::writeChunk('IEND', ''); + } + + /** One pixel as [red, green, blue, alpha]. */ + public function getPixel(int $x, int $y): array + { + $this->assertRectangle($x, $y, 1, 1); + + return array_values(unpack('C4', substr($this->rgba, ($y * $this->width + $x) * 4, 4))); + } + + /** Sets one pixel to [red, green, blue, alpha]. */ + public function setPixel(int $x, int $y, array $rgba): void + { + $this->assertRectangle($x, $y, 1, 1); + $this->rgba = substr_replace($this->rgba, pack('C4', ...$rgba), ($y * $this->width + $x) * 4, 4); + } + + /** Whether every pixel of a rectangle is wholly transparent, so nothing is drawn there. */ + public function isTransparent(int $x, int $y, int $width, int $height): bool + { + $this->assertRectangle($x, $y, $width, $height); + $mask = str_repeat("\0\0\0\xFF", $width); + $clear = str_repeat("\0", $width * 4); + for ($row = $y; $row < $y + $height; $row++) { + if ((substr($this->rgba, ($row * $this->width + $x) * 4, $width * 4) & $mask) !== $clear) { + return false; + } + } + + return true; + } + + /** + * A copy of this image with a rectangle of another image's pixels laid + * over it at a point, every pixel replaced as it is, transparency + * included; nothing else changes. + */ + public function withPixelsFrom(self $source, int $sourceX, int $sourceY, int $width, int $height, int $x, int $y): self + { + $source->assertRectangle($sourceX, $sourceY, $width, $height); + $this->assertRectangle($x, $y, $width, $height); + $rgba = $this->rgba; + for ($row = 0; $row < $height; $row++) { + $rgba = substr_replace($rgba, substr($source->rgba, (($sourceY + $row) * $source->width + $sourceX) * 4, $width * 4), + (($y + $row) * $this->width + $x) * 4, $width * 4); + } + + return new self($this->width, $this->height, $rgba, $this->kept); + } + + /** Whether two images hold the same pixels. */ + public function hasSamePixels(self $other): bool + { + return $this->width === $other->width && $this->height === $other->height && $this->rgba === $other->rgba; + } + + /** A fingerprint of the pixels alone, whatever the file's encoding. */ + public function getPixelHash(): string + { + return hash('sha256', $this->width . 'x' . $this->height . ':' . $this->rgba); + } + + private function assertRectangle(int $x, int $y, int $width, int $height): void + { + if ($x < 0 || $y < 0 || $width < 1 || $height < 1 || $x + $width > $this->width || $y + $height > $this->height) { + throw new InvalidArgumentException(sprintf('The rectangle %d,%d %d x %d is outside the %d x %d image.', + $x, $y, $width, $height, $this->width, $this->height)); + } + } + + /** @return array{width: int, height: int, depth: int, color: int, channels: int} */ + private static function readHeader(string $chunk, string $label): array + { + if (strlen($chunk) !== 13) { + throw new InvalidArgumentException("{$label} has a damaged header."); + } + ['width' => $width, 'height' => $height, 'depth' => $depth, 'color' => $color, 'compression' => $compression, + 'filter' => $filter, 'interlace' => $interlace] = unpack('Nwidth/Nheight/Cdepth/Ccolor/Ccompression/Cfilter/Cinterlace', $chunk); + $channels = [0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4][$color] ?? null; + $depths = [0 => [1, 2, 4, 8, 16], 2 => [8, 16], 3 => [1, 2, 4, 8], 4 => [8, 16], 6 => [8, 16]][$color] ?? []; + if ($width < 1 || $height < 1 || $channels === null || ! in_array($depth, $depths, true) || $compression !== 0 || $filter !== 0) { + throw new InvalidArgumentException("{$label} has a header no PNG reader accepts."); + } + if ($depth === 16) { + throw new InvalidArgumentException("{$label} has 16 bits per channel. Save it with 8 bits per channel, as tileset sheets are, and try again."); + } + if ($interlace !== 0) { + throw new InvalidArgumentException("{$label} is interlaced. Save it without interlacing and try again."); + } + + return ['width' => $width, 'height' => $height, 'depth' => $depth, 'color' => $color, 'channels' => $channels]; + } + + /** + * Each row's bytes with its filter undone. + * + * @param array{width: int, height: int, depth: int, color: int, channels: int} $header + * @return list> + */ + private static function unfilter(string $raw, array $header, string $label): array + { + $bits = $header['depth'] * $header['channels']; + $stride = intdiv($header['width'] * $bits + 7, 8); + $step = max(1, intdiv($bits, 8)); + if (strlen($raw) !== ($stride + 1) * $header['height']) { + throw new InvalidArgumentException("{$label}'s pixel data does not fit its size."); + } + $rows = []; + $previous = array_fill(0, $stride, 0); + for ($y = 0; $y < $header['height']; $y++) { + $start = $y * ($stride + 1); + $filter = ord($raw[$start]); + $row = array_values(unpack('C*', substr($raw, $start + 1, $stride)) ?: []); + switch ($filter) { + case 0: + break; + case 1: + for ($i = $step; $i < $stride; $i++) { + $row[$i] = ($row[$i] + $row[$i - $step]) & 0xFF; + } + break; + case 2: + for ($i = 0; $i < $stride; $i++) { + $row[$i] = ($row[$i] + $previous[$i]) & 0xFF; + } + break; + case 3: + for ($i = 0; $i < $stride; $i++) { + $left = $i >= $step ? $row[$i - $step] : 0; + $row[$i] = ($row[$i] + (($left + $previous[$i]) >> 1)) & 0xFF; + } + break; + case 4: + for ($i = 0; $i < $stride; $i++) { + $left = $i >= $step ? $row[$i - $step] : 0; + $up = $previous[$i]; + $corner = $i >= $step ? $previous[$i - $step] : 0; + $estimate = $left + $up - $corner; + [$toLeft, $toUp, $toCorner] = [abs($estimate - $left), abs($estimate - $up), abs($estimate - $corner)]; + $predictor = $toLeft <= $toUp && $toLeft <= $toCorner ? $left : ($toUp <= $toCorner ? $up : $corner); + $row[$i] = ($row[$i] + $predictor) & 0xFF; + } + break; + default: + throw new InvalidArgumentException("{$label} uses an unknown row filter."); + } + $rows[] = $row; + $previous = $row; + } + + return $rows; + } + + /** + * The rows' pixels as 8-bit RGBA. + * + * @param list> $rows + * @param array{width: int, height: int, depth: int, color: int, channels: int} $header + */ + private static function expand(array $rows, array $header, string $palette, ?string $transparency, string $label): string + { + ['width' => $width, 'depth' => $depth, 'color' => $color] = $header; + if ($color === 6) { + return implode('', array_map(static fn(array $row): string => pack('C*', ...$row), $rows)); + } + $paletteColors = []; + if ($color === 3) { + if ($palette === '' || strlen($palette) % 3 !== 0) { + throw new InvalidArgumentException("{$label} names palette colours but has no palette."); + } + $alphas = $transparency === null ? [] : array_values(unpack('C*', $transparency) ?: []); + foreach (str_split($palette, 3) as $index => $entry) { + $paletteColors[] = $entry . chr($alphas[$index] ?? 255); + } + } + $key = $transparency !== null && in_array($color, [0, 2], true) ? array_values(unpack('n*', $transparency) ?: []) : null; + $scale = $depth < 8 ? intdiv(255, (1 << $depth) - 1) : 1; + $pixels = ''; + foreach ($rows as $row) { + $line = ''; + for ($x = 0; $x < $width; $x++) { + $line .= match ($color) { + 2 => self::packKeyed([$row[$x * 3], $row[$x * 3 + 1], $row[$x * 3 + 2]], $key), + 4 => pack('C4', $row[$x * 2], $row[$x * 2], $row[$x * 2], $row[$x * 2 + 1]), + 3 => $paletteColors[self::readSample($row, $x, $depth)] + ?? throw new InvalidArgumentException("{$label} names a colour its palette does not have."), + default => self::packGrey(self::readSample($row, $x, $depth), $scale, $key), + }; + } + $pixels .= $line; + } + + return $pixels; + } + + /** The sample of pixel x in a row of one-channel samples of the given depth. */ + private static function readSample(array $row, int $x, int $depth): int + { + return $depth === 8 ? $row[$x] : ($row[intdiv($x * $depth, 8)] >> (8 - $depth - ($x * $depth) % 8)) & ((1 << $depth) - 1); + } + + /** @param list|null $key */ + private static function packGrey(int $value, int $scale, ?array $key): string + { + $grey = $value * $scale; + + return pack('C4', $grey, $grey, $grey, $key !== null && $key[0] === $value ? 0 : 255); + } + + /** @param list $rgb @param list|null $key */ + private static function packKeyed(array $rgb, ?array $key): string + { + return pack('C4', ...$rgb, ...[$key !== null && $key === $rgb ? 0 : 255]); + } + + private static function writeChunk(string $type, string $data): string + { + return pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data) & 0xFFFFFFFF); + } +} diff --git a/src/Assets/TilesetSheetCells.php b/src/Assets/TilesetSheetCells.php new file mode 100644 index 00000000..1ed5d47c --- /dev/null +++ b/src/Assets/TilesetSheetCells.php @@ -0,0 +1,196 @@ +>}> + */ + public static function describeSheets(Tileset $tileset): array + { + $sheets = []; + foreach (self::PLAIN_SHEETS as $sheet) { + $asset = $tileset->sheets[$sheet->value] ?? null; + if (! is_string($asset)) { + continue; + } + $ids = []; + for ($row = 0; $row < $sheet->getRows(); $row++) { + for ($column = 0; $column < $sheet->getColumns(); $column++) { + $ids[$row][$column] = TileId::getPlainTileAt($sheet, $column, $row); + } + } + $sheets[] = ['sheet' => $sheet->value, 'asset' => $asset, 'columns' => $sheet->getColumns(), 'rows' => $sheet->getRows(), 'ids' => $ids]; + } + + return $sheets; + } + + /** + * Which cells of a sheet are free, as its image is now: true where every + * pixel is transparent. Cell 0, 0 of B is never free, being the empty + * tile that erases. + * + * @return array{sheet: string, asset: string, tileSize: int, free: list>} + * @throws InvalidArgumentException When the sheet is not a plain sheet of the tileset, or its image cannot be read. + */ + public static function readFreeCells(Tileset $tileset, string $sheetName, string $assetRoot): array + { + [$sheet, $asset, $image, $size] = self::readSheet($tileset, $sheetName, $assetRoot); + $free = []; + for ($row = 0; $row < $sheet->getRows(); $row++) { + for ($column = 0; $column < $sheet->getColumns(); $column++) { + $free[$row][$column] = ! self::isEmptyTile($sheet, $column, $row) && $image->isTransparent($column * $size, $row * $size, $size, $size); + } + } + + return ['sheet' => $sheet->value, 'asset' => $asset, 'tileSize' => $size, 'free' => $free]; + } + + /** + * Plans copying a rectangle of whole cells from another image into free + * cells of a sheet, the other image cut into cells of the sheet's tile + * size from its top-left. + * + * @param array{0: int, 1: int} $from The source rectangle's top-left cell. + * @param array{0: int, 1: int} $size Its width and height in cells. + * @param array{0: int, 1: int} $to The sheet cell its top-left lands on. + * @return array{plan: SourceSetPlan, tiles: list>, sheet: string, asset: string} The write, and the tile + * identity of each copied cell, in the source rectangle's rows. + * @throws InvalidArgumentException When the cells cannot be copied there, saying why. + */ + public static function planCopy(string $projectRoot, Tileset $tileset, string $sheetName, string $source, array $from, array $size, + array $to): array + { + $assetRoot = rtrim($projectRoot, '/') . '/assets'; + [$sheet, $asset, $image, $tileSize] = self::readSheet($tileset, $sheetName, $assetRoot); + [$width, $height] = $size; + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException('Choose at least one cell to copy.'); + } + if ($to[0] < 0 || $to[1] < 0 || $to[0] + $width > $sheet->getColumns() || $to[1] + $height > $sheet->getRows()) { + throw new InvalidArgumentException(sprintf('%d x %d cells do not fit sheet %s at %d, %d: it is %d x %d cells. Nothing was copied.', + $width, $height, $sheet->value, $to[0], $to[1], $sheet->getColumns(), $sheet->getRows())); + } + try { + SpriteValidation::validateAssetPath($source); + } catch (InvalidArgumentException) { + throw new InvalidArgumentException("{$source} is not an image in the assets folder."); + } + $sourcePath = $assetRoot . '/' . $source; + $sourceBytes = is_file($sourcePath) ? file_get_contents($sourcePath) : false; + if ($sourceBytes === false) { + throw new InvalidArgumentException("{$source} cannot be read."); + } + $pixels = PngPixels::decode($sourceBytes, $source); + [$sourceColumns, $sourceRows] = [intdiv($pixels->width, $tileSize), intdiv($pixels->height, $tileSize)]; + if ($from[0] < 0 || $from[1] < 0 || $from[0] + $width > $sourceColumns || $from[1] + $height > $sourceRows) { + throw new InvalidArgumentException(sprintf('%s is %d x %d cells of %d pixels, so it has no %d x %d cells at %d, %d.', + $source, $sourceColumns, $sourceRows, $tileSize, $width, $height, $from[0], $from[1])); + } + if ($pixels->isTransparent($from[0] * $tileSize, $from[1] * $tileSize, $width * $tileSize, $height * $tileSize)) { + throw new InvalidArgumentException("Those cells of {$source} are empty, so there is nothing to copy."); + } + $taken = []; + $tiles = []; + for ($row = 0; $row < $height; $row++) { + for ($column = 0; $column < $width; $column++) { + [$x, $y] = [$to[0] + $column, $to[1] + $row]; + if (self::isEmptyTile($sheet, $x, $y)) { + throw new InvalidArgumentException('Cell 0, 0 of sheet B is the empty tile, which erases. Copy into other cells; nothing was copied.'); + } + if (! $image->isTransparent($x * $tileSize, $y * $tileSize, $tileSize, $tileSize)) { + $taken[] = "{$x}, {$y}"; + } + $tiles[$row][$column] = TileId::getPlainTileAt($sheet, $x, $y); + } + } + if ($taken !== []) { + throw new InvalidArgumentException(sprintf('Sheet %s already has art in %s %s. Copy into free cells only; nothing was copied.', + $sheet->value, count($taken) === 1 ? 'cell' : 'cells', self::listCells($taken))); + } + $copied = $image->withPixelsFrom($pixels, $from[0] * $tileSize, $from[1] * $tileSize, $width * $tileSize, $height * $tileSize, + $to[0] * $tileSize, $to[1] * $tileSize); + $target = $assetRoot . '/' . $asset; + $original = (string) file_get_contents($target); + $readBack = static fn(string $path, string $staged): string => PngPixels::decode((string) file_get_contents($staged), $path)->getPixelHash(); + $plan = new SourceSetPlan($projectRoot, [$target => $original], [$target => $original], + [$target => $copied->encode()], [$target => $image->getPixelHash()], [$target => $copied->getPixelHash()], + static fn(): array => [$target], $readBack, 'cell copy', "Sheet {$sheet->value}"); + + return ['plan' => $plan, 'tiles' => $tiles, 'sheet' => $sheet->value, 'asset' => $asset]; + } + + /** @return array{0: TilesetSheet, 1: string, 2: PngPixels, 3: int} */ + private static function readSheet(Tileset $tileset, string $sheetName, string $assetRoot): array + { + $sheet = TilesetSheet::tryFrom($sheetName); + if ($sheet === null || ! in_array($sheet, self::PLAIN_SHEETS, true)) { + throw new InvalidArgumentException(sprintf('Cells are copied into the sheets of whole tiles, A5 and B to E; %s is not one.', $sheetName)); + } + $asset = $tileset->sheets[$sheet->value] ?? throw new InvalidArgumentException(sprintf( + 'Tileset %s has no sheet %s yet. Choose its image in the inspector first.', $tileset->name, $sheet->value)); + $path = $assetRoot . '/' . $asset; + $bytes = is_file($path) ? file_get_contents($path) : false; + if ($bytes === false) { + throw new InvalidArgumentException("Sheet {$sheet->value} ({$asset}) cannot be read."); + } + $image = PngPixels::decode($bytes, $asset); + $tileSize = intdiv($image->width, $sheet->getColumns()); + if ($image->width % $sheet->getColumns() !== 0 || $image->height !== $tileSize * $sheet->getRows()) { + throw new InvalidArgumentException(sprintf('Sheet %s (%s) is %d x %d pixels, which is not %d x %d cells of one size.', + $sheet->value, $asset, $image->width, $image->height, $sheet->getColumns(), $sheet->getRows())); + } + + return [$sheet, $asset, $image, $tileSize]; + } + + private static function isEmptyTile(TilesetSheet $sheet, int $column, int $row): bool + { + return $sheet === TilesetSheet::B && $column === 0 && $row === 0; + } + + /** @param list $cells */ + private static function listCells(array $cells): string + { + $shown = array_slice($cells, 0, 4); + + return implode('; ', $shown) . (count($cells) > count($shown) ? sprintf(' and %d more', count($cells) - count($shown)) : ''); + } +} diff --git a/src/Audio/AudioAudition.php b/src/Audio/AudioAudition.php new file mode 100644 index 00000000..33b219d5 --- /dev/null +++ b/src/Audio/AudioAudition.php @@ -0,0 +1,104 @@ + 'BGM', 'sfx' => 'SFX']; + + private ?AudioPlayback $playback = null; + + /** What is playing, as kind and name, while it plays. */ + private ?array $playing = null; + + /** @var list|null The players to use instead of this computer's, as a test gives them. */ + private ?array $players; + + /** @param list|null $players The players to use instead of this computer's; tests give silent ones. */ + public function __construct(Game $game, private readonly string $projectRoot, ?array $players = null) + { + $this->players = $players; + parent::__construct($game); + } + + protected function createBackends(): array + { + return $this->players ?? parent::createBackends(); + } + + /** + * Plays a track or sound by the name a field stores, stopping any other. + * + * @param 'bgm'|'sfx' $kind + * @return string|null Why it cannot play, or null when it plays. + */ + public function play(string $kind, string $name): ?string + { + $this->stop(); + $folder = self::FOLDERS[$kind] ?? null; + if ($folder === null) { + return sprintf('%s is not music or a sound effect.', $kind); + } + if (trim($name) === '' || str_contains($name, '..')) { + return 'Choose a track first.'; + } + $file = $this->resolveAudioPath(rtrim($this->projectRoot, '/') . "/assets/Audio/{$folder}/{$name}", $folder); + if ($file === null) { + return sprintf('%s was not found in assets/Audio/%s.', $name, $folder); + } + $backend = $this->selectBackend($file); + if ($backend === null) { + return sprintf('No audio player on this computer can play %s.', basename($file)); + } + $this->playback = $this->spawn($backend->buildCommand($file, 1.0, false)); + if ($this->playback === null) { + return sprintf('%s could not be started.', $backend->getExecutableName()); + } + $this->playing = ['kind' => $kind, 'name' => $name]; + + return null; + } + + /** Stops the audition, if one is playing. */ + public function stop(): void + { + $this->playback?->stop(); + $this->playback = null; + $this->playing = null; + } + + /** + * What is playing, as kind and name; null once it has finished or been stopped. + * + * @return array{kind: string, name: string}|null + */ + public function describePlaying(): ?array + { + if ($this->playback !== null && ! $this->playback->isRunning) { + $this->stop(); + } + + return $this->playing; + } + + public function shutdown(): void + { + $this->stop(); + parent::shutdown(); + } +} diff --git a/src/Canvas/AreaCanvas.php b/src/Canvas/AreaCanvas.php new file mode 100644 index 00000000..659d005f --- /dev/null +++ b/src/Canvas/AreaCanvas.php @@ -0,0 +1,312 @@ + The cells selected for an area, by "x,y". */ + private array $areaSelection = []; + /** @var array{int, int}|null The first corner of a rectangle being selected. */ + private ?array $areaRectangleAnchor = null; + /** The area whose cells the selection replaces; null for a new area. */ + private ?string $editedAreaId = null; + /** The id typed for a new area; null when none is being named. */ + private ?string $areaIdBuffer = null; + + /** Leaves no selection, corner, area or prompt behind. */ + private function resetAreaSelection(): void + { + $this->areaSelection = []; + $this->areaRectangleAnchor = null; + $this->editedAreaId = null; + $this->areaIdBuffer = null; + } + + /** Handles a key in Area mode; false when the canvas's own keys take it. */ + private function handleAreaModeInput(string $input): bool + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap) { + return false; + } + if ($this->areaIdBuffer !== null) { + $this->handleAreaIdInput($map, $input); + + return true; + } + + return match (true) { + $input === ' ' => $this->toggleAreaCell(), + $input === 'r' || $input === 'R' => $this->markAreaRectangleCorner(), + $input === "\n" || $input === "\r" => $this->applyAreaSelection($map), + $input === 'n' || $input === 'N' => $this->pickUpAreaAtCursor($map, step: true), + $input === 'c' || $input === 'C' => $this->clearAreaSelection('Selection cleared.'), + str_contains($input, "\033[3~") || $input === "\x7f" => $this->deleteEditedArea($map), + $input === "\033" => $this->backOutOfAreaSelection(), + default => false, + }; + } + + private function toggleAreaCell(): bool + { + $key = $this->cursorX . ',' . $this->cursorY; + if (isset($this->areaSelection[$key])) { + unset($this->areaSelection[$key]); + } else { + $this->areaSelection[$key] = [$this->cursorX, $this->cursorY]; + } + $this->describeAreaSelection(); + + return true; + } + + private function markAreaRectangleCorner(): bool + { + if ($this->areaRectangleAnchor === null) { + $this->areaRectangleAnchor = [$this->cursorX, $this->cursorY]; + $this->setStatus(sprintf('Rectangle from %d, %d: move to its other corner and press r.', $this->cursorX, $this->cursorY)); + $this->renderCanvasArea(); + + return true; + } + foreach ($this->readAreaRectangleCells() as [$x, $y]) { + $this->areaSelection[$x . ',' . $y] = [$x, $y]; + } + $this->areaRectangleAnchor = null; + $this->describeAreaSelection(); + + return true; + } + + /** @return list The cells from the rectangle's first corner to the cursor. */ + private function readAreaRectangleCells(): array + { + if ($this->areaRectangleAnchor === null) { + return []; + } + [$anchorX, $anchorY] = $this->areaRectangleAnchor; + $cells = []; + for ($y = min($anchorY, $this->cursorY); $y <= max($anchorY, $this->cursorY); $y++) { + for ($x = min($anchorX, $this->cursorX); $x <= max($anchorX, $this->cursorX); $x++) { + $cells[] = [$x, $y]; + } + } + + return $cells; + } + + /** + * Enter: with nothing selected, picks up the area under the cursor; with + * cells selected, applies them to the area being changed, or asks for a + * new area's id. + */ + private function applyAreaSelection(ProjectMap $map): bool + { + if ($this->areaSelection === []) { + return $this->pickUpAreaAtCursor($map, step: false); + } + if ($this->editedAreaId !== null) { + $id = $this->editedAreaId; + if ($this->recordAreaEdit(fn(MapAreaAuthoring $areas): ?Command => $areas->setArea($id, $this->readAreaSelectionShape()), sprintf('Area %s now covers %%s.', $id))) { + $this->resetAreaSelection(); + } + $this->renderCanvasArea(); + + return true; + } + $this->areaIdBuffer = ''; + $this->setStatus('New area id: (lowercase letters, digits, . _ -; Enter makes it, Esc cancels)'); + $this->renderCanvasArea(); + + return true; + } + + /** + * Picks up the area under the cursor to change it: the first, or with + * `step` the one after the area picked up, as several may share a cell. + */ + private function pickUpAreaAtCursor(ProjectMap $map, bool $step): bool + { + $under = $this->findAreasAtCursor($map); + if ($under === []) { + $this->setStatus('Select cells first: Space adds the cursor\'s cell, r marks a rectangle\'s corners.', StatusLevel::WARN); + $this->renderFooter(); + + return true; + } + $current = $step ? array_search($this->editedAreaId, array_column($under, 'id'), true) : false; + $area = $under[$current === false ? 0 : ($current + 1) % count($under)]; + $this->resetAreaSelection(); + $this->editedAreaId = $area['id']; + foreach ($area['cells'] as [$x, $y]) { + $this->areaSelection[$x . ',' . $y] = [$x, $y]; + } + $this->setStatus(sprintf('Changing area %s%s: Space and r change its cells, Enter applies, Del deletes it, Esc lets it go.', + $area['id'], count($under) > 1 ? sprintf(' (1 of %d here; n for the next)', count($under)) : '')); + $this->renderCanvasArea(); + + return true; + } + + private function handleAreaIdInput(ProjectMap $map, string $input): void + { + if ($input === "\033") { + $this->areaIdBuffer = null; + $this->setStatus('No area made; the selection is kept.'); + } elseif ($input === "\n" || $input === "\r") { + $id = trim((string) $this->areaIdBuffer); + $this->areaIdBuffer = null; + if ($this->recordAreaEdit(fn(MapAreaAuthoring $areas): ?Command => $areas->createArea($id, $this->readAreaSelectionShape()), sprintf('Made area %s of %%s.', $id))) { + $this->resetAreaSelection(); + } + } elseif ($input === "\x7f" || $input === "\x08") { + $this->areaIdBuffer = mb_substr((string) $this->areaIdBuffer, 0, max(0, mb_strlen((string) $this->areaIdBuffer) - 1)); + $this->setStatus('New area id: ' . $this->areaIdBuffer); + } elseif (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { + $this->areaIdBuffer .= $input; + $this->setStatus('New area id: ' . $this->areaIdBuffer); + } + $this->renderCanvasArea(); + } + + private function deleteEditedArea(ProjectMap $map): bool + { + $id = $this->editedAreaId ?? ($this->findAreasAtCursor($map)[0]['id'] ?? null); + if ($id === null) { + $this->setStatus('No area here to delete.', StatusLevel::WARN); + $this->renderFooter(); + + return true; + } + if ($this->recordAreaEdit(static fn(MapAreaAuthoring $areas): ?Command => $areas->deleteArea($id), sprintf('Deleted area %s.', $id))) { + $this->resetAreaSelection(); + } + $this->renderCanvasArea(); + + return true; + } + + /** Esc walks one step out: the rectangle corner, then the selection, then Area mode. */ + private function backOutOfAreaSelection(): bool + { + if ($this->areaRectangleAnchor !== null) { + $this->areaRectangleAnchor = null; + $this->setStatus('Rectangle cancelled.'); + $this->renderCanvasArea(); + + return true; + } + if ($this->areaSelection !== [] || $this->editedAreaId !== null) { + return $this->clearAreaSelection('Selection let go.'); + } + + return false; + } + + private function clearAreaSelection(string $message): bool + { + $this->resetAreaSelection(); + $this->setStatus($message); + $this->renderCanvasArea(); + + return true; + } + + private function describeAreaSelection(): void + { + $count = count($this->areaSelection); + $this->setStatus(sprintf('%d %s selected%s. Enter %s.', $count, $count === 1 ? 'cell' : 'cells', + $this->editedAreaId === null ? '' : ' for area ' . $this->editedAreaId, + $this->editedAreaId === null ? 'makes an area of them' : 'applies them')); + $this->renderCanvasArea(); + } + + /** + * Runs an area edit and records it, reporting what it did or why it was + * refused. + * + * @param callable(MapAreaAuthoring): ?Command $edit + * @param string $done The status on success; `%s` stands for the cells. + * @return bool Whether it was made. + */ + private function recordAreaEdit(callable $edit, string $done): bool + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap) { + return false; + } + $cells = count($this->areaSelection); + try { + $command = $edit(new MapAreaAuthoring($map)); + } catch (MapAreaRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + + return false; + } + if ($command !== null) { + $this->recordCommand($command); + } + $this->setStatus(sprintf($done, sprintf('%d %s', $cells, $cells === 1 ? 'cell' : 'cells')), StatusLevel::INFO); + $this->renderInspectorArea(); + + return true; + } + + /** @return array The selection as the area shape it is written as. */ + private function readAreaSelectionShape(): array + { + return MapAreaAuthoring::createShapeFor(array_values($this->areaSelection)); + } + + /** + * The areas covering the cursor's cell, in the map's order. + * + * @return list}> + */ + private function findAreasAtCursor(ProjectMap $map): array + { + try { + $areas = MapAreaAuthoring::describeAreas($map); + } catch (MapAreaRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + + return []; + } + + return array_values(array_filter($areas, fn(array $area): bool => in_array([$this->cursorX, $this->cursorY], $area['cells'], true))); + } + + /** + * The selection and the rectangle being marked, highlighted over the + * map's own glyphs, as ProjectMap::renderPreview() takes a footprint. + * + * @return array> + */ + private function getAreaPreviewCells(): array + { + $cells = []; + foreach ([...array_values($this->areaSelection), ...$this->readAreaRectangleCells()] as [$x, $y]) { + $cells[$y][$x] = null; + } + + return $cells; + } +} diff --git a/src/Canvas/CanvasClipboard.php b/src/Canvas/CanvasClipboard.php new file mode 100644 index 00000000..d454ddfd --- /dev/null +++ b/src/Canvas/CanvasClipboard.php @@ -0,0 +1,125 @@ +getLayerSymbol($layerId, $x + $column, $y + $row); + $styles[$row][$column] = $map->getLayerCellStyle($layerId, $x + $column, $y + $row); + } + } + $tiles = $withTiles ? $map->readTileEntries($map->getTileLayersMovingWith($layerId), $x, $y, $width, $height) : []; + $source = array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId); + $name = (string) ($source['name'] ?? ''); + // A stack's objects travel by the layer each belongs to, never as whole tile layers. + $stacked = $withTiles ? StackedObjects::read($map, $name, $x, $y, $width, $height) : null; + $clipboard->store($rows, $layerId, $styles, $tiles, $name, $stacked); + } + + /** + * Copies the block into the clipboard, then clears its glyphs and the + * tiles that went with them, as one undo step. + * + * @return array{command: ?\Ichiloto\Editor\History\Command, changed: int} + * @throws MapSourceRefusal When the map cannot take the change; nothing is written then. + */ + public static function cut(ProjectMap $map, string $layerId, int $x, int $y, int $width, int $height, Clipboard $clipboard, + bool $withTiles = true): array + { + self::copy($map, $layerId, $x, $y, $width, $height, $clipboard, $withTiles); + $writes = array_map(static fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], 'symbol' => ' ', 'color' => null], + ToolGeometry::rectangleFilled($x, $y, $x + $width - 1, $y + $height - 1)); + $clears = array_map(static fn(array $tiles): array => array_map( + static fn(array $cell): array => ['entry' => (string) TileId::EMPTY] + $cell, $tiles), + $clipboard->projectTiles($x, $y, $map->getWidth(), $map->getHeight())); + $clears += StackedObjects::replace($map, $clipboard->layerName, + $clipboard->projectStacked($x, $y, $map->getWidth(), $map->getHeight())['cells']); + $applied = self::applyWithTiles($map, $layerId, $writes, 'Cut selection', $clears); + + return ['command' => $applied['command'], 'changed' => $applied['changed']]; + } + + /** + * Stamps the clipboard with its top-left cell at a map cell, as one undo + * step: its glyphs on the layer it came from, and its tiles on the tile + * layers that move with that layer. + * + * @param array $choices The role key chosen for a glyph that could be several pieces, or null for no tiles. + * @return array{command: ?\Ichiloto\Editor\History\Command, changed: int, unresolved: array>} + * @throws MapSourceRefusal When the clipboard is empty or from another layer, or the map cannot take the change. + */ + public static function paste(ProjectMap $map, string $layerId, Clipboard $clipboard, int $x, int $y, array $choices = []): array + { + if ($clipboard->isEmpty()) { + throw new MapSourceRefusal('The clipboard is empty: select part of the map and copy it first.'); + } + // A layer's id is its own map's; its name (terrain, buildings) is what it is on any map, so a block + // copied on one map pastes onto the layer of the same name on another. + $target = array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId); + if ($clipboard->layerName !== '' ? ($target['name'] ?? null) !== $clipboard->layerName : $clipboard->layer !== $layerId) { + $source = $clipboard->layerName !== '' ? $clipboard->layerName + : array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $clipboard->layer)['name'] ?? null; + throw new MapSourceRefusal($source === null + ? 'The clipboard holds a block from another layer; choose that layer before pasting.' + : sprintf('The clipboard holds a block from the %s layer; choose it before pasting.', MapLayers::formatLabel((string) $source))); + } + $tiles = array_intersect_key($clipboard->projectTiles($x, $y, $map->getWidth(), $map->getHeight()), + array_flip($map->getTileLayersMovingWith($layerId))); + ['cells' => $cells, 'laid' => $laid] = $clipboard->projectStacked($x, $y, $map->getWidth(), $map->getHeight()); + $tiles += StackedObjects::replace($map, (string) ($target['name'] ?? ''), $cells, $laid); + + return self::applyWithTiles($map, $layerId, $clipboard->project($x, $y, $map->getWidth(), $map->getHeight()), + 'Paste selection', $tiles, $choices); + } + + /** + * Writes glyphs and the tiles that follow them as one undo step, the + * edit's own tiles winning on their layers. + * + * @param array $writes + * @param array> $tiles + * @param array $choices + * @return array{command: ?\Ichiloto\Editor\History\Command, changed: int, unresolved: array>} + */ + private static function applyWithTiles(ProjectMap $map, string $layerId, array $writes, string $label, array $tiles, + array $choices = []): array + { + $plan = CanvasEditor::plan($map, $layerId, $writes, $choices, false, array_keys($tiles)); + if ($plan !== null && $plan['unresolved'] !== []) { + return ['command' => null, 'changed' => 0, 'unresolved' => $plan['unresolved']]; + } + $applied = CanvasEditor::apply($map, $layerId, $plan['writes'] ?? $writes, $label, [...($plan['tiles'] ?? []), ...$tiles]); + + return ['command' => $applied['command'], 'changed' => $applied['changed'], 'unresolved' => []]; + } +} diff --git a/src/Canvas/CanvasEditor.php b/src/Canvas/CanvasEditor.php new file mode 100644 index 00000000..12ebb07c --- /dev/null +++ b/src/Canvas/CanvasEditor.php @@ -0,0 +1,578 @@ + $writes The glyphs the edit writes. + * @param array $choices The role key chosen for a glyph, or null for no tiles. + * @param bool $repaint Whether a glyph painted over itself may take another role. + * @param list $excludedLayers Tile layers the edit sets itself. + * @param array $assigned The role key a written cell's glyph plays, by `x,y`, when the edit knows it. + * @return array{tiles: array>, unresolved: array>, writes: array}|null + * The tiles, the glyphs still waiting on a choice, and the glyphs the edit writes: `$writes`, except where + * an erase gives a cell back to the role its kept underlay proves, painted as its piece authors it. Null + * when the layer has no pieces to follow, and the edit writes `$writes` as they are. + * @throws MapSourceRefusal When a tile layer the pieces draw on cannot be read. + */ + public static function plan(ProjectMap $map, string $layerId, array $writes, array $choices = [], bool $repaint = false, + array $excludedLayers = [], array $assigned = []): ?array + { + $planner = self::loadGlyphTilePlanner($map, $layerId, $excludedLayers); + if ($planner === null) { + return null; + } + $changes = []; + foreach ($writes as $write) { + if ($map->hasLayerCell($layerId, $write['x'], $write['y'])) { + $changes[] = ['x' => $write['x'], 'y' => $write['y'], + 'old' => $map->getLayerSymbol($layerId, $write['x'], $write['y']), 'new' => $write['symbol']]; + } + } + + $plan = $planner->plan( + $changes, + static fn(int $x, int $y): ?string => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : null, + self::createTileReader($map), + $choices, + $repaint, + $assigned, + ); + $restored = []; + foreach ($plan['glyphs'] as $glyph) { + // The face comes back as its piece paints it, never in the colour of the piece that left. + $restored["{$glyph['x']},{$glyph['y']}"] = ['x' => $glyph['x'], 'y' => $glyph['y'], 'symbol' => $glyph['symbol']] + + PiecePlacer::resolveCellPaint($glyph['role']->source, ''); + } + $plan['writes'] = array_map(static fn(array $write): array => $restored["{$write['x']},{$write['y']}"] ?? $write, $writes); + + return $plan; + } + + /** + * The piece a layer's glyph plays at a cell, read from the tiles around + * it: what an eyedropper picks up with the glyph, so painting it again + * draws the same piece. Null when no piece of the map's kind draws it, or + * its tiles do not tell which. + */ + public static function findPlayedRole(ProjectMap $map, string $layerId, int $x, int $y): ?PieceRole + { + $planner = self::loadGlyphTilePlanner($map, $layerId); + if ($planner === null || ! $map->hasLayerCell($layerId, $x, $y)) { + return null; + } + try { + return $planner->findPlayedRole($map->getLayerSymbol($layerId, $x, $y), $x, $y, self::createTileReader($map)); + } catch (MapSourceRefusal) { + return null; + } + } + + /** + * Draws the tiles of the glyphs already on a layer, as if each were + * painted again: every glyph a piece draws gets that piece's tiles, as one + * step the caller records. A glyph that could be several pieces is + * reported unresolved, as painting does, and its answer in `$choices` + * applies wherever its neighbours do not decide. Glyphs no piece draws, + * and the glyphs themselves, are left as they are. + * + * @param array $choices The role key chosen for a glyph, or null for no tiles. + * @return array{command: ?Command, cells: int, unresolved: array>}|null + * The applied step and how many tiles it drew; or, when a glyph must be asked about, nothing applied and the + * roles of the first glyph unresolved. Null when no piece in the map's kind draws this layer's glyphs. + * @throws MapSourceRefusal When the tile layers cannot take the tiles; nothing is written then. + */ + public static function drawTilesForGlyphs(ProjectMap $map, string $layerId, array $choices = []): ?array + { + $writes = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if ($map->hasLayerCell($layerId, $x, $y) && ($symbol = $map->getLayerSymbol($layerId, $x, $y)) !== ' ') { + $writes[] = ['x' => $x, 'y' => $y, 'symbol' => $symbol]; + } + } + } + $plan = self::plan($map, $layerId, $writes, $choices, true); + if ($plan === null) { + return null; + } + if ($plan['unresolved'] !== []) { + return ['command' => null, 'cells' => 0, 'unresolved' => $plan['unresolved']]; + } + $before = $map->getTileLayerSources(); + $map->writeTileCells($plan['tiles']); + $after = $map->getTileLayerSources(); + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + $command = $after === $before ? null : new GenericCommand( + sprintf('Draw %s tiles', MapLayers::formatLabel((string) ($layer['name'] ?? $layerId))), + static fn() => $map->restoreTileLayerSources($after), + static fn() => $map->restoreTileLayerSources($before), + ); + + return ['command' => $command, 'cells' => $command === null ? 0 : array_sum(array_map(count(...), $plan['tiles'])), 'unresolved' => []]; + } + + /** + * Writes glyphs onto a layer and tiles onto the tile layers, and returns + * the command that undoes and redoes both, already applied. The command + * is null when nothing changed. + * + * @param array $writes + * @param array> $tiles Tile cells keyed by tile layer name. + * @return array{command: ?Command, changed: int} The command and the number of glyph cells that changed. + * @throws MapSourceRefusal When the tile layers cannot take the tiles; nothing is written then. + */ + public static function apply(ProjectMap $map, string $layerId, array $writes, string $label, array $tiles = []): array + { + if ($tiles === []) { + [$stroke, $changed] = self::writeCells($map, $layerId, $writes, $label); + + return ['command' => $stroke->hasChanges() ? $stroke : null, 'changed' => $changed]; + } + $tilesBefore = $map->getTileLayerSources(); + $map->writeTileCells($tiles); + $tilesAfter = $map->getTileLayerSources(); + [$stroke, $changed] = self::writeCells($map, $layerId, $writes, $label); + $command = $stroke->hasChanges() || $tilesAfter !== $tilesBefore + ? self::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter) + : null; + + return ['command' => $command, 'changed' => $changed]; + } + + /** + * Writes cells onto one layer and returns the stroke that undoes them, + * unrecorded, so a caller can record it alone or as part of a larger + * step. Cells outside the layer are skipped. + * + * @param array $writes The cells to write. + * @return array{0: PaintStrokeCommand, 1: int} The stroke and the number of cells that actually changed. + */ + public static function writeCells(ProjectMap $map, string $layer, array $writes, string $label): array + { + $stroke = new PaintStrokeCommand($map, $layer, $label); + $changed = 0; + // Each write reads the cell as the writes before it left it, then all are set as one edit. + $pending = []; + + foreach ($writes as $write) { + if (! $map->hasLayerCell($layer, $write['x'], $write['y'])) { + continue; + } + + $key = $write['x'] . ',' . $write['y']; + $oldSymbol = $pending[$key]['symbol'] ?? $map->getLayerSymbol($layer, $write['x'], $write['y']); + $oldStyle = isset($pending[$key]) + ? ['prefix' => $pending[$key]['prefix'], 'suffix' => $pending[$key]['suffix']] + : $map->getLayerCellStyle($layer, $write['x'], $write['y']); + [$newPrefix, $newSuffix] = isset($write['style']) + ? [$write['style']['prefix'], $write['style']['suffix']] + : self::resolvePaintStyle($write['symbol'], $write['color'] ?? null, $oldStyle); + $newSymbol = ProjectMap::normalizeSymbol($write['symbol']); + unset($pending[$key]); + $pending[$key] = ['x' => $write['x'], 'y' => $write['y'], 'symbol' => $newSymbol, 'prefix' => $newPrefix, 'suffix' => $newSuffix]; + $stroke->appendCell($write['x'], $write['y'], $oldSymbol, $newSymbol, $oldStyle['prefix'], $oldStyle['suffix'], + $newPrefix, $newSuffix); + + if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { + $changed++; + } + } + if ($pending !== []) { + $map->setLayerCells($layer, array_values($pending)); + } + + return [$stroke, $changed]; + } + + /** + * Resolves the styling bytes a paint writes. + * + * The colour directive follows the brush contract: null keeps the + * cell's existing styling byte-for-byte, an empty string paints without + * colour, and any other value becomes an `fg=` tag. A space is always + * uncoloured, so erasing never leaves invisible styling behind. + * + * @param array{prefix: string, suffix: string} $oldStyle The cell's current styling. + * @return array{0: string, 1: string} The prefix and suffix to write. + */ + public static function resolvePaintStyle(string $symbol, ?string $colorDirective, array $oldStyle): array + { + if ($symbol === ' ') { + return ['', '']; + } + + if ($colorDirective === null) { + return [$oldStyle['prefix'], $oldStyle['suffix']]; + } + + if ($colorDirective === '') { + return ['', '']; + } + + return [sprintf('', $colorDirective), '']; + } + + /** + * Sets one tile in each cell of a tile layer, `0` erasing it, as one undo + * step, by {@see stampTiles()}: a tile that stands for a glyph brings or + * takes that glyph with it. + * + * @param list $cells The cells, as [x, y]. + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{command: ?Command, changed: int, glyphs: int, unresolved: array>} + * @throws MapSourceRefusal When the tile is not a tile identity, a cell is outside the map or the layer cannot be written; nothing is changed. + */ + public static function setTiles(ProjectMap $map, string $layerName, array $cells, int $tile, string $label, array $choices = []): array + { + return self::stampTiles($map, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), $label, $choices); + } + + /** + * Sets each listed cell of a tile layer to its own tile, `0` erasing, as + * one undo step: a block of tiles chosen together and stamped, as RPG + * Maker places a multi-tile selection. The layer is created when the map + * does not have it yet. An autotile is placed by its kind; the Engine + * shapes it from its neighbours. + * + * A tile that stands for a glyph ({@see TileGlyphBinding}) never moves + * without it. Placing one writes its glyph where its role puts it, with + * the rest of that role's tiles and the piece's glyph cells that draw no + * tile; erasing or covering one removes the glyph it stood for, with its + * other tiles. A wall's glyphs take the shapes their neighbours give them. + * Glyphs, their collision and every tile change as one undo step. A tile + * that stands for nothing, such as a rug, is written as given. An erase + * and a stamp in one call move a piece. + * + * @param list $cells Each cell as [x, y, tile]. + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{command: ?Command, changed: int, glyphs: int, unresolved: array>} + * No command when nothing changed, or when a tile still waits on a choice ({@see PieceRole} by tile entry). + * @throws MapSourceRefusal When a tile is not a tile identity, a cell is outside the map, a tile's glyph would fall + * outside its layer or the layer cannot be written; nothing is changed. + */ + public static function stampTiles(ProjectMap $map, string $layerName, array $cells, string $label, array $choices = []): array + { + $stamp = []; + foreach ($cells as [$x, $y, $tile]) { + $stamp["{$x},{$y}"] = (string) $tile; + } + $binding = TileGlyphBinding::fromMap($map); + $tileAt = self::createTileReader($map); + $glyphPlan = self::planStampGlyphs($map, $binding, $layerName, $stamp, $tileAt, $choices); + if ($glyphPlan['unresolved'] !== []) { + return ['command' => null, 'changed' => 0, 'glyphs' => 0, 'unresolved' => $glyphPlan['unresolved']]; + } + + // The tiles the glyphs' roles take and bring, then the stamp's own + // tiles, except where an arriving role draws: its tiles stay whole. + $tiles = []; + foreach ($glyphPlan['writes'] as $layerId => $writes) { + $plan = self::plan($map, $layerId, $writes, assigned: $glyphPlan['assigned'][$layerId] ?? []); + foreach ($plan['tiles'] ?? [] as $tileLayer => $entries) { + foreach ($entries as $entry) { + $tiles[$tileLayer]["{$entry['x']},{$entry['y']}"] = $entry['entry']; + } + } + } + foreach ($stamp as $position => $entry) { + $planned = $tiles[$layerName][$position] ?? null; + if ($planned === null || $planned === (string) TileId::EMPTY) { + $tiles[$layerName][$position] = $entry; + } + } + + $before = $map->getTileLayerSources(); + $previous = []; + foreach (array_keys($tiles) as $tileLayer) { + $previous[$tileLayer] = $map->readTileEntries([$tileLayer], 0, 0, $map->getWidth(), $map->getHeight())[$tileLayer] ?? []; + } + $map->writeTileCells(array_map(static fn(array $entries): array => array_map(static function (string $entry, string $position): array { + [$x, $y] = array_map(intval(...), explode(',', $position)); + + return ['x' => $x, 'y' => $y, 'entry' => $entry]; + }, $entries, array_map(strval(...), array_keys($entries))), $tiles)); + $after = $map->getTileLayerSources(); + $strokes = []; + $glyphs = 0; + foreach ($glyphPlan['writes'] as $layerId => $writes) { + [$stroke, $changed] = self::writeCells($map, $layerId, $writes, $label); + $glyphs += $changed; + if ($stroke->hasChanges()) { + $strokes[] = $stroke; + } + } + if ($strokes === [] && $after === $before) { + return ['command' => null, 'changed' => 0, 'glyphs' => 0, 'unresolved' => []]; + } + $changed = 0; + foreach ($tiles as $tileLayer => $entries) { + foreach ($entries as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $changed += ($previous[$tileLayer][$y][$x] ?? (string) TileId::EMPTY) !== $entry ? 1 : 0; + } + } + + return ['command' => new GenericCommand($label, + static function () use ($strokes, $map, $after): void { + foreach ($strokes as $stroke) { + $stroke->execute(); + } + $map->restoreTileLayerSources($after); + }, + static function () use ($strokes, $map, $before): void { + foreach (array_reverse($strokes) as $stroke) { + $stroke->undo(); + } + $map->restoreTileLayerSources($before); + }), 'changed' => $changed, 'glyphs' => $glyphs, 'unresolved' => []]; + } + + /** + * The glyphs a tile stamp writes so no tile that stands for a glyph is + * left without it: the glyph a covered or erased tile stood for leaves, + * and the glyph a placed tile stands for arrives, each with the piece's + * glyph cells that draw no tile. A wall joins or leaves its connected + * piece and every wall cell beside it takes its new shape. + * + * @param array $stamp The stamp's tile entries, by `x,y`. + * @param Closure(string, int, int): string $tileAt Tile entries before the stamp. + * @param array $choices The role key chosen for an ambiguous tile, by entry. + * @return array{writes: array>, assigned: array>, + * unresolved: array>} Glyph writes and the role each written cell plays, by gameplay layer id. + * @throws MapSourceRefusal When a tile's glyph would fall outside its layer. + */ + private static function planStampGlyphs(ProjectMap $map, TileGlyphBinding $binding, string $tileLayer, array $stamp, + Closure $tileAt, array $choices): array + { + $glyphAt = static fn(string $layerId, int $x, int $y): ?string + => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : null; + $symbols = $assigned = $unresolved = []; + $connected = []; + + $leave = static function (TileBinding $bound, int $x, int $y) use (&$symbols, &$assigned, &$connected, $glyphAt): void { + if ($bound->isConnected) { + $connected[$bound->layerId][$bound->piece->id]['erased']["{$x},{$y}"] = ['x' => $x, 'y' => $y]; + return; + } + $symbols[$bound->layerId]["{$x},{$y}"] = ' '; + unset($assigned[$bound->layerId]["{$x},{$y}"]); + foreach ($bound->companions as $companion) { + [$cx, $cy] = [$x + $companion['dx'], $y + $companion['dy']]; + if ($glyphAt($bound->layerId, $cx, $cy) === $companion['glyph']) { + $symbols[$bound->layerId]["{$cx},{$cy}"] = ' '; + } + } + }; + $arrive = static function (TileBinding $bound, int $x, int $y) use (&$symbols, &$assigned, &$connected, $glyphAt): void { + if ($glyphAt($bound->layerId, $x, $y) === null) { + throw new MapSourceRefusal(sprintf('That tile stands for %s, whose glyph at (%d, %d) is outside the map. Nothing was changed.', + $bound->role->label, $x, $y)); + } + if ($bound->isConnected) { + $connected[$bound->layerId][$bound->piece->id]['drawn']["{$x},{$y}"] = ['x' => $x, 'y' => $y]; + return; + } + $symbols[$bound->layerId]["{$x},{$y}"] = $bound->glyph; + $assigned[$bound->layerId]["{$x},{$y}"] = $bound->role->key; + foreach ($bound->companions as $companion) { + [$cx, $cy] = [$x + $companion['dx'], $y + $companion['dy']]; + if ($glyphAt($bound->layerId, $cx, $cy) !== null) { + $symbols[$bound->layerId]["{$cx},{$cy}"] = $companion['glyph']; + $assigned[$bound->layerId]["{$cx},{$cy}"] = $companion['key']; + } + } + }; + + // Leaving first: the glyph each covered or erased tile stood for. + foreach ($stamp as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $old = $tileAt($tileLayer, $x, $y); + if ($old === $entry) { + continue; + } + foreach ($binding->findBindings($tileLayer, $old) as $bound) { + [$gx, $gy] = [$x - $bound->dx, $y - $bound->dy]; + if ($glyphAt($bound->layerId, $gx, $gy) === $bound->glyph + && $binding->getPlanner($bound->layerId)?->findPlayedRole($bound->glyph, $gx, $gy, $tileAt)?->key === $bound->role->key) { + $leave($bound, $gx, $gy); + break; + } + } + } + // Then arriving: the glyph each placed tile stands for. + foreach ($stamp as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $bindings = $binding->findBindings($tileLayer, $entry); + if ($binding->isFree($tileLayer, $entry)) { + // A free tile plays the role of the glyph already on its cell, or none: it never brings a glyph. + $bindings = array_values(array_filter($bindings, static fn(TileBinding $bound): bool => + $glyphAt($bound->layerId, $x - $bound->dx, $y - $bound->dy) === $bound->glyph)); + } + $bound = self::chooseBinding($bindings, $tileLayer, $x, $y, $stamp, $glyphAt, $choices[$entry] ?? null); + if ($bound instanceof TileBinding) { + $arrive($bound, $x - $bound->dx, $y - $bound->dy); + } elseif ($bound !== null) { + $unresolved[$entry] = $bound; + } + } + // Connected pieces: every drawn, erased and neighbouring wall cell takes its shape. + foreach ($connected as $layerId => $pieces) { + foreach ($pieces as $pieceId => $cells) { + $piece = $map->loadPlacements()[$pieceId] ?? null; + if ($piece === null) { + continue; + } + $isMember = static fn(int $x, int $y): bool => in_array($glyphAt($layerId, $x, $y), $piece->shapes, true); + foreach (ConnectedPieceShaper::reshapeCells($piece, array_values($cells['drawn'] ?? []), array_values($cells['erased'] ?? []), $isMember) as $cell) { + if ($glyphAt($layerId, $cell['x'], $cell['y']) !== null) { + $symbols[$layerId]["{$cell['x']},{$cell['y']}"] = $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']]; + } + } + } + } + + $writes = []; + foreach ($symbols as $layerId => $cells) { + foreach ($cells as $position => $symbol) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + $writes[$layerId][] = ['x' => $x, 'y' => $y, 'symbol' => $symbol]; + } + } + + return ['writes' => $writes, 'assigned' => $assigned, 'unresolved' => $unresolved]; + } + + /** + * The glyph a placed tile stands for: its only binding; the one the rest + * of the stamp proves, by holding more of that role's tiles; the author's + * choice; or, still undecided, the roles to choose among. The cells of + * one connected piece count as one binding, since their neighbours decide + * the shape. Null for a tile that stands for nothing. + * + * @param list $bindings + * @param array $stamp + * @param Closure(string, int, int): ?string $glyphAt + * @return TileBinding|list|null + */ + private static function chooseBinding(array $bindings, string $tileLayer, int $x, int $y, array $stamp, Closure $glyphAt, + ?string $choice): TileBinding|array|null + { + $distinct = []; + foreach ($bindings as $bound) { + $distinct[$bound->isConnected ? "{$bound->layerId}:{$bound->piece->id}" : "{$bound->layerId}:{$bound->role->key}"] ??= $bound; + } + if (count($distinct) < 2) { + return array_values($distinct)[0] ?? null; + } + if ($choice !== null) { + foreach ($distinct as $bound) { + if ($bound->role->key === $choice) { + return $bound; + } + } + } + $scored = []; + foreach ($distinct as $bound) { + [$gx, $gy] = [$x - $bound->dx, $y - $bound->dy]; + $score = 0; + foreach ($bound->role->tiles[$tileLayer] ?? [] as $cell) { + $score += ($stamp[($gx + $cell['dx']) . ',' . ($gy + $cell['dy'])] ?? null) === $cell['entry'] ? 2 : 0; + } + $score += $glyphAt($bound->layerId, $gx, $gy) === $bound->glyph ? 1 : 0; + $scored[$score][] = $bound; + } + krsort($scored); + $best = reset($scored); + if (count($best) === 1) { + return $best[0]; + } + + return array_values(array_map(static fn(TileBinding $bound): PieceRole => $bound->role, $distinct)); + } + + /** + * One undo step for a glyph stroke and the tile layer change made with it, + * such as a stamped piece or a pasted block. + * + * @param array $tilesBefore Tile layer sources before the change. + * @param array $tilesAfter Tile layer sources after it. + */ + public static function combineStrokeWithTiles(string $label, ProjectMap $map, PaintStrokeCommand $stroke, + array $tilesBefore, array $tilesAfter): Command + { + return new GenericCommand($label, + static function () use ($stroke, $map, $tilesAfter): void { + $stroke->execute(); + $map->restoreTileLayerSources($tilesAfter); + }, + static function () use ($stroke, $map, $tilesBefore): void { + $stroke->undo(); + $map->restoreTileLayerSources($tilesBefore); + }, + ); + } + + /** The planner for a gameplay layer's pieces, or null when it has none to follow. */ + public static function loadGlyphTilePlanner(ProjectMap $map, string $layerId, array $excludedLayers = []): ?GlyphTilePlanner + { + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + if ($layer === null || $layer['id'] === MapLayers::EVENT || $layer['decoration']) { + return null; + } + try { + $pieces = $map->loadPlacements(); + // A stack lays its pieces' tiles itself, so no glyph carries them. + $excludedLayers = [...$excludedLayers, ...$map->loadTileset()?->getStackLayers() ?? []]; + } catch (InvalidArgumentException | RuntimeException) { + return null; + } + + return $pieces === [] ? null : GlyphTilePlanner::fromPieces($pieces, $layer['name'], array_values(array_unique($excludedLayers))); + } + + /** + * Reads tile entries cell by cell, each layer once: `0` where a layer + * has no tile, or the map has no such layer. + * + * @return Closure(string, int, int): string + */ + public static function createTileReader(ProjectMap $map): Closure + { + $layers = []; + + return static function (string $layer, int $x, int $y) use ($map, &$layers): string { + $layers[$layer] ??= $map->readTileEntries([$layer], 0, 0, $map->getWidth(), $map->getHeight())[$layer] ?? []; + + return $layers[$layer][$y][$x] ?? (string) TileId::EMPTY; + }; + } +} diff --git a/src/Canvas/CanvasTool.php b/src/Canvas/CanvasTool.php index 3d0a0d05..86f5f1c9 100644 --- a/src/Canvas/CanvasTool.php +++ b/src/Canvas/CanvasTool.php @@ -36,6 +36,32 @@ enum CanvasTool: string */ case SELECT = 'select'; + /** The square brush widths an author chooses between, in cells. */ + public const array BRUSH_SIZES = [1, 2, 3, 5]; + + /** + * The cells the tool paints between an anchor and the cursor: the brush's + * square at the cursor, a line, or a rectangle's outline, each widened by + * the brush; a filled rectangle is already solid, so it is not widened, + * which would only spill past its corners. A selection paints nothing. + * + * @param array{x: int, y: int} $anchor + * @param array{x: int, y: int} $cursor + * @return list + */ + public function getShapeCells(array $anchor, array $cursor, int $brushSize): array + { + $cells = match ($this) { + self::BRUSH => [$cursor], + self::LINE => ToolGeometry::line($anchor['x'], $anchor['y'], $cursor['x'], $cursor['y']), + self::RECTANGLE => ToolGeometry::rectangleOutline($anchor['x'], $anchor['y'], $cursor['x'], $cursor['y']), + self::FILLED_RECTANGLE => ToolGeometry::rectangleFilled($anchor['x'], $anchor['y'], $cursor['x'], $cursor['y']), + self::SELECT => [], + }; + + return array_values($this === self::FILLED_RECTANGLE ? $cells : ToolGeometry::expandByBrush($cells, $brushSize)); + } + /** * Returns the short user-facing tool label. * diff --git a/src/Canvas/Clipboard.php b/src/Canvas/Clipboard.php index c88c1948..95d1db02 100644 --- a/src/Canvas/Clipboard.php +++ b/src/Canvas/Clipboard.php @@ -10,7 +10,8 @@ * paste per stamp. * * The clipboard is session-scoped and layer-tagged so a block copied from - * the event layer never lands silently on the tile layer. + * the event layer never lands silently on the tile layer. A block lifted + * off a gameplay layer carries the tiles that move with it, cell for cell. */ final class Clipboard { @@ -24,6 +25,18 @@ final class Clipboard * The layer the block was lifted from (a PaintStrokeCommand LAYER_* value). */ public private(set) string $layer = ''; + /** + * The source layer's name (terrain, buildings, fixtures): what the block is to any map, since a layer's id + * is its own map's and its tile layers follow it by name. Empty for a block stored without one. + */ + public private(set) string $layerName = ''; + /** @var array>> Tile entries by row, keyed by tile layer name. */ + private array $tiles = []; + /** + * @var array>>>|null The layer's stacked objects' tiles, cell by cell, by row, keyed by stack id + * ({@see StackedObjects}); null for a block lifted without its tiles. + */ + private ?array $stacked = null; /** * Stores a block of symbols. @@ -31,13 +44,21 @@ final class Clipboard * @param array> $rows Rows of symbols, top-left first. * @param string $layer The source layer. * @param array> $styles Optional tile styles, aligned with rows. + * @param array>> $tiles The tile entries that move with the block, by row, keyed by tile layer name. + * @param string $layerName The source layer's name, which a paste on another map matches. + * @param array>>>|null $stacked The layer's stacked objects' tiles in the block ({@see StackedObjects::read()}), + * or null for a block lifted without its tiles. * @return void */ - public function store(array $rows, string $layer, array $styles = []): void + public function store(array $rows, string $layer, array $styles = [], array $tiles = [], string $layerName = '', + ?array $stacked = null): void { + $this->stacked = $stacked; $this->rows = array_values(array_map(static fn(array $row): array => array_values($row), $rows)); $this->styles = array_values(array_map(static fn(array $row): array => array_values($row), $styles)); $this->layer = $layer; + $this->layerName = $layerName; + $this->tiles = $tiles; } /** @@ -50,6 +71,9 @@ public function clear(): void $this->rows = []; $this->styles = []; $this->layer = ''; + $this->layerName = ''; + $this->tiles = []; + $this->stacked = null; } /** @@ -132,4 +156,62 @@ public function project(int $originX, int $originY, int $width, int $height): ar return $placements; } + + /** + * Projects the block's tiles onto a map at the given top-left origin, + * every cell included, so a paste replaces the tiles under the block as + * it replaces the glyphs. + * + * @return array> The clipped cells, keyed by tile layer name. + */ + public function projectTiles(int $originX, int $originY, int $width, int $height): array + { + $cells = []; + foreach ($this->tiles as $name => $rows) { + $cells[$name] = []; + foreach ($rows as $rowIndex => $row) { + foreach ($row as $columnIndex => $entry) { + $x = $originX + $columnIndex; + $y = $originY + $rowIndex; + if ($x >= 0 && $y >= 0 && $x < $width && $y < $height) { + $cells[$name][] = ['x' => $x, 'y' => $y, 'entry' => $entry]; + } + } + } + } + + return $cells; + } + + /** + * Projects the block's stacked objects onto a map at the given top-left + * origin: every cell of the block on the map, whose objects of the + * block's layer a paste replaces, and the tiles it lays in each. A block + * lifted without its tiles replaces none. + * + * @return array{cells: list, laid: array>>} Tiles by stack id, then `x,y` cell key. + */ + public function projectStacked(int $originX, int $originY, int $width, int $height): array + { + $cells = $laid = []; + if ($this->stacked === null) { + return ['cells' => [], 'laid' => []]; + } + foreach ($this->rows as $rowIndex => $row) { + foreach (array_keys($row) as $columnIndex) { + [$x, $y] = [$originX + $columnIndex, $originY + $rowIndex]; + if ($x < 0 || $y < 0 || $x >= $width || $y >= $height) { + continue; + } + $cells[] = ['x' => $x, 'y' => $y]; + foreach ($this->stacked as $stack => $rows) { + if (($rows[$rowIndex][$columnIndex] ?? []) !== []) { + $laid[$stack]["{$x},{$y}"] = $rows[$rowIndex][$columnIndex]; + } + } + } + } + + return ['cells' => $cells, 'laid' => $laid]; + } } diff --git a/src/Canvas/ConnectedPieceShaper.php b/src/Canvas/ConnectedPieceShaper.php new file mode 100644 index 00000000..e99a1475 --- /dev/null +++ b/src/Canvas/ConnectedPieceShaper.php @@ -0,0 +1,76 @@ + Neighbour offsets, in the order {@see TilesetPiece::getLineShape()} takes them. */ + private const array NEIGHBOURS = ['north' => [0, -1], 'east' => [1, 0], 'south' => [0, 1], 'west' => [-1, 0]]; + + private function __construct() + { + } + + /** + * @param list $drawn The cells that join the piece. + * @param list $erased The cells that leave it. + * @param Closure(int, int): bool $isMemberCell Whether a cell belongs to the piece before the change; false beyond the map. + * @return list Each cell to write with the shape it takes, or null for an erased + * cell: the drawn cells first, in order, then the erased cells, then the neighbours they reshape. + */ + public static function reshapeCells(TilesetPiece $piece, array $drawn, array $erased, Closure $isMemberCell): array + { + $overrides = []; + foreach ($erased as $cell) { + $overrides[self::getCellKey($cell['x'], $cell['y'])] = false; + } + foreach ($drawn as $cell) { + $overrides[self::getCellKey($cell['x'], $cell['y'])] = true; + } + $isMember = static fn(int $x, int $y): bool => $overrides[self::getCellKey($x, $y)] ?? $isMemberCell($x, $y); + + $cells = []; + foreach ([...$drawn, ...$erased] as $cell) { + $cells[self::getCellKey($cell['x'], $cell['y'])] ??= ['x' => $cell['x'], 'y' => $cell['y']]; + } + foreach ([...$drawn, ...$erased] as $cell) { + foreach (self::NEIGHBOURS as [$deltaX, $deltaY]) { + $x = $cell['x'] + $deltaX; + $y = $cell['y'] + $deltaY; + if (! isset($cells[self::getCellKey($x, $y)]) && $isMember($x, $y)) { + $cells[self::getCellKey($x, $y)] = ['x' => $x, 'y' => $y]; + } + } + } + + return array_values(array_map(static function (array $cell) use ($piece, $isMember): array { + if (! $isMember($cell['x'], $cell['y'])) { + return $cell + ['shape' => null]; + } + $joined = array_map(static fn(array $offset): bool => $isMember($cell['x'] + $offset[0], $cell['y'] + $offset[1]), self::NEIGHBOURS); + + return $cell + ['shape' => $piece->getLineShape(...$joined)]; + }, $cells)); + } + + private static function getCellKey(int $x, int $y): string + { + return "{$x},{$y}"; + } +} diff --git a/src/Canvas/FacadeCatalogue.php b/src/Canvas/FacadeCatalogue.php new file mode 100644 index 00000000..de52e62c --- /dev/null +++ b/src/Canvas/FacadeCatalogue.php @@ -0,0 +1,32 @@ + new EditableGrid($block)->cells, + array_filter($blocks, static fn(string $block): bool => trim($block) !== ''), + )); + } + + public static function discover(string $root): array + { + $directory = $root . '/assets/Graphics/Tilesets'; + $files = is_dir($directory) ? (glob($directory . '/*.txt') ?: []) : []; + sort($files); + return $files; + } +} diff --git a/src/Canvas/GlyphTileCanvas.php b/src/Canvas/GlyphTileCanvas.php new file mode 100644 index 00000000..821db2ee --- /dev/null +++ b/src/Canvas/GlyphTileCanvas.php @@ -0,0 +1,277 @@ +}|null The tile layers before the mouse stroke in progress changed them. */ + private ?array $activeStrokeTiles = null; + + /** + * Writes cells onto the active layer and the tiles that follow them as + * one undo step. Tiles another source sets, such as a pasted block's, + * win on their layers. A glyph that needs the author's choice opens the + * choice and changes nothing yet; choosing runs the edit again through + * `$retry` with the answer among its choices. + * + * @param array $writes The cells to write. + * @param array> $tiles Tile cells set by the edit itself, keyed by tile layer name. + * @param (Closure(array): void)|null $retry Runs the edit again with the author's choices. + * @param array $choices The role key chosen for a glyph, or null for no tiles. + * @param bool $repaint Whether a glyph painted over itself may take another role. + * @return int|null The number of glyph cells that changed, or null when nothing changed: refused, or waiting on a choice. + */ + private function commitCanvasWrites(ProjectMap $map, array $writes, string $label, array $tiles = [], ?Closure $retry = null, + array $choices = [], bool $repaint = false): ?int + { + if ($writes === [] && $tiles === []) { + return 0; + } + $layer = $this->getActiveCanvasLayer(); + try { + $plan = $this->planGlyphTiles($map, $layer, $writes, $choices, $repaint, array_keys($tiles)); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + if ($plan !== null && $plan['unresolved'] !== [] && $retry !== null) { + $glyph = (string) array_key_first($plan['unresolved']); + $this->askForGlyphPiece($glyph, $plan['unresolved'][$glyph], $choices, $retry); + return null; + } + $tiles = [...($plan['tiles'] ?? []), ...$tiles]; + $writes = $plan['writes'] ?? $writes; + + $this->finalizeActiveStroke(); + try { + $applied = CanvasEditor::apply($map, $layer, $writes, $label, $tiles); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + if ($applied['command'] !== null) { + $this->recordCommand($applied['command']); + } + + return $applied['changed']; + } + + /** + * Writes the tiles that follow one step of a mouse stroke, before its + * glyphs are written, so the stroke records them with its glyphs when it + * ends ({@see finalizeActiveStroke()}). + * + * @param array $writes + * @param Closure(array): void $retry Paints this step again with the author's choices. + * @return array|null + * The glyphs the step writes ({@see CanvasEditor::plan()}), or null when refused or waiting on a choice. + */ + private function followStrokeWithTiles(ProjectMap $map, array $writes, Closure $retry): ?array + { + try { + $plan = $this->planGlyphTiles($map, $this->getActiveCanvasLayer(), $writes, + $this->getPaintPieceChoices((string) ($writes[0]['symbol'] ?? '')), true); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + if ($plan === null) { + return $writes; + } + if ($plan['unresolved'] !== []) { + $this->finalizeActiveStroke(); + $glyph = (string) array_key_first($plan['unresolved']); + $this->askForGlyphPiece($glyph, $plan['unresolved'][$glyph], [], $retry); + return null; + } + if ($plan['tiles'] === []) { + return $plan['writes']; + } + if ($this->activeStrokeTiles === null || $this->activeStrokeTiles['map'] !== $map) { + $this->activeStrokeTiles = ['map' => $map, 'sources' => $map->getTileLayerSources()]; + } + try { + $map->writeTileCells($plan['tiles']); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + + return $plan['writes']; + } + + /** + * The tiles that keep a layer's pieces drawn where its glyphs will be, + * or null when the layer has no pieces to follow: the event layer, a + * decoration layer, or a map whose tileset cannot load, which validation + * reports. + * + * @param array $writes + * @param array $choices + * @param list $excludedLayers Tile layers the edit sets itself. + * @return array{tiles: array>, unresolved: array>, writes: array>}|null + * @throws MapSourceRefusal When a tile layer the pieces draw on cannot be read. + */ + private function planGlyphTiles(ProjectMap $map, string $layerId, array $writes, array $choices, bool $repaint, + array $excludedLayers = []): ?array + { + return CanvasEditor::plan($map, $layerId, $writes, $choices, $repaint, $excludedLayers); + } + /** + * Draws the tiles of the glyphs already on the layer being edited, as if + * each were painted again: every glyph a piece draws gets that piece's + * tiles, in one undo step, unsaved until the map is saved. A glyph that + * could be several pieces asks first, as painting does, and the answer + * applies wherever its neighbours do not decide. Glyphs no piece draws, + * and the glyphs themselves, are left as they are. + * + * @param array $choices The role key chosen for a glyph, or null for no tiles. + */ + private function drawTilesForLayerGlyphs(array $choices = []): void + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return; + } + $layerId = $this->getActiveCanvasLayer(); + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + $label = MapLayers::formatLabel((string) ($layer['name'] ?? $layerId)); + try { + $drawn = CanvasEditor::drawTilesForGlyphs($map, $layerId, $choices); + } catch (MapSourceRefusal | InvalidArgumentException | RuntimeException $refusal) { + $this->setStatus('Tiles were not drawn: ' . $refusal->getMessage(), StatusLevel::WARN); + $this->renderFooter(); + return; + } + if ($drawn === null) { + $this->setStatus(sprintf('The %s layer has no pieces in this map\'s kind to draw tiles for.', $label), StatusLevel::WARN); + $this->renderFooter(); + return; + } + if ($drawn['unresolved'] !== []) { + $glyph = (string) array_key_first($drawn['unresolved']); + $this->askForGlyphPiece($glyph, $drawn['unresolved'][$glyph], $choices, + fn(array $chosen) => $this->drawTilesForLayerGlyphs($chosen)); + return; + } + if ($drawn['command'] === null) { + $this->setStatus(sprintf('Every glyph on the %s layer already has its tiles.', $label)); + $this->renderFooter(); + return; + } + $this->recordCommand($drawn['command']); + $cells = $drawn['cells']; + $this->setStatus(sprintf('Drew %d tile%s for the %s layer\'s glyphs; save to keep them.', $cells, $cells === 1 ? '' : 's', $label), + StatusLevel::INFO); + $this->renderCanvasArea(); + } + + /** + * Asks which piece a glyph is, listing its roles and No tiles, with the + * brush's last answer for it first in line. The edit runs again on an + * answer; Esc drops it. + * + * @param list $roles + * @param array $choices The answers the edit already has. + * @param Closure(array): void $retry + */ + private function askForGlyphPiece(string $glyph, array $roles, array $choices, Closure $retry): void + { + $this->finalizeActiveStroke(); + $this->optionDialogField = ['glyphPiece' => ['glyph' => $glyph, 'choices' => $choices, 'retry' => $retry]]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = "Piece for {$glyph}"; + $this->eventOptionDialogEntries = [ + ...array_map(static fn(PieceRole $role): array => [ + 'label' => $role->label, + 'value' => $role->key, + 'description' => implode(' · ', array_map( + static fn(string $layer, array $cells): string => $layer . ' ' . implode(' ', array_column($cells, 'entry')), + array_keys($role->tiles), + $role->tiles, + )) ?: 'no tiles', + ], $roles), + ['label' => 'No tiles', 'value' => '', 'description' => 'the glyph alone'], + ]; + $remembered = ($this->paintPieceRole['symbol'] ?? null) === $glyph ? ($this->paintPieceRole['role'] ?? '') : ''; + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($remembered); + $this->isEventOptionDialogOpen = true; + $this->setStatus(sprintf('%s could be %d pieces. Choose the one it draws; Esc leaves the map as it was.', $glyph, count($roles))); + $this->renderSelectionDependentArea(); + } + + /** + * Runs the waiting edit again with the author's answer for its glyph. + * + * @param array{glyph: string, choices: array, retry: Closure(array): void} $pending + */ + private function chooseGlyphPiece(array $pending, string $value): void + { + $this->closeEventOptionDialog(); + ($pending['retry'])([...$pending['choices'], $pending['glyph'] => $value === '' ? null : $value]); + } + + /** + * The brush's answer for its glyph, as edit choices. + * + * @return array + */ + private function getPaintPieceChoices(string $symbol): array + { + return ($this->paintPieceRole['symbol'] ?? null) === $symbol ? [$symbol => $this->paintPieceRole['role']] : []; + } + + /** + * Keeps the answer an edit got for the brush's glyph, so the brush draws + * that piece until a glyph is typed again. + * + * @param array $choices + */ + private function rememberPaintPieceRole(string $symbol, array $choices): void + { + if (array_key_exists($symbol, $choices)) { + $this->paintPieceRole = ['symbol' => $symbol, 'role' => $choices[$symbol]]; + } + } + + /** + * Makes the brush draw the piece role the glyph in a cell plays, as the + * eyedropper picks it up, and names it; an empty string when its tiles + * show no role. + */ + private function pickPaintPieceRole(ProjectMap $map, int $x, int $y): string + { + $this->paintPieceRole = null; + $layerId = $this->getActiveCanvasLayer(); + $role = CanvasEditor::findPlayedRole($map, $layerId, $x, $y); + if ($role === null) { + return ''; + } + $this->paintPieceRole = ['symbol' => $map->getLayerSymbol($layerId, $x, $y), 'role' => $role->key]; + + return $role->label; + } +} diff --git a/src/Canvas/GlyphTilePlanner.php b/src/Canvas/GlyphTilePlanner.php new file mode 100644 index 00000000..9c4a0df8 --- /dev/null +++ b/src/Canvas/GlyphTilePlanner.php @@ -0,0 +1,359 @@ +> $roles Roles by glyph, distinct by the tiles they draw and keep. + * @param array $twins The kept role for each key of a role that draws and keeps the same tiles with the same glyph. + */ + private function __construct(private array $roles, private array $twins = []) {} + + /** + * @param iterable $pieces The map's tileset pieces. + * @param string $layerName The gameplay layer the glyphs are on. + * @param list $excludedLayers Tile layers another source already sets. + */ + public static function fromPieces(iterable $pieces, string $layerName, array $excludedLayers = []): self + { + $roles = $twins = []; + foreach ($pieces as $piece) { + if ($piece->layer !== $layerName) { + continue; + } + foreach (PieceRole::readPiece($piece, $excludedLayers) as $glyph => $pieceRoles) { + foreach ($pieceRoles as $role) { + $kept = $roles[(string) $glyph][self::getSignature($role)] ??= $role; + if ($kept !== $role) { + $twins[$role->key] = $kept; + } + } + } + } + + return new self(array_map(array_values(...), $roles), $twins); + } + + /** @return array> Every role the layer's glyphs can play, keyed by glyph. */ + public function listRoles(): array + { + return $this->roles; + } + + /** @return list The roles a glyph can play, one for each drawing and keeping behaviour. */ + public function getRoles(string $glyph): array + { + return $this->roles[$glyph] ?? []; + } + + /** + * The role with this key that a glyph can play, or null when it has none. + * A role kept for another with the same drawing and keeping behaviour answers for its key. + */ + public function findRole(string $glyph, string $key): ?PieceRole + { + $twin = $this->twins[$key] ?? null; + foreach ($this->getRoles($glyph) as $role) { + if ($role->key === $key || $role === $twin) { + return $role; + } + } + + return null; + } + + /** + * The role the glyph in a cell plays now, read from the tiles there: the + * one whose own tiles are all in place and the most of whose tiles are, + * or null when its tiles do not show one. + * + * @param Closure(string, int, int): string $tileAt The entry at a cell of a tile layer. + */ + public function findPlayedRole(string $glyph, int $x, int $y, Closure $tileAt): ?PieceRole + { + $best = null; + foreach ($this->getRoles($glyph) as $role) { + $own = $role->getOwnEntries(); + if ($own === [] || array_filter($own, static fn(string $entry, string $layer): bool => + $tileAt($layer, $x, $y) !== $entry, ARRAY_FILTER_USE_BOTH) !== []) { + continue; + } + $matched = 0; + foreach ($role->tiles as $layer => $cells) { + foreach ($cells as $cell) { + $matched += $tileAt($layer, $x + $cell['dx'], $y + $cell['dy']) === $cell['entry'] ? 1 : 0; + } + } + if ($best === null || $matched > $best[1]) { + $best = [$role, $matched]; + } + } + + return $best[0] ?? null; + } + + /** + * Plans the tile writes that keep the glyphs' tiles consistent with a set + * of glyph changes. A change whose glyph stays the same is planned only + * when `$repaint` says the author painted it again on purpose, which can + * give it another role. + * + * @param list $changes Glyph changes, by cell. + * @param Closure(int, int): ?string $glyphAt The layer's glyph at a cell before the changes, or null off the map. + * @param Closure(string, int, int): string $tileAt The entry at a cell of a tile layer before the changes. + * @param array $choices The role key the author chose for a glyph, or null for no tiles. + * @param array $assigned The role key a cell's glyph plays, by `x,y`, when the edit already knows + * it, such as a tile edit that placed that role's tile. It outranks proof and choice, and plans the cell even + * when its glyph stays the same, so a role whose tiles had gone missing gets them back. + * @return array{tiles: array>, unresolved: array>, glyphs: list} + * The tile cells to write, keyed by tile layer name; the roles of each glyph that still needs a choice; and + * the cells an erase gives back to the role their kept underlay proves ({@see findUnderlayRole()}), which + * the edit writes instead of a blank. + */ + public function plan(array $changes, Closure $glyphAt, Closure $tileAt, array $choices = [], bool $repaint = false, + array $assigned = []): array + { + $changes = array_values(array_filter($changes, static fn(array $change): bool => $repaint || $change['old'] !== $change['new'] + || isset($assigned["{$change['x']},{$change['y']}"]))); + $unresolved = []; + $glyphs = []; + $underlay = []; + foreach ($changes as $index => $change) { + if ($change['new'] === ' ' && ($found = $this->findUnderlayRole($change, $tileAt, $choices, $unresolved)) !== null) { + [$changes[$index]['new'], $underlay[$index]] = $found; + $glyphs[] = ['x' => $change['x'], 'y' => $change['y'], 'symbol' => $found[0], 'role' => $found[1]]; + } + } + $changed = []; + foreach ($changes as $change) { + $changed["{$change['x']},{$change['y']}"] = $change['new']; + } + $overlay = []; + $resolved = []; + $kept = []; + + foreach ($changes as $index => $change) { + $key = $assigned["{$change['x']},{$change['y']}"] ?? null; + $resolved[$index] = match (true) { + isset($underlay[$index]) => $underlay[$index], + $change['new'] === ' ' => null, + $key !== null => $this->findRole($change['new'], $key), + default => $this->resolveRole($change, $glyphAt, $tileAt, $changed, $choices, $unresolved), + }; + foreach ($resolved[$index]?->keeps ?? [] as $layer => $cells) { + foreach ($cells as $cell) { + [$x, $y] = [$change['x'] + $cell['dx'], $change['y'] + $cell['dy']]; + if ($glyphAt($x, $y) !== null) { + $kept[$layer]["{$x},{$y}"] = true; + } + } + } + } + // Every leaving role takes its tiles before any arriving role draws, + // except existing underlay kept by any arriving role, including its blank cells. + foreach ($changes as $index => $change) { + $leaving = $this->findPlayedRole($change['old'], $change['x'], $change['y'], $tileAt); + if ($leaving === null || ($resolved[$index] ?? null) === $leaving) { + continue; + } + foreach ($leaving->tiles as $layer => $cells) { + foreach ($cells as $cell) { + [$x, $y] = [$change['x'] + $cell['dx'], $change['y'] + $cell['dy']]; + if (! isset($kept[$layer]["{$x},{$y}"]) && $glyphAt($x, $y) !== null + && ($overlay[$layer]["{$x},{$y}"] ?? $tileAt($layer, $x, $y)) === $cell['entry']) { + $overlay[$layer]["{$x},{$y}"] = (string) TileId::EMPTY; + } + } + } + } + foreach ($changes as $index => $change) { + foreach (($resolved[$index] ?? null)?->tiles ?? [] as $layer => $cells) { + foreach ($cells as $cell) { + [$x, $y] = [$change['x'] + $cell['dx'], $change['y'] + $cell['dy']]; + if ($glyphAt($x, $y) !== null) { + $overlay[$layer]["{$x},{$y}"] = $cell['entry']; + } + } + } + } + + $tiles = []; + foreach ($overlay as $layer => $cells) { + foreach ($cells as $position => $entry) { + [$x, $y] = array_map(intval(...), explode(',', (string) $position)); + if ($tileAt((string) $layer, $x, $y) !== $entry) { + $tiles[$layer][] = ['x' => $x, 'y' => $y, 'entry' => $entry]; + } + } + } + + return ['tiles' => $tiles, 'unresolved' => $unresolved, 'glyphs' => $glyphs]; + } + + /** + * The role an erased cell goes back to: when the role leaving it keeps a + * layer's existing tiles there, the role those kept tiles alone prove, + * one whose own tiles at the cell are all present and all on kept layers + * (the wall face a window was mounted on). Erasing gives the cell that + * role's glyph, so its terminal and collision role returns with the tiles + * that never left. Roles of another glyph proven the same way make the + * erase wait for the author's choice, keyed by the blank glyph; choosing + * none leaves the cell blank. Null when nothing is kept or nothing proven. + * + * @param array{x: int, y: int, old: string, new: string} $change + * @param array $choices + * @param array> $unresolved + * @return array{0: string, 1: PieceRole}|null The glyph and role the cell takes. + */ + private function findUnderlayRole(array $change, Closure $tileAt, array $choices, array &$unresolved): ?array + { + [$x, $y] = [$change['x'], $change['y']]; + $leaving = $this->findPlayedRole($change['old'], $x, $y, $tileAt); + $keptLayers = []; + foreach ($leaving?->keeps ?? [] as $layer => $cells) { + if (array_any($cells, static fn(array $cell): bool => $cell['dx'] === 0 && $cell['dy'] === 0)) { + $keptLayers[$layer] = true; + } + } + if ($leaving === null || $keptLayers === []) { + return null; + } + $proven = []; + foreach (array_keys($this->roles) as $glyph) { + $role = $this->findPlayedRole((string) $glyph, $x, $y, $tileAt); + if ($role !== null && $role->pieceId !== $leaving->pieceId && array_diff_key($role->getOwnEntries(), $keptLayers) === []) { + $proven[(string) $glyph] = $role; + } + } + if (count($proven) < 2) { + return $proven === [] ? null : [(string) array_key_first($proven), reset($proven)]; + } + if (array_key_exists(' ', $choices)) { + foreach ($proven as $glyph => $role) { + if ($choices[' '] !== null && $this->findRole($glyph, $choices[' ']) === $role) { + return [$glyph, $role]; + } + } + + return null; + } + $waiting = []; + foreach ([...($unresolved[' '] ?? []), ...array_values($proven)] as $role) { + $waiting[$role->key] = $role; + } + $unresolved[' '] = array_values($waiting); + + return null; + } + + /** + * The role an arriving glyph plays: its only one; the one its neighbours + * outside this edit prove, by holding the rest of that role's piece; or + * the author's choice. Null draws no tiles, and a glyph with neither + * proof nor choice is noted as unresolved. + * + * @param array{x: int, y: int, old: string, new: string} $change + * @param array $changed The glyph each cell of the edit gets, by `x,y`. + * @param array $choices + * @param array> $unresolved + */ + private function resolveRole(array $change, Closure $glyphAt, Closure $tileAt, array $changed, array $choices, array &$unresolved): ?PieceRole + { + $roles = $this->getRoles($change['new']); + if (count($roles) < 2) { + return $roles[0] ?? null; + } + $proven = []; + foreach ($roles as $role) { + $proof = $this->countNeighbourProof($role, $change['x'], $change['y'], $glyphAt, $tileAt, $changed); + if ($proof > 0) { + $proven[$proof][] = $role; + } + } + if ($proven !== []) { + krsort($proven); + $strongest = reset($proven); + if (count($strongest) === 1) { + return $strongest[0]; + } + } + if (array_key_exists($change['new'], $choices)) { + return $choices[$change['new']] === null ? null : $this->findRole($change['new'], $choices[$change['new']]); + } + $unresolved[$change['new']] = $roles; + + return null; + } + + /** + * How many of the other glyph cells of a role's piece its neighbours + * outside the edit hold, each with its glyph and its own tiles. + * + * @param array $changed + */ + private function countNeighbourProof(PieceRole $role, int $x, int $y, Closure $glyphAt, Closure $tileAt, array $changed): int + { + $proof = 0; + foreach ($this->roles as $glyph => $roles) { + foreach ($roles as $other) { + $offset = self::getOffset($role, $other); + if ($offset === null) { + continue; + } + [$otherX, $otherY] = [$x + $offset[0], $y + $offset[1]]; + if (isset($changed["{$otherX},{$otherY}"]) || $glyphAt($otherX, $otherY) !== (string) $glyph) { + continue; + } + $own = $other->getOwnEntries(); + if ($own !== [] && array_filter($own, static fn(string $entry, string $layer): bool => + $tileAt($layer, $otherX, $otherY) !== $entry, ARRAY_FILTER_USE_BOTH) === []) { + $proof++; + } + } + } + + return $proof; + } + + /** + * Where another cell of the same item piece lies from this role's cell, + * or null when the two roles are not cells of one item piece. + * + * @return array{int, int}|null Columns across and rows down. + */ + private static function getOffset(PieceRole $role, PieceRole $other): ?array + { + if ($role->cell === null || $other->cell === null || $role->pieceId !== $other->pieceId || $role->key === $other->key) { + return null; + } + + return [$other->cell[1] - $role->cell[1], $other->cell[0] - $role->cell[0]]; + } + + /** What a role draws and keeps, so only behaviourally identical roles count once. */ + private static function getSignature(PieceRole $role): string + { + $tiles = $role->tiles; + $keeps = $role->keeps; + ksort($tiles); + ksort($keeps); + + return json_encode([$tiles, $keeps], JSON_THROW_ON_ERROR); + } +} diff --git a/src/Canvas/LayerCanvas.php b/src/Canvas/LayerCanvas.php new file mode 100644 index 00000000..7289c127 --- /dev/null +++ b/src/Canvas/LayerCanvas.php @@ -0,0 +1,337 @@ +getSelectedMap()?->getLayers() ?? [], + static fn(array $layer): bool => ! $layer['decoration'])); + } + + private function getCanvasLayerState(): array + { + $map = $this->getSelectedMap(); + $state = $this->canvasLayerStates[$map?->mapId ?? ''] ?? []; + return $state + ['selected' => $map?->getBaseLayerId() ?? MapLayers::BASE, + 'visibility' => [], 'dim' => false]; + } + + private function getCanvasLayerTitle(): string + { + $map = $this->getSelectedMap(); + if ($map === null || $map->isLegacyMap()) { + return ''; + } + if ($this->editingMode === self::MODE_NPC) { + return ' [NPCs]'; + } + if ($this->editingMode === self::MODE_AREA) { + return ' [Areas]'; + } + foreach ($this->getTerminalCanvasLayers() as $layer) { + if ($layer['id'] === $this->getActiveCanvasLayer()) { + return ' [' . MapLayers::formatLabel($layer['name']) . ']'; + } + } + return ''; + } + + private function setCanvasLayerState(string $key, mixed $value): void + { + $this->canvasLayerStates[$this->getSelectedMap()?->mapId ?? ''][$key] = $value; + $this->renderCanvasArea(); + } + + private function selectCanvasLayer(string $id): void + { + if (! in_array($id, array_column($this->getTerminalCanvasLayers(), 'id'), true)) { + $this->setStatus('Graphical layers are preserved separately and are not editable in the terminal canvas.', StatusLevel::WARN); + return; + } + $this->finalizeActiveStroke(); + $this->activeMousePaintButton = null; + $this->lastMousePaintPoint = null; + $this->canvasToolAnchor = null; + $this->canvasSelection = null; + $this->facadeBrush = null; + $this->piecePlacement = null; + $this->setCanvasLayerState('selected', $id); + $this->setEditingMode($id === MapLayers::EVENT ? self::MODE_EVENT : self::MODE_MAP); + } + + /** + * Opens the layer picker: the map's gameplay layers, Events and NPCs, + * filterable by name, with what the canvas edits selected. Enter edits + * the highlighted layer, or enters NPC mode for NPCs, which are placed on + * the map like a layer though they are stored as the map's NPC list. + */ + private function openCanvasLayerPicker(): void + { + $layers = $this->getTerminalCanvasLayers(); + if ($layers === []) { + return; + } + $visibility = $this->getCanvasLayerState()['visibility']; + $active = $this->getActiveCanvasLayer(); + $entries = array_map(fn(array $layer): array => [ + 'label' => MapLayers::formatLabel($layer['name']), + 'value' => $layer['id'], + 'description' => implode(' · ', array_filter([ + $layer['id'] === MapLayers::EVENT ? 'event markers' : 'gameplay', + ($visibility[$layer['id']] ?? true) ? 'visible' : 'hidden', + $layer['id'] === $active ? 'editing' : null, + ])), + ], $layers); + $npcs = $this->getSelectedMap()?->getNpcs()->count() ?? 0; + $entries[] = [ + 'label' => 'NPCs', + 'value' => self::NPC_LAYER_ENTRY, + 'description' => implode(' · ', array_filter([ + sprintf('%d placed', $npcs), + $this->editingMode === self::MODE_NPC ? 'editing' : null, + ])), + ]; + if ($this->editingMode === self::MODE_NPC) { + $active = self::NPC_LAYER_ENTRY; + } + $this->finalizeActiveStroke(); + $this->optionDialogField = ['canvasLayer' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Layer'; + $this->eventOptionDialogEntries = $entries; + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($active); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Choose the layer to edit.'; + $this->renderSelectionDependentArea(); + } + + private function cycleCanvasLayer(int $step = 1): void + { + $ids = array_column($this->getTerminalCanvasLayers(), 'id'); + if ($ids === []) { + return; + } + $index = (int) array_search($this->getActiveCanvasLayer(), $ids, true); + $this->selectCanvasLayer($ids[($index + $step + count($ids)) % count($ids)]); + } + + private function toggleCanvasLayerVisibility(?string $id = null): void + { + $id ??= $this->getActiveCanvasLayer(); + if (! in_array($id, array_column($this->getTerminalCanvasLayers(), 'id'), true)) { + return; + } + $visibility = $this->getCanvasLayerState()['visibility']; + $visibility[$id] = ! ($visibility[$id] ?? true); + $this->setCanvasLayerState('visibility', $visibility); + } + + private function toggleCanvasLayerOption(string $option): void + { + if ($option !== 'dim') { + return; + } + $this->finalizeActiveStroke(); + $this->inputMode = self::INPUT_NORMAL; + $this->setCanvasLayerState($option, ! $this->getCanvasLayerState()[$option]); + } + + private function buildLayerPaletteItems(): array + { + $map = $this->getSelectedMap(); + if ($map === null || $map->getGridSourceIssue() !== null) { + return []; + } + // Choosing a layer has its own picker (L); the palette keeps one entry + // for it instead of a Layer and a Visibility entry per layer. + $items = [ + new PaletteItem('Layers: Choose the layer to edit', 'L', fn() => $this->openCanvasLayerPicker()), + new PaletteItem('Layers: Create gameplay layer', '', fn() => $this->openLayerPrompt('create')), + new PaletteItem('Layers: Rename selected layer', '', fn() => $this->openLayerPrompt('rename')), + new PaletteItem('Layers: Remove selected layer', '', fn() => $this->openLayerPrompt('remove')), + new PaletteItem('Layers: Dim inactive layers', 'd', fn() => $this->toggleCanvasLayerOption('dim')), + ]; + foreach (FacadeCatalogue::discover($this->workspace->projectRoot) as $path) { + try { + foreach (FacadeCatalogue::load($path) as $index => $rows) { + $items[] = new PaletteItem(sprintf('Facade: %s #%d (%dx%d)', basename($path), $index + 1, + max(array_map(count(...), $rows)), count($rows)), '', fn() => $this->selectFacadeBrush($path, $index)); + } + } catch (\Throwable $error) { + $items[] = new PaletteItem('Facade: ' . basename($path) . ' (unreadable)', '', fn() => $this->setStatus($error->getMessage(), StatusLevel::WARN)); + } + } + return $items; + } + + private function openLayerPrompt(string $action): void + { + $map = $this->getSelectedMap(); + if ($map === null || ! in_array($action, ['create', 'rename', 'remove'], true)) { + return; + } + $this->finalizeActiveStroke(); + $id = $this->getActiveCanvasLayer(); + $layer = array_values(array_filter($this->getTerminalCanvasLayers(), static fn(array $layer): bool => $layer['id'] === $id))[0] ?? null; + $this->layerPrompt = ['action' => $action, 'id' => $id, 'name' => $action === 'rename' ? ($layer['name'] ?? '') : '']; + $this->modals->push(Modal::LAYER_EDIT); + $this->requestFullRender(); + } + + private function handleLayerPromptInput(string $input): void + { + if ($input === "\033") { + $this->layerPrompt = null; + $this->modals->remove(Modal::LAYER_EDIT); + $this->requestFullRender(); + return; + } + $prompt = $this->layerPrompt; + if ($prompt === null) { + return; + } + if (! in_array($prompt['action'], ['create', 'rename', 'remove'], true)) { + $this->setStatus('Only gameplay layers can be managed in the terminal canvas.', StatusLevel::WARN); + return; + } + if (($input === "\r" || $input === "\n") && $prompt['action'] !== 'remove' && ! isset($prompt['confirmation']) + || ($input === 'y' && ($prompt['action'] === 'remove' || isset($prompt['confirmation'])))) { + $map = $this->getSelectedMap(); + try { + if ($map === null || ! in_array($prompt['id'], array_column($this->getTerminalCanvasLayers(), 'id'), true)) { + throw new MapSourceRefusal('This layer is not available in the terminal canvas. Cancel and select a gameplay layer.'); + } + // Removal is confirmed by its own prompt; a rename that + // changes collisions asks again before anything changes. + $edit = match ($prompt['action']) { + 'create' => LayerEditor::createLayer($map, $prompt['name']), + 'rename' => LayerEditor::renameLayer($map, $prompt['id'], $prompt['name'], isset($prompt['confirmation'])), + 'remove' => LayerEditor::removeLayer($map, $prompt['id'], true), + }; + if ($edit['question'] !== null) { + $this->layerPrompt['confirmation'] = $edit['question']; + $this->requestFullRender(); + return; + } + if ($edit['command'] !== null) { + $this->recordCommand($edit['command']); + } + $this->selectCanvasLayer((string) $edit['layer']); + $this->layerPrompt = null; + $this->modals->remove(Modal::LAYER_EDIT); + $this->setStatus('Layer change staged. Ctrl+S saves the complete file set.'); + } catch (\Throwable $error) { + $this->setStatus($error->getMessage(), StatusLevel::WARN); + } + } elseif (isset($prompt['confirmation'])) { + return; + } elseif ($input === "\177" || $input === "\010") { + $this->layerPrompt['name'] = mb_substr($prompt['name'], 0, -1); + } elseif (preg_match('/\A[a-zA-Z0-9_-]\z/', $input) === 1) { + $this->layerPrompt['name'] .= $input; + } + $this->requestFullRender(); + } + + private function renderLayerPrompt(array $layout): void + { + $width = min(68, $layout['width'] - 4); + $remove = $this->layerPrompt['action'] === 'remove'; + $confirmation = $this->layerPrompt['confirmation'] ?? null; + $message = $confirmation ?? ($remove ? 'Remove this layer and its painted cells?' : 'Name: ' . $this->layerPrompt['name']); + $lines = [...explode("\n", wordwrap($message, max(1, $this->getWindowContentWidth($width)), "\n", true)), + 'Rename keeps its numeric order. Undo restores the layer.', + 'New layers use the next free order; legacy terrain becomes 00.', + 'Collision lookup uses the layer name, not its order.']; + new EditorWindow( + title: 'Layer: ' . $this->layerPrompt['action'], + help: $confirmation !== null ? 'Y:Confirm collision change Esc:Cancel' : ($remove ? 'Y:Remove Esc:Cancel' : 'Enter:Apply Esc:Cancel'), + position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 6], + width: $width, height: count($lines) + 2, + content: $this->fitLines($lines, $this->getWindowContentWidth($width), count($lines)), + )->render(); + } + + private function selectFacadeBrush(string $path, int $index): void + { + foreach ($this->getTerminalCanvasLayers() as $layer) { + if ($layer['name'] === 'buildings') { + $this->selectCanvasLayer($layer['id']); + $this->facadeBrush = ['path' => $path, 'index' => $index, 'mapId' => $this->getSelectedMap()->mapId]; + $this->canvasTool = CanvasTool::BRUSH; + $this->inputMode = self::INPUT_NORMAL; + $this->focusedPane = self::FOCUS_CANVAS; + $this->setStatus('Facade brush selected. Enter stamps; i enables mouse painting; b returns to glyphs.'); + return; + } + } + $this->setStatus('Create a gameplay buildings layer before selecting a facade brush.', StatusLevel::WARN); + } + + /** @param array $choices The pieces the author chose for glyphs the facade could draw several ways. */ + private function stampFacadeBrush(array $choices = []): void + { + if ($this->facadeBrush === null) { + return; + } + $map = $this->getSelectedMap(); + $target = array_values(array_filter($this->getTerminalCanvasLayers(), + fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0] ?? null; + if ($map?->mapId !== $this->facadeBrush['mapId'] || $this->editingMode !== self::MODE_MAP + || ($target['name'] ?? null) !== 'buildings' || ($target['decoration'] ?? true)) { + $this->facadeBrush = null; + $this->setStatus('Select the facade brush again on a gameplay buildings layer.', StatusLevel::WARN); + return; + } + try { + $rows = FacadeCatalogue::load($this->facadeBrush['path'])[$this->facadeBrush['index']] ?? null; + if ($rows === null) { + throw new \RuntimeException('This facade no longer exists in its catalogue. Select it again.'); + } + $writes = []; + foreach ($rows as $y => $row) { + foreach ($row as $x => $cell) { + $writes[] = ['x' => $this->cursorX + $x, 'y' => $this->cursorY + $y, + 'symbol' => $cell['symbol'], 'style' => ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']]]; + } + } + $this->applyCanvasWrites($map, $writes, 'Facade stamp', $this->stampFacadeBrush(...), $choices); + $this->renderCanvasArea(); + } catch (\Throwable $error) { + $this->setStatus($error->getMessage(), StatusLevel::WARN); + } + } + + private function getLayerInspectorFields(): array + { + $map = $this->getSelectedMap(); + if ($map === null) { + return []; + } + $id = $this->getActiveCanvasLayer(); + $fields = []; + foreach ($this->getTerminalCanvasLayers() as $layer) { + $fields[] = ['label' => ($id === $layer['id'] ? '> ' : ' ') . MapLayers::formatLabel($layer['name']), + 'value' => ($this->getCanvasLayerState()['visibility'][$layer['id']] ?? true) ? 'visible' : 'hidden', 'editable' => false]; + } + return $fields; + } +} diff --git a/src/Canvas/MapLineCanvas.php b/src/Canvas/MapLineCanvas.php new file mode 100644 index 00000000..65571116 --- /dev/null +++ b/src/Canvas/MapLineCanvas.php @@ -0,0 +1,218 @@ +getSelectedMap(); + if ($map === null || $map->getGridSourceIssue() !== null || $this->editingMode !== self::MODE_MAP) { + return []; + } + + return [ + new PaletteItem('Map: Insert rows above the cursor', '', fn() => $this->openMapLinePrompt('y', false)), + new PaletteItem('Map: Insert columns left of the cursor', '', fn() => $this->openMapLinePrompt('x', false)), + new PaletteItem('Map: Remove rows from the cursor down', '', fn() => $this->openMapLinePrompt('y', true)), + new PaletteItem('Map: Remove columns from the cursor rightward', '', fn() => $this->openMapLinePrompt('x', true)), + ]; + } + + private function openMapLinePrompt(string $axis, bool $remove): void + { + $map = $this->getSelectedMap(); + if ($map === null || ! $this->canEditMapLines($remove)) { + return; + } + $this->finalizeActiveStroke(); + $this->mapLinePrompt = ['axis' => $axis, 'at' => $axis === 'y' ? $this->cursorY : $this->cursorX, 'mapId' => $map->mapId, 'remove' => $remove]; + $this->mapLineCount = new TextFieldEditor(); + $this->mapLineCount->open('1'); + $this->modals->push(Modal::MAP_LINES); + $this->requestFullRender(); + } + + /** Refuses while other edits are unsaved: the edit writes now, and undo restores files. */ + private function canEditMapLines(bool $remove): bool + { + if (! $this->workspace instanceof ProjectWorkspace) { + return false; + } + if ($this->workspace->hasUnsavedChanges()) { + $this->setStatus(sprintf('Save or undo pending edits before %s rows or columns. No files were changed.', $remove ? 'removing' : 'inserting'), StatusLevel::WARN); + return false; + } + + return true; + } + + private function closeMapLinePrompt(): void + { + $this->mapLinePrompt = null; + $this->mapLineCount = null; + $this->modals->remove(Modal::MAP_LINES); + $this->requestFullRender(); + } + + private function handleMapLinePromptInput(string $input): void + { + $prompt = $this->mapLinePrompt; + if ($prompt === null || $this->mapLineCount === null) { + $this->closeMapLinePrompt(); + return; + } + $control = new InputControl(InputControlType::INTEGER, $this->mapLineCount->value); + switch ($this->mapLineCount->handleKey($input, $control)) { + case TextFieldKeyResult::CANCELLED: + $this->closeMapLinePrompt(); + $this->setStatus(sprintf('%s cancelled. No files were changed.', $prompt['remove'] ? 'Removal' : 'Insertion')); + return; + case TextFieldKeyResult::SUBMITTED: + $count = trim($this->mapLineCount->value); + if (! ctype_digit($count) || (int) $count < 1) { + $this->setStatus('Enter a whole number of at least 1.', StatusLevel::WARN); + return; + } + $this->closeMapLinePrompt(); + $this->openMapLineConfirmation($prompt['axis'], $prompt['at'], (int) $count, $prompt['remove']); + return; + default: + $this->requestFullRender(); + } + } + + private function renderMapLinePrompt(array $layout): void + { + $prompt = $this->mapLinePrompt; + if ($prompt === null || $this->mapLineCount === null) { + return; + } + $width = min(68, $layout['width'] - 4); + $rows = $prompt['axis'] === 'y'; + $line = sprintf('%s %d', $rows ? 'row' : 'column', $prompt['at'] + 1); + $lines = $prompt['remove'] + ? [ + sprintf('%s to remove from %s %s: %s', $rows ? 'Rows' : 'Columns', $line, $rows ? 'down' : 'rightward', $this->mapLineCount->value), + sprintf('Everything beyond them moves %s and the map shrinks.', $rows ? 'up' : 'left'), + 'Anything authored on a removed line refuses the removal.', + 'The next step lists every file before anything is written.', + ] + : [ + sprintf('%s to insert %s %s: %s', $rows ? 'Rows' : 'Columns', $rows ? 'above' : 'left of', $line, $this->mapLineCount->value), + sprintf('Everything from %s %s moves and the map grows.', $line, $rows ? 'down' : 'right'), + 'Spawn points, scripts and saves that name this map follow.', + 'The next step lists every file before anything is written.', + ]; + new EditorWindow( + title: ($prompt['remove'] ? 'Remove ' : 'Insert ') . ($rows ? 'rows' : 'columns'), + help: 'Enter:Plan Up/Down:Count Esc:Cancel', + position: ['x' => max(2, intdiv($layout['width'] - $width, 2)), 'y' => 6], + width: $width, height: count($lines) + 2, + content: $this->fitLines($lines, $this->getWindowContentWidth($width), count($lines)), + )->render(); + } + + /** + * Plans the edit and lists what it writes, and what it could not + * rewrite, for confirmation. Nothing is written yet. + */ + private function openMapLineConfirmation(string $axis, int $at, int $count, bool $remove): void + { + $map = $this->getSelectedMap(); + if ($map === null || ! $this->canEditMapLines($remove)) { + return; + } + try { + $plan = $remove + ? MapLinePlanner::planRemoval($this->projectRoot, $map->mapId, $axis, $at, $count) + : MapLinePlanner::planInsertion($this->projectRoot, $map->mapId, $axis, $at, $count); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, $remove ? 'Removal' : 'Insertion'); + return; + } + $edit = $plan->edit; + $paths = array_map(fn(string $path): string => substr($path, strlen($this->projectRoot) + 1), $plan->getChangedPaths()); + $this->optionDialogField = ['mapLines' => $plan]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = $edit->verb . ' ' . $edit->description; + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave all files unchanged.'], + ['label' => sprintf('Write %d files now', count($paths)), 'value' => 'write', + 'description' => 'Writes now, not on Save. Ctrl+Z restores files.'], + ...array_map(static fn(string $path): array => [ + 'label' => $path, 'value' => 'preview', + 'description' => sprintf('Written by the %s. Select Write to apply all files.', $remove ? 'removal' : 'insertion'), + ], $paths), + ...array_map(static fn(array $edit): array => [ + 'label' => sprintf('Hand edit: %s %s', $edit['file'], $edit['where']), 'value' => 'preview', + 'description' => sprintf('Not rewritten: %s. Move it by hand after writing.', $edit['reason']), + ], $plan->handEdits), + ...array_map(static fn(string $note): array => [ + 'label' => $note, 'value' => 'preview', 'description' => $note, + ], $plan->notes), + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->setStatus(sprintf('Confirm %s %s. Writes now, not on Save. Ctrl+Z restores files.', $remove ? 'removing' : 'inserting', $edit->description)); + $this->renderSelectionDependentArea(); + } + + private function confirmMapLines(MapLinePlan $plan, bool $confirmed): void + { + $remove = $plan->edit instanceof LineRemoval; + if (! $confirmed || ! $this->workspace instanceof ProjectWorkspace) { + $this->closeEventOptionDialog(); + $this->setStatus(sprintf('%s cancelled. No files were changed.', $remove ? 'Removal' : 'Insertion')); + return; + } + try { + $command = new SourceSetCommand($plan->edit->verb . ' ' . $plan->edit->noun, $remove ? 'this removal' : 'this insertion', $plan->getSourceSet(), $this->workspace, + fn(): ?ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + $command->execute(); + $this->recordCommand($command); + $handEdits = count($plan->handEdits); + $this->closeEventOptionDialog(); + // The files are on disk now, so the result reads as saved. + $this->setStatus(sprintf('%s %s.%s Ctrl+Z restores the files.', $remove ? 'Removed' : 'Inserted', $plan->edit->description, + $handEdits === 0 ? '' : sprintf(' %d %s still need a hand edit.', $handEdits, $handEdits === 1 ? 'coordinate' : 'coordinates')), StatusLevel::SUCCESS); + $this->requestFullRender(); + } catch (Throwable $failure) { + $this->closeEventOptionDialog(''); + $this->setErrorStatus($failure, $remove ? 'Removal' : 'Insertion'); + } + } +} diff --git a/src/Canvas/PhysicalOccupancyEditor.php b/src/Canvas/PhysicalOccupancyEditor.php new file mode 100644 index 00000000..30e305e1 --- /dev/null +++ b/src/Canvas/PhysicalOccupancyEditor.php @@ -0,0 +1,140 @@ + */ + public static function getCollisionChoices(): array + { + return array_values(array_map(static fn(CollisionType $type): array => [ + 'value' => $type->value, + 'label' => ucwords(strtolower(str_replace('_', ' ', $type->name))), + ], array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH))); + } + + /** @return array{rows: int[][], types: list, issue: ?string} */ + public static function readOccupancy(ProjectMap $map): array + { + $types = self::getCollisionChoices(); + try { + if ($map->getGridSourceIssue() !== null) { + throw new MapSourceRefusal($map->getGridSourceIssue()); + } + return ['rows' => $map->getResolvedCollisionMap(), 'types' => $types, 'issue' => null]; + } catch (InvalidArgumentException | MapSourceRefusal $error) { + return ['rows' => [], 'types' => $types, 'issue' => $error->getMessage()]; + } + } + + /** + * Applies one already-validated stroke and returns its unrecorded history entry. + * History restores only occupancy, through the same source-preserving writer. + * + * @param list $cells + * @return array{changed: int, command: ?Command} + */ + public static function applyPaint(ProjectMap $map, array $cells, int $collision, string $label = 'Paint collision'): array + { + $type = CollisionType::tryFrom($collision); + if ($type === null || $type === CollisionType::PASS_THROUGH) { + throw new MapSourceRefusal('Choose a final CollisionType value. Nothing was changed.'); + } + $path = [MapPhysicalOccupancy::DATA_KEY]; + $before = $map->getMapDataField($path); + $changed = $map->paintPhysicalOccupancy($cells, $type); + $after = $map->getMapDataField($path); + + return ['changed' => $changed, 'command' => $changed === 0 ? null : new GenericCommand($label, + static fn() => $map->setMapDataField($path, $after), + static fn() => $map->setMapDataField($path, $before), + )]; + } + + /** @return list>|null */ + public static function exportRecipe(TilesetPiece $piece): ?array + { + return $piece->occupancy === null ? null : array_map(static fn(array $row): array => + array_map(static fn(?CollisionType $type): ?int => $type?->value, $row), $piece->occupancy); + } + + /** + * Preview and commit use exactly the same authored mask. Transparent mask + * cells never acquire collision from artwork or terminal glyphs. + * @return list + */ + public static function getPieceWrites(ProjectMap $map, TilesetPiece $piece, int $x, int $y): array + { + $map->assertSourcesUnchanged(); + if ($piece->occupancy === null) { + throw new MapSourceRefusal('This piece has no physical footprint. Define and save one in its tileset first. Nothing was changed.'); + } + if (! $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + throw new MapSourceRefusal('Physical occupancy must be explicitly migrated before stamping. Nothing was changed.'); + } + $rows = $map->getResolvedCollisionMap(); + if (! isset($rows[$y][$x])) { + throw new MapSourceRefusal("Physical origin {$x}, {$y} is outside the map. Nothing was changed."); + } + $writes = []; + foreach ($piece->occupancy as $dy => $row) { + foreach ($row as $dx => $type) { + if ($type === null) { + continue; + } + if (! isset($rows[$y + $dy][$x + $dx])) { + throw new MapSourceRefusal('The whole physical footprint must fit the map. Nothing was changed.'); + } + $writes[] = [$x + $dx, $y + $dy, $type]; + } + } + + return $writes; + } + + /** @return array{changed: int, command: ?Command} */ + public static function applyPiece(ProjectMap $map, TilesetPiece $piece, int $x, int $y): array + { + $writes = self::getPieceWrites($map, $piece, $x, $y); + $path = [MapPhysicalOccupancy::DATA_KEY]; + $before = $map->getMapDataField($path); + $changed = $map->writePhysicalOccupancy($writes); + $after = $map->getMapDataField($path); + + return ['changed' => $changed, 'command' => $changed === 0 ? null : new GenericCommand('Stamp physical footprint', + static fn() => $map->setMapDataField($path, $after), + static fn() => $map->setMapDataField($path, $before), + )]; + } + + /** @return list */ + public static function getFillRegion(ProjectMap $map, int $x, int $y): array + { + $map->assertSourcesUnchanged(); + $occupancy = self::readOccupancy($map); + if ($occupancy['issue'] !== null) { + throw new MapSourceRefusal($occupancy['issue']); + } + $rows = $occupancy['rows']; + if (! isset($rows[$y][$x])) { + throw new MapSourceRefusal("Physical cell {$x}, {$y} is outside the map."); + } + // A missing ragged cell has no collision identity, so fill cannot cross it. + $identityAt = static fn(int $x, int $y): string => isset($rows[$y][$x]) ? (string) $rows[$y][$x] : 'missing'; + + return array_map(static fn(array $cell): array => [$cell['x'], $cell['y']], + ToolGeometry::floodFill($identityAt, $map->getWidth(), $map->getHeight(), $x, $y)); + } +} diff --git a/src/Canvas/PieceCanvas.php b/src/Canvas/PieceCanvas.php new file mode 100644 index 00000000..473feed5 --- /dev/null +++ b/src/Canvas/PieceCanvas.php @@ -0,0 +1,463 @@ +getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return; + } + $pieces = $this->loadCanvasPieces($map); + if ($pieces === null) { + return; + } + $layer = $this->findPieceCanvasLayer($map); + $offered = array_filter($pieces, static fn(TilesetPiece $piece): bool => $piece->layer === ($layer['name'] ?? null)); + if ($offered === []) { + $layers = array_values(array_unique(array_map(static fn(TilesetPiece $piece): string => MapLayers::formatLabel($piece->layer), $pieces))); + $this->setStatus(sprintf('%s has no pieces for the %s layer. It has pieces for %s; press L to switch layers.', + $map->loadTileset()?->name ?? 'The tileset', $layer === null ? 'current' : MapLayers::formatLabel($layer['name']), + implode(', ', $layers)), StatusLevel::WARN); + $this->renderFooter(); + return; + } + $this->finalizeActiveStroke(); + $this->optionDialogField = ['canvasPiece' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = sprintf('%s piece', MapLayers::formatLabel($layer['name'])); + $this->eventOptionDialogEntries = array_map(static function (TilesetPiece $piece): array { + $tileLayers = array_keys($piece->connects === null ? $piece->tiles : $piece->shapeTiles); + return [ + 'label' => $piece->name, + 'value' => $piece->id, + 'description' => implode(' · ', array_filter([ + $piece->connects === null ? sprintf('%d x %d', $piece->width, $piece->height) : 'connected', + $tileLayers === [] ? null : 'tiles: ' . implode(', ', $tileLayers), + ])), + ]; + }, array_values($offered)); + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex($this->piecePlacement['piece']->id ?? ''); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Choose a piece to place.'; + $this->renderSelectionDependentArea(); + } + + /** + * The gameplay layer pieces go on: the layer Map mode edits, even from + * Event mode, so choosing a piece there returns to it. Null for a + * decoration layer, which takes no pieces. + * + * @return array|null + */ + private function findPieceCanvasLayer(ProjectMap $map): ?array + { + $selected = $this->getCanvasLayerState()['selected']; + $layers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $layer = array_find($layers, static fn(array $candidate): bool => $candidate['id'] === $selected) + ?? array_find($layers, static fn(array $candidate): bool => $candidate['id'] === $map->getBaseLayerId()); + + return $layer === null || $layer['decoration'] ? null : $layer; + } + + /** + * The pieces of the map's tileset, or null after saying in the status + * why there are none to offer. + * + * @return array|null Pieces by id, in authored order. + */ + private function loadCanvasPieces(ProjectMap $map): ?array + { + try { + return PiecePlacer::loadPieces($map); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + } + + /** Starts placing the chosen piece on the selected map, in Map mode. */ + private function choosePiece(string $id): void + { + $map = $this->getSelectedMap(); + $pieces = $map instanceof ProjectMap ? $this->loadCanvasPieces($map) : null; + if ($pieces === null) { + return; + } + $piece = $pieces[$id] ?? null; + if ($piece === null) { + $this->setStatus("Piece {$id} is no longer in the map's tileset. Press P to choose again.", StatusLevel::WARN); + return; + } + if ($this->editingMode !== self::MODE_MAP) { + $this->setEditingMode(self::MODE_MAP); + } + $this->facadeBrush = null; + $this->canvasToolAnchor = null; + $this->inputMode = self::INPUT_NORMAL; + $this->setFocusedPane(self::FOCUS_CANVAS, false); + $this->piecePlacement = ['map' => $map, 'piece' => $piece, 'anchor' => null]; + $this->setStatus(sprintf($piece->connects === null + ? 'Placing %s. Enter stamps and anchors; Enter again fills the area to the cursor, or drag with the mouse. Esc when done.' + : 'Drawing %s. Enter draws at the cursor and anchors there, Del erases, Esc when done.', $piece->name)); + $this->renderFocusDependentArea(); + } + + /** + * The placement in force: the piece being placed, while its map is the + * selected one and the canvas is in Map mode's Normal input. + * + * @return array{map: ProjectMap, piece: TilesetPiece, anchor: array{x: int, y: int}|null}|null + */ + private function getActivePiecePlacement(): ?array + { + $placement = $this->piecePlacement; + return $placement !== null && $placement['map'] === $this->getSelectedMap() + && $this->editingMode === self::MODE_MAP && $this->inputMode === self::INPUT_NORMAL + ? $placement : null; + } + + /** + * Esc while placing: drops a connected piece's anchor when one is set, + * and otherwise ends placement. + */ + private function backOutOfPiecePlacement(): void + { + $placement = $this->getActivePiecePlacement(); + if ($placement !== null && $placement['anchor'] !== null) { + $this->piecePlacement['anchor'] = null; + $this->setStatus(sprintf('Anchor dropped. Enter draws %s at the cursor; Esc when done.', $placement['piece']->name)); + $this->renderCanvasArea(); + return; + } + $this->endPiecePlacement('Piece placement ended.'); + } + + private function endPiecePlacement(string $statusMessage = ''): void + { + $this->piecePlacement = null; + if ($statusMessage !== '') { + $this->setStatus($statusMessage); + } + $this->renderCanvasArea(); + } + + /** + * What Enter would write with the cursor as the far corner, for the + * canvas preview ({@see PiecePlacer::getPreviewCells()}). + * + * @return array> Cells by row and column. + */ + private function getPiecePreviewCells(): array + { + $placement = $this->getActivePiecePlacement(); + if ($placement === null) { + return []; + } + $cursor = ['x' => $this->cursorX, 'y' => $this->cursorY]; + + return array_map(static fn(array $row): array => array_map(static fn(?array $cell): ?string => $cell['symbol'] ?? null, $row), + PiecePlacer::getPreviewCells($placement['map'], $placement['piece'], $placement['anchor'] ?? $cursor, $cursor)); + } + + /** + * Enter while placing: stamps an item piece, or draws a connected one. + * The piece is read from the tileset again, so the tileset stays the one + * source of pieces. + */ + private function applyPieceAtCursor(): void + { + $placement = $this->reloadPiecePlacement(); + if ($placement === null) { + return; + } + if (isset($placement['kind'])) { + $this->paintKindArea($placement['map'], $placement['kind'], $placement['anchor']); + return; + } + if ($placement['piece']->connects === null) { + $this->stampPieceArea($placement['map'], $placement['piece'], $placement['anchor']); + return; + } + $this->drawConnectedPiece($placement['map'], $placement['piece'], $placement['anchor']); + } + + /** + * The placement in force with its piece read from the tileset again, or + * null after ending placement when the piece is gone. + * + * @return array{map: ProjectMap, piece: TilesetPiece, anchor: array{x: int, y: int}|null}|null + */ + private function reloadPiecePlacement(): ?array + { + $placement = $this->getActivePiecePlacement(); + if ($placement === null) { + return null; + } + if (isset($placement['kind'])) { + return $this->reloadKindPlacement($placement); + } + $pieces = $this->loadCanvasPieces($placement['map']); + $piece = $pieces[$placement['piece']->id] ?? null; + if ($piece === null) { + if ($pieces !== null) { + $this->setStatus("{$placement['piece']->name} is no longer in the map's tileset. Press P to choose a piece.", StatusLevel::WARN); + } + $this->endPiecePlacement(); + return null; + } + $this->piecePlacement['piece'] = $piece; + + return $this->piecePlacement; + } + + + /** + * Stamps the piece across the area from the anchor to the cursor (the + * cursor alone without an anchor) as one undo step + * ({@see PiecePlacer::stampArea()}), in the brush colour where the piece + * has none, then anchors at the cursor so the next Enter fills on from + * there. An area that cannot be stamped whole changes nothing. + * + * @param array{x: int, y: int}|null $anchor + */ + private function stampPieceArea(ProjectMap $map, TilesetPiece $piece, ?array $anchor): void + { + $cursor = ['x' => $this->cursorX, 'y' => $this->cursorY]; + $this->finalizeActiveStroke(); + try { + ['command' => $command, 'origins' => $origins] = PiecePlacer::stampArea($map, $piece, $anchor ?? $cursor, $cursor, $this->selectedPaintColor); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return; + } + $this->piecePlacement['anchor'] = $cursor; + $next = ' Enter fills on from here; Esc drops the anchor.'; + if ($command === null) { + $this->setStatus(sprintf('%s is already there.', $piece->name) . $next); + $this->renderCanvasArea(); + return; + } + $this->recordCommand($command); + $this->setStatus(count($origins) === 1 + ? sprintf('Stamped %s at (%d, %d).', $piece->name, $origins[0]['x'], $origins[0]['y']) . $next + : sprintf('Stamped %d %s across (%d, %d) to (%d, %d).', count($origins), $piece->name, + $origins[0]['x'], $origins[0]['y'], $this->cursorX, $this->cursorY) . $next); + $this->renderCanvasArea(); + } + + + /** + * A mouse drag while placing a piece: the press anchors it, the drag + * previews the area or line to the pointer, and the release draws it and + * drops the anchor, since the drag was the whole gesture. + */ + private function dragPieceWithMouse(int $x, int $y, bool $isMotion, bool $isRelease): void + { + if ($this->getActivePiecePlacement() === null) { + return; + } + $this->cursorX = $x; + $this->cursorY = $y; + if (! $isMotion && ! $isRelease) { + $this->piecePlacement['anchor'] = ['x' => $x, 'y' => $y]; + } + if ($isRelease) { + $this->applyPieceAtCursor(); + if ($this->piecePlacement !== null) { + $this->piecePlacement['anchor'] = null; + } + return; + } + $anchor = $this->piecePlacement['anchor'] ?? ['x' => $x, 'y' => $y]; + $this->setStatus(sprintf('%s from (%d, %d) to (%d, %d). Release to draw.', $this->piecePlacement['piece']->name, $anchor['x'], $anchor['y'], $x, $y)); + $this->renderCanvasArea(); + } + + /** + * Records a glyph stroke and the tile layer change made with it, such as + * a pasted block, as one undo step. + * + * @param array $tilesBefore Tile layer sources before the change. + * @param array $tilesAfter Tile layer sources after it. + */ + private function recordStrokeWithTiles(string $label, ProjectMap $map, PaintStrokeCommand $stroke, array $tilesBefore, array $tilesAfter): void + { + $this->recordCommand(CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter)); + } + + /** + * Draws a connected piece: the cell at the cursor without an anchor, + * otherwise the line or room outline from the anchor to the cursor. The + * anchor then moves to the cursor, so the next draw continues from there. + * + * @param array{x: int, y: int}|null $anchor + */ + private function drawConnectedPiece(ProjectMap $map, TilesetPiece $piece, ?array $anchor): void + { + $cursor = ['x' => $this->cursorX, 'y' => $this->cursorY]; + $cells = PiecePlacer::getConnectedDrawCells($anchor ?? $cursor, $cursor); + $changed = $this->applyConnectedPiece($map, $piece, $cells, [], 'Piece draw'); + if ($changed === null) { + return; + } + $this->piecePlacement['anchor'] = $cursor; + $drew = $changed + ? sprintf('Drew %d %s %s.', count($cells), mb_strtolower($piece->name), count($cells) === 1 ? 'cell' : 'cells') + : sprintf('%s is already drawn there.', $piece->name); + $this->setStatus($drew . ($anchor === null + ? ' Anchor set; move and press Enter to draw a line or a room.' + : ' Enter draws on from here; Esc drops the anchor.')); + $this->renderCanvasArea(); + } + + /** + * Erases the connected piece's cell under the cursor and reshapes the + * cells beside it. A cell that is not part of the piece is left alone. + */ + private function eraseConnectedPieceCell(): void + { + $placement = $this->reloadPiecePlacement(); + if ($placement === null || $placement['piece']->connects === null) { + return; + } + $map = $placement['map']; + $piece = $placement['piece']; + $x = $this->cursorX; + $y = $this->cursorY; + try { + $layer = PiecePlacer::findLayer($map, $piece); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return; + } + if (! PiecePlacer::resolveMemberLookup($map, $layer, $piece)($x, $y)) { + $this->setStatus(sprintf('(%d, %d) is not part of a %s, so nothing was erased.', $x, $y, mb_strtolower($piece->name))); + $this->renderCanvasArea(); + return; + } + if ($this->applyConnectedPiece($map, $piece, [], [['x' => $x, 'y' => $y]], 'Piece erase') !== null) { + $this->setStatus(sprintf('Erased the %s cell at (%d, %d).', mb_strtolower($piece->name), $x, $y)); + $this->renderCanvasArea(); + } + } + + /** + * Draws and erases a connected piece's cells as one undo step + * ({@see PiecePlacer::drawConnected()}), drawn cells in the brush colour + * where their shape has none. + * + * @param list $drawn + * @param list $erased + * @return bool|null Whether anything changed, or null when refused. + */ + private function applyConnectedPiece(ProjectMap $map, TilesetPiece $piece, array $drawn, array $erased, string $label): ?bool + { + $this->finalizeActiveStroke(); + try { + $command = PiecePlacer::drawConnected($map, $piece, $drawn, $erased, $this->selectedPaintColor, $label); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return null; + } + if ($command === null) { + return false; + } + $this->recordCommand($command); + + return true; + } + + + + + + /** The canvas border hint while a piece is being placed, or null when none is. */ + private function getPiecePlacementHelp(int $windowWidth): ?string + { + $placement = $this->getActivePiecePlacement(); + if ($placement === null) { + return null; + } + $piece = $placement['piece']; + $escape = $placement['anchor'] === null ? 'Esc:Done' : 'Esc:Unanchor'; + if ($piece->connects === null) { + $title = isset($placement['kind']) ? 'KIND' : 'PIECE'; + $enter = $placement['anchor'] === null ? (isset($placement['kind']) ? 'Enter:Paint' : 'Enter:Stamp') : 'Enter:Fill'; + return $this->fitHelp( + $windowWidth, + sprintf('%s %s %s Drag:Fill %s', $title, $piece->name, $enter, $escape), + sprintf('%s %s %s %s', $title, $piece->name, $enter, $escape), + "{$title} {$enter} {$escape}", + "{$enter} {$escape}", + $escape, + ); + } + return $this->fitHelp( + $windowWidth, + sprintf('PIECE %s Enter:Draw Del:Erase %s', $piece->name, $escape), + sprintf('PIECE %s Enter:Draw Del:Erase %s', $piece->name, $escape), + "PIECE Enter:Draw Del:Erase {$escape}", + "Enter:Draw Del:Erase {$escape}", + $escape, + ); + } + + /** @return list */ + private function buildPiecePaletteItems(): array + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return []; + } + return [new PaletteItem('Pieces: Choose a piece to place', 'P', function (): void { + $this->closeDatabaseIfOpen(); + $this->openPiecePicker(); + }), new PaletteItem('Pieces: Draw tiles for this layer\'s glyphs', 'T', function (): void { + $this->closeDatabaseIfOpen(); + $this->drawTilesForLayerGlyphs(); + })]; + } +} diff --git a/src/Canvas/PiecePlacer.php b/src/Canvas/PiecePlacer.php new file mode 100644 index 00000000..38e201d6 --- /dev/null +++ b/src/Canvas/PiecePlacer.php @@ -0,0 +1,459 @@ + + * @throws MapSourceRefusal When the map has no tileset, it cannot load, or it has no pieces. + */ + public static function loadPieces(ProjectMap $map): array + { + try { + $tileset = $map->loadTileset(); + } catch (\Throwable $error) { + throw new MapSourceRefusal('Pieces are unavailable: ' . $error->getMessage(), previous: $error); + } + if ($tileset === null) { + throw new MapSourceRefusal('This map has no kind yet, so it has no pieces. Set its Kind in the Inspector.'); + } + if ($tileset->pieces === []) { + throw new MapSourceRefusal(sprintf('Tileset %s has no pieces. Add them to assets/%s/%s.php.', $tileset->id, Tileset::DIRECTORY, $tileset->id)); + } + + return $tileset->pieces; + } + + /** + * The id of the gameplay layer the piece's glyphs go on. + * + * @throws MapSourceRefusal When the map has no gameplay layer with that name. + */ + public static function findLayer(ProjectMap $map, TilesetPiece $piece): string + { + foreach ($map->getLayers() as $layer) { + if ($layer['id'] !== MapLayers::EVENT && ! $layer['decoration'] && $layer['name'] === $piece->layer) { + return $layer['id']; + } + } + throw new MapSourceRefusal(sprintf('%s goes on the %s layer, which this map does not have. Create that gameplay layer first. Nothing was changed.', + $piece->name, $piece->layer)); + } + + /** + * The top-left cells an item piece is stamped at across the area from one + * corner to the other: whole copies only, so an area smaller than the + * piece holds one copy, at its top-left. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @return list + */ + public static function getAreaOrigins(TilesetPiece $piece, array $from, array $to): array + { + $span = static function (int $from, int $to, int $size): array { + [$low, $high] = [min($from, $to), max($from, $to)]; + $starts = [$low]; + for ($start = $low + $size; $start + $size - 1 <= $high; $start += $size) { + $starts[] = $start; + } + return $starts; + }; + $origins = []; + foreach ($span($from['y'], $to['y'], $piece->height) as $y) { + foreach ($span($from['x'], $to['x'], $piece->width) as $x) { + $origins[] = ['x' => $x, 'y' => $y]; + } + } + + return $origins; + } + + /** + * The cells a connected piece is drawn on between two corners: the + * outline of the rectangle they make, a straight line when they share a + * row or a column, one cell when they are the same. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @return list + */ + public static function getConnectedDrawCells(array $from, array $to): array + { + return array_values(ToolGeometry::rectangleOutline($from['x'], $from['y'], $to['x'], $to['y'])); + } + + /** + * What placing the piece between two corners would write, for a preview: + * each cell's glyph and the colour it shows in, null where a space leaves + * the map's cell as it is. A connected piece previews each drawn cell with + * the glyph of the shape it would take beside what is already there. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @param string|null $fallbackColor The colour of cells the piece leaves unstyled. + * @return array> Cells by row and column. + */ + public static function getPreviewCells(ProjectMap $map, TilesetPiece $piece, array $from, array $to, ?string $fallbackColor = null): array + { + $cells = []; + if ($piece->connects !== null) { + $drawn = self::getConnectedDrawCells($from, $to); + try { + $isMemberCell = self::resolveMemberLookup($map, self::findLayer($map, $piece), $piece); + } catch (MapSourceRefusal) { + $isMemberCell = static fn(int $x, int $y): bool => false; + } + $sources = $piece->getSourceShapeGrid(); + foreach (array_slice(ConnectedPieceShaper::reshapeCells($piece, $drawn, [], $isMemberCell), 0, count($drawn)) as $cell) { + $shape = (string) $cell['shape']; + $cells[$cell['y']][$cell['x']] = ['symbol' => $piece->shapes[$shape], 'color' => self::readCellColor($sources[$shape]) ?? $fallbackColor]; + } + + return $cells; + } + $sources = $piece->getSourceGrid(); + foreach (self::getAreaOrigins($piece, $from, $to) as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + $cells[$y + $row][$x + $column] = $symbol === ' ' + ? null + : ['symbol' => $symbol, 'color' => self::readCellColor($sources[$row][$column]) ?? $fallbackColor]; + } + } + } + + return $cells; + } + + /** + * A picture of what the piece draws, for a picker: an item piece's own + * cells, or a small room of a connected piece's shapes. Each cell holds + * its glyph and authored colour; a space shows nothing. + * + * @return list> Cells by row. + */ + public static function buildPicture(TilesetPiece $piece): array + { + if ($piece->connects !== null) { + $sources = $piece->getSourceShapeGrid(); + $shape = static fn(string $name): array => ['symbol' => $piece->shapes[$name], 'color' => self::readCellColor($sources[$name])]; + $blank = ['symbol' => ' ', 'color' => null]; + + return [ + [$shape('corner'), $shape('horizontal'), $shape('corner')], + [$shape('vertical'), $blank, $shape('vertical')], + [$shape('corner'), $shape('horizontal'), $shape('corner')], + ]; + } + $sources = $piece->getSourceGrid(); + + return array_map(static fn(array $symbols, int $row): array => array_map( + static fn(string $symbol, int $column): array => ['symbol' => $symbol, 'color' => self::readCellColor($sources[$row][$column])], + $symbols, array_keys($symbols), + ), $piece->glyphs, array_keys($piece->glyphs)); + } + + /** + * Stamps an item piece across the area between two corners as one undo + * step. Space glyphs and `0` tiles leave their cells as they are, and a + * glyph the stamp covers takes its own tiles with it, as any glyph edit + * does ({@see GlyphTilePlanner}). A piece without glyphs, such as a rug, + * stands for nothing and is laid as authored. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @param string|null $fallbackColor The colour of cells the piece leaves unstyled; null keeps the map cell's own. + * @return array{command: ?Command, origins: list} + * @throws MapSourceRefusal When the piece is connected, its layer is missing or a copy falls off the map. + */ + public static function stampArea(ProjectMap $map, TilesetPiece $piece, array $from, array $to, ?string $fallbackColor, + string $label = 'Piece stamp'): array + { + $origins = self::getAreaOrigins($piece, $from, $to); + + return ['command' => self::stampOrigins($map, $piece, $origins, $fallbackColor, $label), 'origins' => $origins]; + } + + /** + * Stamps whole copies of an item piece with their top-left cells at the + * given origins, as one undo step, by the rules of {@see stampArea()}. + * + * @param list $origins + * @return ?Command The applied, unrecorded edit, or null when it changed nothing. + * @throws MapSourceRefusal When the piece is connected, its layer is missing or a copy falls off the map. + */ + public static function stampOrigins(ProjectMap $map, TilesetPiece $piece, array $origins, ?string $fallbackColor, + string $label = 'Piece stamp'): ?Command + { + if ($piece->connects !== null) { + throw new MapSourceRefusal(sprintf('%s is drawn as connected cells, not stamped. Nothing was changed.', $piece->name)); + } + $sources = $piece->getSourceGrid(); + $writes = $assigned = []; + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + if ($symbol !== ' ') { + $writes[] = ['x' => $x + $column, 'y' => $y + $row, 'symbol' => $symbol] + + self::resolveCellPaint($sources[$row][$column], $fallbackColor); + } + } + } + // Each glyph cell plays its own cell of this piece, never a lookalike. + foreach (PieceRole::readPiece($piece) as $roles) { + foreach ($roles as $role) { + $assigned[($x + $role->cell[1]) . ',' . ($y + $role->cell[0])] = $role->key; + } + } + } + $layer = self::findLayer($map, $piece); + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach ($piece->glyphs as $row => $symbols) { + foreach (array_keys($symbols) as $column) { + if (! $map->hasLayerCell($layer, $x + $column, $y + $row)) { + throw new MapSourceRefusal(sprintf('%s (%d x %d) does not fit at (%d, %d): the map has no cell at (%d, %d). Nothing was changed.', + $piece->name, $piece->width, $piece->height, $x, $y, $x + $column, $y + $row)); + } + } + } + } + $tilesBefore = $map->getTileLayerSources(); + $stack = self::findStack($map, $piece); + if ($stack !== null) { + $map->writeTileCells(self::layStacked($map, $piece, $stack, $origins)); + } elseif ($writes === []) { + foreach ($origins as ['x' => $x, 'y' => $y]) { + $map->writeTileEntries($piece->tiles, $x, $y); + } + } else { + $plan = CanvasEditor::plan($map, $layer, $writes, assigned: $assigned); + $map->writeTileCells($plan['tiles'] ?? []); + $writes = $plan['writes'] ?? $writes; + } + $tilesAfter = $map->getTileLayerSources(); + [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); + + return $stroke->hasChanges() || $tilesAfter !== $tilesBefore + ? CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter) + : null; + } + + /** The stack the piece is laid on in the map's tileset, or null when it is stamped as authored. */ + private static function findStack(ProjectMap $map, TilesetPiece $piece): ?TilesetStack + { + try { + return $map->loadTileset()?->getStackOf($piece); + } catch (\Throwable $error) { + throw new MapSourceRefusal('Pieces are unavailable: ' . $error->getMessage(), previous: $error); + } + } + + /** + * The stack's entries in every cell a stacked piece's copies cover once + * they are laid there ({@see TilesetStack::layTile()}), keyed by tile + * layer in the stack's order, a layer left empty included. + * + * @param list $origins + * @return array> + * @throws MapSourceRefusal When the map draws the stack's layers out of order, a cell holds an entry the stack cannot order, or a cell would hold more tiles than the stack has layers. + */ + private static function layStacked(ProjectMap $map, TilesetPiece $piece, TilesetStack $stack, array $origins): array + { + $present = array_values(array_intersect($map->getTileLayerNames(), $stack->layers)); + if ($present !== array_values(array_intersect($stack->layers, $present))) { + throw new MapSourceRefusal(sprintf('This map draws the tile layers of stack %s out of order (%s); they draw bottom to top as %s. Nothing was changed.', + $stack->id, implode(', ', $present), implode(', ', $stack->layers))); + } + $xs = array_column($origins, 'x'); + $ys = array_column($origins, 'y'); + $area = ['x' => min($xs), 'y' => min($ys), 'width' => max($xs) - min($xs) + $piece->width, 'height' => max($ys) - min($ys) + $piece->height]; + $stacked = $map->readTileEntries($stack->layers, $area['x'], $area['y'], $area['width'], $area['height']); + $held = []; + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach ($piece->tiles[$stack->getBaseLayer()] as $row => $entries) { + foreach ($entries as $column => $entry) { + $tileId = (int) $entry; + if ($tileId === TileId::EMPTY) { + continue; + } + [$cellX, $cellY] = [$x + $column, $y + $row]; + $key = "{$cellX},{$cellY}"; + $held[$key] ??= self::readStackedCell($stacked, $stack, $cellX - $area['x'], $cellY - $area['y'], $cellX, $cellY); + try { + $held[$key] = $stack->layTile($held[$key], $tileId, $cellX, $cellY); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(sprintf('%s does not fit at (%d, %d): %s Nothing was changed.', $piece->name, $x, $y, $error->getMessage()), + previous: $error); + } + } + } + } + $cells = array_fill_keys($stack->layers, []); + foreach ($held as $key => $tiles) { + [$cellX, $cellY] = array_map(intval(...), explode(',', $key)); + foreach ($stack->layers as $index => $layer) { + $cells[$layer][] = ['x' => $cellX, 'y' => $cellY, 'entry' => (string) ($tiles[$index] ?? TileId::EMPTY)]; + } + } + + return $cells; + } + + /** + * A cell's tiles on a stack's layers, bottom to top, empty ones left out. + * + * @param array>> $stacked The stack's entries over an area, keyed by tile layer in the map's order. + * @return list + * @throws MapSourceRefusal When an entry is not a plain tile. + */ + private static function readStackedCell(array $stacked, TilesetStack $stack, int $column, int $row, int $x, int $y): array + { + $tiles = []; + foreach ($stacked as $layer => $rows) { + $entry = (string) ($rows[$row][$column] ?? '0'); + if (! ctype_digit($entry)) { + throw new MapSourceRefusal(sprintf('Tile layer %s holds %s at (%d, %d), which stack %s cannot order. Nothing was changed.', + $layer, $entry, $x, $y, $stack->id)); + } + if ((int) $entry !== TileId::EMPTY) { + $tiles[] = (int) $entry; + } + } + + return $tiles; + } + + /** + * Draws and erases a connected piece's cells as one undo step: writes + * them, then gives every drawn cell and every member beside a drawn or + * erased cell the glyph and tiles of its shape. Drawn cells take the + * fallback colour unless their shape has its own; reshaped cells keep + * theirs; tiles become `0` where a cell was erased. + * + * @param list $drawn + * @param list $erased + * @return ?Command The edit, or null when it changed nothing. + * @throws MapSourceRefusal When the piece is not connected, its layer is missing or a cell is off the map. + */ + public static function drawConnected(ProjectMap $map, TilesetPiece $piece, array $drawn, array $erased, ?string $fallbackColor, + string $label = 'Piece draw'): ?Command + { + if ($piece->connects === null) { + throw new MapSourceRefusal(sprintf('%s is stamped whole, not drawn as connected cells. Nothing was changed.', $piece->name)); + } + $layer = self::findLayer($map, $piece); + foreach ([...$drawn, ...$erased] as $cell) { + if (! $map->hasLayerCell($layer, $cell['x'], $cell['y'])) { + throw new MapSourceRefusal(sprintf('%s cannot reach (%d, %d): the map has no cell there. Nothing was changed.', + $piece->name, $cell['x'], $cell['y'])); + } + } + $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, $erased, self::resolveMemberLookup($map, $layer, $piece)); + $drawnKeys = array_flip(array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}", $drawn)); + $sources = $piece->getSourceShapeGrid(); + $writes = array_map(static fn(array $cell): array => [ + 'x' => $cell['x'], + 'y' => $cell['y'], + 'symbol' => $cell['shape'] === null ? ' ' : $piece->shapes[$cell['shape']], + ] + ($cell['shape'] === null ? ['color' => null] : self::resolveCellPaint($sources[$cell['shape']], + isset($drawnKeys["{$cell['x']},{$cell['y']}"]) ? $fallbackColor : null)), $cells); + // Each shape draws its tiles, and a glyph the wall covers takes its own ({@see GlyphTilePlanner}). + $tilesBefore = $map->getTileLayerSources(); + $plan = CanvasEditor::plan($map, $layer, $writes); + $map->writeTileCells($plan['tiles'] ?? []); + $tilesAfter = $map->getTileLayerSources(); + [$stroke] = CanvasEditor::writeCells($map, $layer, $plan['writes'] ?? $writes, $label); + + return $stroke->hasChanges() || $tilesAfter !== $tilesBefore + ? CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter) + : null; + } + + /** + * Whether a cell of the layer belongs to the connected piece now: it is on + * the map and holds one of the piece's glyphs. + * + * @return Closure(int, int): bool + */ + public static function resolveMemberLookup(ProjectMap $map, string $layer, TilesetPiece $piece): Closure + { + return static fn(int $x, int $y): bool => $map->hasLayerCell($layer, $x, $y) && $piece->isMember($map->getLayerSymbol($layer, $x, $y)); + } + + /** + * How one piece cell is painted: in the style its tileset authored for it, + * kept byte for byte, or with the fallback colour when it has none (null + * keeps the map cell's own colour). + * + * @return array{style: array{prefix: string, suffix: string}}|array{color: string|null} + */ + public static function resolveCellPaint(string $source, ?string $fallbackColor): array + { + $style = self::readCellStyle($source); + + return $style !== null ? ['style' => $style] : ['color' => $fallbackColor]; + } + + /** + * A piece cell's authored style, or null when it has none. + * + * @return array{prefix: string, suffix: string}|null + */ + private static function readCellStyle(string $source): ?array + { + $cell = TerminalText::parseSourceCells($source)[0] ?? null; + + return $cell !== null && ($cell['prefix'] !== '' || $cell['suffix'] !== '') + ? ['prefix' => $cell['prefix'], 'suffix' => $cell['suffix']] + : null; + } + + /** A piece cell's authored foreground, or null when it has none. */ + private static function readCellColor(string $source): ?string + { + $style = self::readCellStyle($source); + + return $style === null ? null : ProjectMap::getInnermostForeground($style['prefix']); + } +} diff --git a/src/Canvas/PieceRole.php b/src/Canvas/PieceRole.php new file mode 100644 index 00000000..5d1b6e35 --- /dev/null +++ b/src/Canvas/PieceRole.php @@ -0,0 +1,159 @@ +> $tiles Tile entries relative to the glyph cell, keyed by tile layer name; never `0`. + * @param array{int, int}|null $cell An item piece's cell, as row and column; null for a connected piece's shape. + * @param array> $keeps Cells whose existing tiles stay, keyed by tile layer; these are not owned tiles to remove. + * @param string $source The glyph cell as the tileset authors it, with its style markup, if any. + */ + public function __construct(public string $pieceId, string $part, public string $label, public array $tiles, public ?array $cell = null, + public array $keeps = [], public string $source = '') + { + $this->key = "{$pieceId}:{$part}"; + } + + /** + * The roles a piece's glyphs play, keyed by glyph. A connected piece has + * one per shape; an item piece one per glyph cell, each blank cell with + * tiles going to the glyph cell nearest it (the first in reading order + * on a tie). A piece with no glyphs, such as a rug, has none. + * + * @param list $excludedLayers Tile layers left out of every role. + * @return array> + */ + public static function readPiece(TilesetPiece $piece, array $excludedLayers = []): array + { + $excluded = array_flip($excludedLayers); + if ($piece->connects !== null) { + $roles = []; + $sources = $piece->getSourceShapeGrid(); + foreach ($piece->shapes as $shape => $glyph) { + $tiles = []; + foreach (array_diff_key($piece->shapeTiles, $excluded) as $layer => $entries) { + if ($entries[$shape] !== (string) TileId::EMPTY) { + $tiles[$layer] = [['dx' => 0, 'dy' => 0, 'entry' => $entries[$shape]]]; + } + } + // A connected shape is one cell, so it keeps its piece's kept layers at that cell alone. + $keeps = []; + foreach (array_diff($piece->keeps, $excludedLayers) as $layer) { + $keeps[$layer] = [['dx' => 0, 'dy' => 0]]; + } + $roles[$glyph][] = new self($piece->id, $shape, $piece->name, $tiles, keeps: $keeps, source: $sources[$shape] ?? $glyph); + } + + return $roles; + } + $glyphCells = $blankCells = []; + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + if ($symbol === ' ') { + $blankCells[] = [$row, $column]; + } else { + $glyphCells[] = [$row, $column]; + } + } + } + if ($glyphCells === []) { + return []; + } + $attached = []; + foreach ($blankCells as [$row, $column]) { + $nearest = null; + foreach ($glyphCells as $index => [$glyphRow, $glyphColumn]) { + $distance = abs($glyphRow - $row) + abs($glyphColumn - $column); + if ($nearest === null || $distance < $nearest[1]) { + $nearest = [$index, $distance]; + } + } + $attached[$nearest[0]][] = [$row, $column]; + } + $roles = []; + $sources = $piece->getSourceGrid(); + foreach ($glyphCells as $index => [$row, $column]) { + $tiles = []; + $ownedCells = [[$row, $column], ...($attached[$index] ?? [])]; + foreach (array_diff_key($piece->tiles, $excluded) as $layer => $entries) { + foreach ($ownedCells as [$cellRow, $cellColumn]) { + if ($entries[$cellRow][$cellColumn] !== (string) TileId::EMPTY) { + $tiles[$layer][] = ['dx' => $cellColumn - $column, 'dy' => $cellRow - $row, 'entry' => $entries[$cellRow][$cellColumn]]; + } + } + } + $keeps = []; + foreach (array_diff($piece->keeps, $excludedLayers) as $layer) { + foreach ($ownedCells as [$cellRow, $cellColumn]) { + $keeps[$layer][] = ['dx' => $cellColumn - $column, 'dy' => $cellRow - $row]; + } + } + $roles[$piece->glyphs[$row][$column]][] = new self($piece->id, "{$row}:{$column}", + self::describeCell($piece->name, $glyphCells, $row, $column), $tiles, [$row, $column], $keeps, + $sources[$row][$column] ?? $piece->glyphs[$row][$column]); + } + + return $roles; + } + + /** The tile entries of this role at the glyph cell itself, keyed by tile layer name. */ + public function getOwnEntries(): array + { + $own = []; + foreach ($this->tiles as $layer => $cells) { + foreach ($cells as $cell) { + if ($cell['dx'] === 0 && $cell['dy'] === 0) { + $own[$layer] = $cell['entry']; + } + } + } + + return $own; + } + + /** + * A piece's name, with the cell's place among the piece's glyph cells + * when it has more than one, such as `Large Bed (top left)`. + * + * @param list $glyphCells The piece's glyph cells, as row and column. + */ + private static function describeCell(string $name, array $glyphCells, int $row, int $column): string + { + if (count($glyphCells) === 1) { + return $name; + } + $place = static function (int $index, array $indexes, string $first, string $last): ?string { + [$low, $high] = [min($indexes), max($indexes)]; + $size = $high - $low + 1; + return match (true) { + $size === 1 => null, + $index === $low => $first, + $index === $high => $last, + $size === 3 => 'middle', + default => sprintf('%d of %d', $index - $low + 1, $size), + }; + }; + $vertical = $place($row, array_column($glyphCells, 0), 'top', 'bottom'); + $horizontal = $place($column, array_column($glyphCells, 1), 'left', 'right'); + + return sprintf('%s (%s)', $name, implode(' ', array_filter([$vertical, $horizontal]))); + } +} diff --git a/src/Canvas/PlacedPieces.php b/src/Canvas/PlacedPieces.php new file mode 100644 index 00000000..2efd7a9a --- /dev/null +++ b/src/Canvas/PlacedPieces.php @@ -0,0 +1,318 @@ + + * @throws MapSourceRefusal When the map's graphics cannot be read. + */ + public static function findAt(ProjectMap $map, int $x, int $y): array + { + $graphics = self::loadGraphics($map); + $found = []; + foreach ($graphics?->tileset->pieces ?? [] as $piece) { + // A stacked piece, such as a peak, is one of a cell's overlapping objects, not a thing placed on its own. + if ($piece->connects !== null || $graphics->tileset->getStackOf($piece) !== null) { + continue; + } + foreach ($graphics->findPieceStampsCovering($piece, $x, $y) as [$left, $top]) { + $found[] = ['piece' => $piece, 'x' => $left, 'y' => $top]; + } + } + usort($found, static fn(array $a, array $b): int => $a['piece']->width * $a['piece']->height <=> $b['piece']->width * $b['piece']->height); + + return $found; + } + + /** + * Whether a stamped copy of the piece stands whole with its top-left at a cell. + * + * @throws MapSourceRefusal When the map's graphics cannot be read. + */ + public static function isPlacedAt(ProjectMap $map, TilesetPiece $piece, int $x, int $y): bool + { + return in_array([$x, $y], self::loadGraphics($map)?->findPieceStampsCovering($piece, $x, $y) ?? [], true); + } + + /** @throws MapSourceRefusal When the map's graphics cannot be read. */ + private static function loadGraphics(ProjectMap $map): ?\Ichiloto\Engine\Field\MapGraphics + { + try { + return $map->loadGraphics(); + } catch (Throwable $error) { + throw new MapSourceRefusal('The map\'s tiles cannot be read, so its pieces cannot be found: ' . $error->getMessage(), previous: $error); + } + } + + /** + * A placed copy as an author sees it: where it stands, the cells it + * covers, its doors with the cell each is entered from and the entrance + * that meets it there, if any. + * + * @return array{id: string, name: string, x: int, y: int, width: int, height: int, footprint: bool, + * doors: list} + */ + public static function describe(ProjectMap $map, TilesetPiece $piece, int $x, int $y): array + { + $doors = []; + foreach ($piece->doorway as $door) { + [$doorX, $doorY] = [$x + $door['x'], $y + $door['y']]; + $entranceCell = $door['entrance']->getEntranceCell($doorX, $doorY); + $doors[] = ['x' => $doorX, 'y' => $doorY, 'entrance' => $door['entrance']->value, 'entranceCell' => $entranceCell, + 'event' => self::findEntranceAt($map, $entranceCell[0], $entranceCell[1])]; + } + + return ['id' => $piece->id, 'name' => $piece->name, 'x' => $x, 'y' => $y, 'width' => $piece->width, 'height' => $piece->height, + 'footprint' => $piece->occupancy !== null, 'doors' => $doors]; + } + + /** + * Stamps a piece at its origins with the collision its physical footprint + * gives at each, as one undo step: what placing a building with its + * footprint does. A map whose collision still follows its glyphs takes + * the glyphs' collision, so the footprint is not stamped there. + * + * @param Command|null $stamp The applied glyph and tile stamp ({@see PiecePlacer::stampOrigins()}). + * @param list $origins + * @return array{command: ?Command, cells: int} The step, and how many cells' collision the footprint changed. + * @throws MapSourceRefusal When the footprint does not fit; the stamp is taken back first. + */ + public static function addFootprint(ProjectMap $map, TilesetPiece $piece, ?Command $stamp, array $origins, string $label): array + { + if ($piece->occupancy === null || ! $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + return ['command' => $stamp, 'cells' => 0]; + } + $path = [MapPhysicalOccupancy::DATA_KEY]; + $before = $map->getMapDataField($path); + $changed = 0; + try { + $writes = []; + foreach ($origins as ['x' => $x, 'y' => $y]) { + foreach (PhysicalOccupancyEditor::getPieceWrites($map, $piece, $x, $y) as $write) { + $writes["{$write[0]},{$write[1]}"] = $write; + } + } + $changed = $map->writePhysicalOccupancy(array_values($writes)); + } catch (MapSourceRefusal $refusal) { + $stamp?->undo(); + throw $refusal; + } + $after = $map->getMapDataField($path); + $commands = array_values(array_filter([$stamp, $changed === 0 ? null : new GenericCommand('Stamp physical footprint', + static fn() => $map->setMapDataField($path, $after), static fn() => $map->setMapDataField($path, $before))])); + + return ['command' => $commands === [] ? null : new CommandGroup($label, $commands), 'cells' => $changed]; + } + + /** + * Removes a placed copy as one undo step: its glyphs where the map still + * shows them, with the tiles they carry, every tile of it still in place, + * and with `$footprint` the collision its physical footprint set, given + * back to what the terminal layers now say there. + * + * @return array{command: Command, cells: int} The step, and how many cells' collision went back. + * @throws MapSourceRefusal When no copy of the piece stands there, or what it leaves cannot be worked out. + */ + public static function remove(ProjectMap $map, TilesetPiece $piece, int $x, int $y, bool $footprint, string $label = 'Remove piece'): array + { + if (! self::isPlacedAt($map, $piece, $x, $y)) { + throw new MapSourceRefusal(sprintf('No whole %s stands at %d, %d any more. Nothing was changed.', $piece->name, $x, $y)); + } + $layer = PiecePlacer::findLayer($map, $piece); + $writes = []; + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + [$cellX, $cellY] = [$x + $column, $y + $row]; + if ($symbol !== ' ' && $map->hasLayerCell($layer, $cellX, $cellY) + && TerminalText::stripAnsi($map->getLayerSymbol($layer, $cellX, $cellY)) === $symbol) { + $writes[] = ['x' => $cellX, 'y' => $cellY, 'symbol' => ' ']; + } + } + } + $tilesBefore = $map->getTileLayerSources(); + $plan = CanvasEditor::plan($map, $layer, $writes); + if ($plan !== null && $plan['unresolved'] !== []) { + throw new MapSourceRefusal(sprintf('Removing %s leaves cells whose pieces beneath could be several. Erase its glyphs on the canvas instead. Nothing was changed.', + $piece->name)); + } + $map->writeTileCells($plan['tiles'] ?? []); + $writes = $plan['writes'] ?? $writes; + // Its tiles over blank glyph cells, such as a roof, go too, wherever they are still its own. + $left = []; + $current = $map->readTileEntries(array_keys($piece->tiles), $x, $y, $piece->width, $piece->height); + foreach ($piece->tiles as $name => $rows) { + foreach ($rows as $row => $entries) { + foreach ($entries as $column => $entry) { + if ($entry !== '0' && ($current[$name][$row][$column] ?? null) === $entry) { + $left[$name][] = ['x' => $x + $column, 'y' => $y + $row, 'entry' => '0']; + } + } + } + } + $map->writeTileCells($left); + $tilesAfter = $map->getTileLayerSources(); + [$stroke] = CanvasEditor::writeCells($map, $layer, $writes, $label); + $commands = [CanvasEditor::combineStrokeWithTiles($label, $map, $stroke, $tilesBefore, $tilesAfter)]; + $freed = 0; + if ($footprint && $piece->occupancy !== null && $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + $cells = []; + foreach ($piece->occupancy as $row => $types) { + foreach ($types as $column => $type) { + if ($type !== null) { + $cells[] = [$x + $column, $y + $row]; + } + } + } + $path = [MapPhysicalOccupancy::DATA_KEY]; + $before = $map->getMapDataField($path); + try { + $freed = $map->writePhysicalOccupancy($map->resolveGlyphCollision($cells)); + } catch (MapSourceRefusal|InvalidArgumentException $refusal) { + $commands[0]->undo(); + throw new MapSourceRefusal(rtrim($refusal->getMessage(), '.') . '. Nothing was changed.', previous: $refusal); + } + $after = $map->getMapDataField($path); + if ($freed > 0) { + $commands[] = new GenericCommand('Clear physical footprint', static fn() => $map->setMapDataField($path, $after), + static fn() => $map->setMapDataField($path, $before)); + } + } + + return ['command' => new CommandGroup($label, $commands), 'cells' => $freed]; + } + + /** + * Moves a placed copy so its top-left is at another cell, as one undo + * step: removed where it stood and stamped where it goes, its footprint + * with it when asked, and the entrances meeting its doors moved by the + * same offset, so each door keeps its entrance. + * + * @param string|null $color The colour of the piece's cells that have none of their own, as a stamp takes it. + * @return array{command: Command, entrances: list} The step, and the entrances that moved. + * @throws MapSourceRefusal|EventRefusal When it cannot be moved whole; nothing is changed then. + */ + public static function move(ProjectMap $map, TilesetPiece $piece, int $x, int $y, int $toX, int $toY, bool $footprint, ?string $color): array + { + if ($x === $toX && $y === $toY) { + throw new MapSourceRefusal(sprintf('%s is already there.', $piece->name)); + } + $label = sprintf('Move %s', $piece->name); + $entrances = array_values(array_unique(array_filter(array_column(self::describe($map, $piece, $x, $y)['doors'], 'event')))); + $made = []; + try { + $made[] = self::remove($map, $piece, $x, $y, $footprint, $label)['command']; + $stamp = PiecePlacer::stampOrigins($map, $piece, [['x' => $toX, 'y' => $toY]], $color, $label); + if ($stamp !== null) { + $made[] = $stamp; + } + if ($footprint) { + $placed = self::addFootprint($map, $piece, null, [['x' => $toX, 'y' => $toY]], $label)['command']; + if ($placed !== null) { + $made[] = $placed; + } + } + foreach ($entrances as $marker) { + $moved = EventAuthoring::moveEvent($map, $marker, $toX - $x, $toY - $y, $label); + if ($moved !== null) { + $made[] = $moved; + } + } + } catch (MapSourceRefusal|EventRefusal $refusal) { + foreach (array_reverse($made) as $command) { + $command->undo(); + } + throw $refusal; + } + + return ['command' => new CommandGroup($label, $made), 'entrances' => $entrances]; + } + + /** + * Places a new entrance, a transfer event, on the cell a placed copy's + * door is entered from, as one undo step; its destination is then chosen + * like any transfer's. + * + * @return array{marker: string, command: Command, cell: array{0: int, 1: int}} + * @throws MapSourceRefusal|EventRefusal When there is no such door, it has an entrance already, or the cell cannot take one. + */ + public static function addEntrance(ProjectMap $map, TilesetPiece $piece, int $x, int $y, int $door): array + { + if (! self::isPlacedAt($map, $piece, $x, $y)) { + throw new MapSourceRefusal(sprintf('No whole %s stands at %d, %d any more. Nothing was changed.', $piece->name, $x, $y)); + } + $doors = self::describe($map, $piece, $x, $y)['doors']; + $chosen = $doors[$door] ?? throw new MapSourceRefusal(sprintf('%s has no door %d.', $piece->name, $door + 1)); + if ($chosen['event'] !== null) { + throw new MapSourceRefusal(sprintf('Entrance %s already meets that door.', $chosen['event'])); + } + $type = EventTypeCatalog::findByClass(TransferPlayerTrigger::class) + ?? throw new MapSourceRefusal('The Editor has no transfer event type.'); + $created = EventAuthoring::createEvent($map, [$chosen['entranceCell']], $type); + + return ['marker' => $created['marker'], 'command' => $created['command'], 'cell' => $chosen['entranceCell']]; + } + + /** The transfer event whose cells take in a cell, or null when none does. */ + private static function findEntranceAt(ProjectMap $map, int $x, int $y): ?string + { + foreach (MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), $map->mapId)->events as $event) { + $class = $event['class'] ?? null; + if (! is_string($class) || ! is_a($class, TransferPlayerTrigger::class, true) || ! is_array($event['area'] ?? null)) { + continue; + } + try { + if (CellArea::fromArray($event['area'], 'Entrance')->containsCell($x, $y)) { + return is_string($event['marker'] ?? null) ? $event['marker'] : null; + } + } catch (InvalidArgumentException) { + continue; + } + } + + return null; + } +} diff --git a/src/Canvas/StackedObjects.php b/src/Canvas/StackedObjects.php new file mode 100644 index 00000000..9e1f7df1 --- /dev/null +++ b/src/Canvas/StackedObjects.php @@ -0,0 +1,156 @@ +>>> Rows of cells by stack id; a stack none of whose objects are there is left out. + * @throws MapSourceRefusal When the map's tileset or its stack layers cannot be read. + */ + public static function read(ProjectMap $map, string $layerName, int $x, int $y, int $width, int $height): array + { + $read = []; + foreach (self::loadStacks($map) as $stack) { + $entries = self::readEntries($map, $stack, $x, $y, $width, $height); + $rows = []; + $found = false; + for ($row = 0; $row < $height; $row++) { + for ($column = 0; $column < $width; $column++) { + $own = array_values(array_filter(self::readCell($entries, $stack, $column, $row, $x + $column, $y + $row), + static fn(int $tile): bool => self::belongsTo($stack, $tile, $layerName))); + $found = $found || $own !== []; + $rows[$row][$column] = $own; + } + } + if ($found) { + $read[$stack->id] = $rows; + } + } + + return $read; + } + + /** + * The stack entries that take the gameplay layer's objects out of the + * cells and then lay the given tiles there in depth order, keyed by tile + * layer: a cut lays nothing, a paste lays what it carries. Every stack + * layer of a changed cell is written, an emptied one included. + * + * @param list $cells + * @param array>> $laid Tiles to lay by stack id, then `x,y` cell key. + * @return array> + * @throws MapSourceRefusal When a cell would hold more tiles than its stack has layers, or the stack cannot be read. + */ + public static function replace(ProjectMap $map, string $layerName, array $cells, array $laid = []): array + { + if ($cells === []) { + return []; + } + $xs = array_column($cells, 'x'); + $ys = array_column($cells, 'y'); + [$left, $top] = [min($xs), min($ys)]; + [$width, $height] = [max($xs) - $left + 1, max($ys) - $top + 1]; + $writes = []; + foreach (self::loadStacks($map) as $stack) { + $entries = self::readEntries($map, $stack, $left, $top, $width, $height); + foreach ($cells as ['x' => $x, 'y' => $y]) { + $held = self::readCell($entries, $stack, $x - $left, $y - $top, $x, $y); + $kept = array_values(array_filter($held, static fn(int $tile): bool => ! self::belongsTo($stack, $tile, $layerName))); + try { + foreach ($laid[$stack->id]["{$x},{$y}"] ?? [] as $tile) { + $kept = $stack->layTile($kept, $tile, $x, $y); + } + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(sprintf('(%d, %d) cannot take these objects: %s Nothing was changed.', $x, $y, $error->getMessage()), + previous: $error); + } + if ($kept === $held) { + continue; + } + foreach ($stack->layers as $index => $tileLayer) { + $writes[$tileLayer][] = ['x' => $x, 'y' => $y, 'entry' => (string) ($kept[$index] ?? TileId::EMPTY)]; + } + } + } + + return $writes; + } + + /** Whether a stacked tile draws an object of the gameplay layer; a tile no stacked piece draws belongs to none. */ + private static function belongsTo(TilesetStack $stack, int $tile, string $layerName): bool + { + return ($stack->locate($tile)['piece'] ?? null)?->layer === $layerName; + } + + /** + * @return array + * @throws MapSourceRefusal + */ + private static function loadStacks(ProjectMap $map): array + { + try { + return $map->loadTileset()?->stacks ?? []; + } catch (InvalidArgumentException | RuntimeException $error) { + throw new MapSourceRefusal('The map\'s tileset cannot be read, so its stacked objects cannot be moved: ' . $error->getMessage() + . ' Nothing was changed.', previous: $error); + } + } + + /** @return array>> The stack layers the map has, over the rectangle. */ + private static function readEntries(ProjectMap $map, TilesetStack $stack, int $x, int $y, int $width, int $height): array + { + return $map->readTileEntries(array_values(array_intersect($stack->layers, $map->getTileLayerNames())), $x, $y, $width, $height); + } + + /** + * A cell's tiles on a stack's layers, bottom to top, empty ones left out. + * + * @param array>> $entries + * @return list + * @throws MapSourceRefusal When an entry is not a plain tile. + */ + private static function readCell(array $entries, TilesetStack $stack, int $column, int $row, int $x, int $y): array + { + $tiles = []; + foreach ($stack->layers as $tileLayer) { + $entry = (string) ($entries[$tileLayer][$row][$column] ?? TileId::EMPTY); + if (! ctype_digit($entry)) { + throw new MapSourceRefusal(sprintf('Tile layer %s holds %s at (%d, %d), which stack %s cannot order. Nothing was changed.', + $tileLayer, $entry, $x, $y, $stack->id)); + } + if ((int) $entry !== TileId::EMPTY) { + $tiles[] = (int) $entry; + } + } + + return $tiles; + } +} diff --git a/src/Canvas/TerrainKindCanvas.php b/src/Canvas/TerrainKindCanvas.php new file mode 100644 index 00000000..05e83d51 --- /dev/null +++ b/src/Canvas/TerrainKindCanvas.php @@ -0,0 +1,193 @@ +getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return; + } + $kinds = $this->loadCanvasKinds($map); + if ($kinds === null) { + return; + } + $layer = $this->findPieceCanvasLayer($map); + $offered = array_filter($kinds, static fn(TerrainKind $kind): bool => $kind->layer === ($layer['name'] ?? null)); + if ($offered === []) { + $layers = array_values(array_unique(array_map(static fn(TerrainKind $kind): string => MapLayers::formatLabel($kind->layer), $kinds))); + $this->setStatus(sprintf('The %s layer has no terrain kinds. Kinds are painted on %s; press L to switch layers.', + $layer === null ? 'current' : MapLayers::formatLabel($layer['name']), implode(', ', $layers)), StatusLevel::WARN); + $this->renderFooter(); + return; + } + $this->finalizeActiveStroke(); + $this->optionDialogField = ['canvasKind' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = sprintf('%s kind', MapLayers::formatLabel($layer['name'])); + $this->eventOptionDialogEntries = array_map(static fn(TerrainKind $kind): array => [ + 'label' => sprintf('%s %s', $kind->symbol, $kind->name), + 'value' => $kind->id, + 'description' => self::describeKind($kind, $kinds), + ], array_values($offered)); + $under = TerrainKindPainter::findKindAt($map, (string) $layer['id'], $this->cursorX, $this->cursorY); + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex( + $this->piecePlacement['kind']->id ?? $under?->id ?? ''); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Choose a terrain kind to paint.'; + $this->renderSelectionDependentArea(); + } + + /** + * What a picker row says about a kind: its passage, encounters, ground and + * whether its art is still to be drawn. + * + * @param array $kinds + */ + private static function describeKind(TerrainKind $kind, array $kinds): string + { + return implode(' · ', array_filter([ + $kind->passage === TerrainPassage::SOLID ? 'solid' : 'walk', + $kind->passage === TerrainPassage::SOLID ? null : ($kind->encounters === null ? 'no encounters' : "{$kind->encounters} encounters"), + $kind->ground === null ? null : 'on ' . ($kinds[$kind->ground]->name ?? $kind->ground), + $kind->awaitingArt ? 'awaiting art' : null, + ])); + } + + /** + * The kinds the map is painted in, or null after saying in the status why + * there are none to paint. + * + * @return array|null + */ + private function loadCanvasKinds(ProjectMap $map): ?array + { + try { + return TerrainKindPainter::loadKinds($map); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return null; + } + } + + /** Starts painting the chosen kind on the selected map, in Map mode. */ + private function chooseKind(string $id): void + { + $map = $this->getSelectedMap(); + $kinds = $map instanceof ProjectMap ? $this->loadCanvasKinds($map) : null; + if ($kinds === null) { + return; + } + $kind = $kinds[$id] ?? null; + if ($kind === null) { + $this->setStatus("Terrain kind {$id} is no longer in the map's tileset. Press K to choose again.", StatusLevel::WARN); + return; + } + if ($this->editingMode !== self::MODE_MAP) { + $this->setEditingMode(self::MODE_MAP); + } + $this->facadeBrush = null; + $this->canvasToolAnchor = null; + $this->inputMode = self::INPUT_NORMAL; + $this->setFocusedPane(self::FOCUS_CANVAS, false); + $this->piecePlacement = ['map' => $map, 'piece' => $kind->piece, 'anchor' => null, 'kind' => $kind]; + $this->setStatus(sprintf('Painting %s. Enter paints and anchors; Enter again fills the area to the cursor, or drag with the mouse. Esc when done.%s', + $kind->name, $kind->awaitingArt ? ' Its art is still to be drawn, so it shows its glyph graphically.' : '')); + $this->renderFocusDependentArea(); + } + + /** + * Paints the kind across the area from the anchor to the cursor (the + * cursor alone without an anchor) as one undo step, then anchors at the + * cursor so the next Enter fills on from there. + * + * @param array{x: int, y: int}|null $anchor + */ + private function paintKindArea(ProjectMap $map, TerrainKind $kind, ?array $anchor): void + { + $cursor = ['x' => $this->cursorX, 'y' => $this->cursorY]; + $this->finalizeActiveStroke(); + try { + ['command' => $command, 'cells' => $cells] = TerrainKindPainter::paintArea($map, $kind, $anchor ?? $cursor, $cursor, 'Paint ' . $kind->name); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return; + } + $this->piecePlacement['anchor'] = $cursor; + $next = ' Enter fills on from here; Esc drops the anchor.'; + if ($command === null) { + $this->setStatus(sprintf('%s is already there.', $kind->name) . $next); + $this->renderCanvasArea(); + return; + } + $this->recordCommand($command); + $this->setStatus(($cells === 1 ? sprintf('Painted %s at (%d, %d).', $kind->name, $this->cursorX, $this->cursorY) + : sprintf('Painted %d cells of %s to (%d, %d).', $cells, $kind->name, $this->cursorX, $this->cursorY)) . $next); + $this->renderCanvasArea(); + } + + /** + * The placement in force with its kind read from the tileset again, or + * null after ending placement when the kind is gone. + * + * @return array{map: ProjectMap, piece: \Ichiloto\Engine\Rendering\Tilesets\TilesetPiece, anchor: array{x: int, y: int}|null, kind: TerrainKind}|null + */ + private function reloadKindPlacement(array $placement): ?array + { + $kinds = $this->loadCanvasKinds($placement['map']); + $kind = $kinds[$placement['kind']->id] ?? null; + if ($kind === null) { + if ($kinds !== null) { + $this->setStatus("{$placement['kind']->name} is no longer in the map's tileset. Press K to choose a kind.", StatusLevel::WARN); + } + $this->endPiecePlacement(); + return null; + } + $this->piecePlacement['kind'] = $kind; + $this->piecePlacement['piece'] = $kind->piece; + + return $this->piecePlacement; + } + + /** @return list */ + private function buildKindPaletteItems(): array + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap || $map->getGridSourceIssue() !== null) { + return []; + } + return [new PaletteItem('Terrain: Choose a terrain kind to paint', 'K', function (): void { + $this->closeDatabaseIfOpen(); + $this->openKindPicker(); + })]; + } +} diff --git a/src/Canvas/TerrainKindPainter.php b/src/Canvas/TerrainKindPainter.php new file mode 100644 index 00000000..770230f3 --- /dev/null +++ b/src/Canvas/TerrainKindPainter.php @@ -0,0 +1,340 @@ + true`) is painted + * by kind; the conversion ({@see \Ichiloto\Editor\Maps\TerrainKindConversion}) + * is how a map opts in. Refusals are thrown, never shown. + */ +final class TerrainKindPainter +{ + private function __construct() + { + } + + /** + * The kinds a map can be painted with, by id in authored order. + * + * @return array + * @throws MapSourceRefusal When the map has no tileset, its tileset declares no kinds, or the map is not painted in them. + */ + public static function loadKinds(ProjectMap $map): array + { + $kinds = self::readTilesetKinds($map); + try { + $declared = MapTerrainKinds::isDeclared($map->getEditableData()); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + if (! $declared) { + throw new MapSourceRefusal(sprintf('%s is not painted in terrain kinds yet. Convert it first: ichiloto map:convert-kinds %s, ' + . 'mapping its old glyphs to kinds.', $map->mapId, $map->mapId)); + } + + return $kinds; + } + + /** + * The kinds a map's tileset declares, whether or not the map is painted in them yet. + * + * @return array + * @throws MapSourceRefusal When the map has no tileset, it cannot be read, or it declares no kinds. + */ + public static function readTilesetKinds(ProjectMap $map): array + { + try { + $tileset = $map->loadTileset(); + } catch (InvalidArgumentException | RuntimeException $error) { + throw new MapSourceRefusal('Terrain kinds are unavailable: ' . $error->getMessage(), previous: $error); + } + if ($tileset === null) { + throw new MapSourceRefusal('This map has no kind yet, so it has no terrain kinds. Set its Kind in the Inspector.'); + } + if ($tileset->kinds === []) { + throw new MapSourceRefusal(sprintf('Tileset %s declares no terrain kinds.', $tileset->id)); + } + + return $tileset->kinds; + } + + /** @throws MapSourceRefusal When the map cannot be painted in kinds, or has no kind with the id. */ + public static function requireKind(ProjectMap $map, string $kindId): TerrainKind + { + return self::loadKinds($map)[$kindId] + ?? throw new MapSourceRefusal(sprintf("%s is no longer one of the map's terrain kinds. Choose a kind again.", $kindId)); + } + + /** + * Paints a kind over every cell of the area between two corners, as one undo step. + * + * @param array{x: int, y: int} $from + * @param array{x: int, y: int} $to + * @return array{command: ?Command, cells: int} The applied, unrecorded edit, and the cells painted. + * @throws MapSourceRefusal When the map is not painted in kinds, the kind's layer is missing or the area falls off the map. + */ + public static function paintArea(ProjectMap $map, TerrainKind $kind, array $from, array $to, string $label = 'Paint terrain'): array + { + $cells = PiecePlacer::getAreaOrigins($kind->piece, $from, $to); + + return ['command' => self::paintCells($map, $kind, $cells, $label), 'cells' => count($cells)]; + } + + /** + * Paints a kind on the given cells, as one undo step. + * + * @param list $cells + * @return ?Command The applied, unrecorded edit, or null when it changed nothing. + * @throws MapSourceRefusal When the map is not painted in kinds, the kind's layer is missing or a cell is off the map. + */ + public static function paintCells(ProjectMap $map, TerrainKind $kind, array $cells, string $label = 'Paint terrain'): ?Command + { + self::loadKinds($map); + PiecePlacer::findLayer($map, $kind->piece); + $path = [MapPhysicalOccupancy::DATA_KEY]; + $declared = $map->hasMapDataField($path); + $occupancyBefore = $declared ? $map->getMapDataField($path) : null; + $cleared = self::clearOwnedTiles($map, $kind, $cells, $label); + try { + $paint = PiecePlacer::stampOrigins($map, $kind->piece, $cells, null, $label); + } catch (MapSourceRefusal $refusal) { + $cleared?->undo(); + throw $refusal; + } + $commands = array_values(array_filter([$cleared, $paint])); + if ($declared) { + $writes = []; + $set = $map->getLayerSet(); + foreach ($cells as ['x' => $x, 'y' => $y]) { + if (self::isTopLayer($set, $kind->layer, $x, $y)) { + $writes[] = [$x, $y, $kind->collision]; + } + } + if ($writes !== [] && $map->writePhysicalOccupancy($writes) > 0) { + $occupancyAfter = $map->getMapDataField($path); + $commands[] = new GenericCommand($label, + static fn() => $map->setMapDataField($path, $occupancyAfter), + static fn() => $map->setMapDataField($path, $occupancyBefore), + ); + } + } + + return match (count($commands)) { + 0 => null, + 1 => $commands[0], + default => new CommandGroup($label, $commands), + }; + } + + /** + * Clears, at the cells, what the tile layers the kind's gameplay layer + * owns hold, except the layers the kind keeps: a kind cell's terrain is + * the kind's alone, whatever stood there before, such as a tree an old + * glyph drew or a kind's own tiles. Its tiles are painted next. + * + * A stack holds whole objects ({@see TilesetStack}) that stand over the + * terrain, whichever gameplay layer each belongs to, so it is cleared by + * object, never by cell: an object goes, every tile of it, when the paint + * covers every cell it shows on, and stays whole when it also shows on a + * cell the paint leaves, such as a peak overhanging the road beside its + * range. Painting beside an object never crops it. An object that goes + * takes its footprint's glyphs with it where they are still its own, so a + * hill crest painted over leaves no solid cell behind. + * + * @param list $cells + * @return ?Command The applied, unrecorded edit, or null when nothing was there. + */ + private static function clearOwnedTiles(ProjectMap $map, TerrainKind $kind, array $cells, string $label): ?Command + { + try { + $owners = $map->loadGraphics()?->owners ?? []; + $tileset = $map->loadTileset(); + } catch (InvalidArgumentException | RuntimeException $error) { + throw new MapSourceRefusal('The map\'s tile layers cannot be read, so the cells\' old tiles cannot be cleared: ' . $error->getMessage() + . ' Nothing was changed.', previous: $error); + } + $tiles = CanvasEditor::createTileReader($map); + $painted = []; + foreach ($cells as ['x' => $x, 'y' => $y]) { + $painted["{$x},{$y}"] = true; + } + $writes = $objects = []; + foreach ($owners as $tileLayer => $owner) { + if (in_array($tileLayer, $kind->piece->keeps, true)) { + continue; + } + $stack = $tileset?->findStackHolding((string) $tileLayer); + if ($owner !== $kind->layer && $stack === null) { + continue; + } + foreach ($cells as ['x' => $x, 'y' => $y]) { + $entry = $tiles((string) $tileLayer, $x, $y); + if ($entry === (string) TileId::EMPTY) { + continue; + } + $object = $stack?->locate((int) $entry); + // A tile no stacked piece draws is the cell's alone, and goes only from a layer the kind's layer owns. + if ($object === null && $owner !== $kind->layer) { + continue; + } + if ($object !== null) { + if (self::showsBeyond($stack, $map, $tiles, (int) $entry, $x, $y, $painted)) { + continue; + } + $left = $x - $object['column']; + $top = $y - $object['row']; + $objects["{$object['piece']->id}@{$left},{$top}"] = [$object['piece'], $left, $top]; + } + $writes[$tileLayer][] = ['x' => $x, 'y' => $y, 'entry' => (string) TileId::EMPTY]; + } + } + if ($writes === []) { + return null; + } + $before = $map->getTileLayerSources(); + $map->writeTileCells($writes); + $after = $map->getTileLayerSources(); + $commands = [new GenericCommand($label, + static fn() => $map->restoreTileLayerSources($after), + static fn() => $map->restoreTileLayerSources($before), + )]; + foreach (self::findFootprintGlyphs($map, $objects) as $layerId => $erased) { + [$stroke] = CanvasEditor::writeCells($map, $layerId, $erased, $label); + if ($stroke->hasChanges()) { + $commands[] = $stroke; + } + } + + return count($commands) === 1 ? $commands[0] : new CommandGroup($label, $commands); + } + + /** + * The footprint glyphs of cleared stacked objects that still stand where + * each put them, as erasing writes, by gameplay layer id. A glyph since + * replaced by another stays. + * + * @param array $objects + * @return array> + */ + private static function findFootprintGlyphs(ProjectMap $map, array $objects): array + { + $erased = []; + foreach ($objects as [$piece, $left, $top]) { + if (! array_any($piece->glyphs, static fn(array $row): bool => array_any($row, static fn(string $glyph): bool => $glyph !== ' '))) { + continue; + } + $layerId = PiecePlacer::findLayer($map, $piece); + foreach ($piece->glyphs as $row => $symbols) { + foreach ($symbols as $column => $symbol) { + [$x, $y] = [$left + $column, $top + $row]; + if ($symbol !== ' ' && $map->hasLayerCell($layerId, $x, $y) && $map->getLayerSymbol($layerId, $x, $y) === $symbol) { + $erased[$layerId]["{$x},{$y}"] = ['x' => $x, 'y' => $y, 'symbol' => ' ', 'color' => null]; + } + } + } + } + + return array_map(array_values(...), $erased); + } + + /** + * Whether the stacked object a cell's tile draws also shows on a cell + * outside the paint: one of its other cells still holds its tile there on + * one of the stack's layers. A tile no stacked piece draws belongs to no + * object, and is the cell's alone. + * + * @param Closure(string, int, int): string $tiles + * @param array $painted + */ + private static function showsBeyond(TilesetStack $stack, ProjectMap $map, Closure $tiles, int $tileId, int $x, int $y, array $painted): bool + { + $cell = $stack->locate($tileId); + if ($cell === null) { + return false; + } + ['piece' => $piece, 'column' => $column, 'row' => $row] = $cell; + $layers = array_values(array_intersect($stack->layers, $map->getTileLayerNames())); + foreach ($piece->tiles[$stack->getBaseLayer()] as $pieceRow => $entries) { + foreach ($entries as $pieceColumn => $entry) { + [$cellX, $cellY] = [$x - $column + $pieceColumn, $y - $row + $pieceRow]; + if ((int) $entry === TileId::EMPTY || isset($painted["{$cellX},{$cellY}"])) { + continue; + } + foreach ($layers as $layer) { + if ($tiles($layer, $cellX, $cellY) === (string) $entry) { + return true; + } + } + } + } + + return false; + } + + /** + * The kind a cell of the map shows in both presentations, or null: a + * glyph and tiles of different kinds, or none ({@see MapTerrainKinds::findKindAt()}). + */ + public static function findKindAt(ProjectMap $map, string $layerId, int $x, int $y): ?TerrainKind + { + try { + $kinds = $map->loadTerrainKinds(); + } catch (InvalidArgumentException) { + return null; + } + $layer = array_find($map->getLayers(), static fn(array $candidate): bool => $candidate['id'] === $layerId); + if ($kinds === null || $layer === null || ! $map->hasLayerCell($layerId, $x, $y)) { + return null; + } + $tiles = CanvasEditor::createTileReader($map); + + return $kinds->findKindAt($layer['name'], $map->getLayerSymbol($layerId, $x, $y), + static fn(string $tileLayer): int => (int) $tiles($tileLayer, $x, $y)); + } + + /** Whether no gameplay layer above the named layer holds a glyph at the cell, so the cell's collision is that layer's. */ + public static function isTopLayer(MapLayerSet $set, string $layerName, int $x, int $y): bool + { + $above = false; + foreach ($set->layers as $layer) { + if ($layer->name === $layerName) { + $above = true; + } elseif ($above && ! $layer->decoration && ($layer->getGlyphAt($x, $y) ?? ' ') !== ' ') { + return false; + } + } + + return true; + } +} diff --git a/src/Canvas/TileBinding.php b/src/Canvas/TileBinding.php new file mode 100644 index 00000000..d83ae02e --- /dev/null +++ b/src/Canvas/TileBinding.php @@ -0,0 +1,41 @@ + $companions The piece's glyph cells that draw no + * tile, relative to this role's glyph cell; they come and go with it, as the lower half of a window does. + */ + public function __construct( + public readonly string $layerId, + public readonly string $glyph, + public readonly PieceRole $role, + public readonly int $dx, + public readonly int $dy, + public readonly TilesetPiece $piece, + public readonly array $companions = [], + ) {} + + /** Whether the glyph belongs to a connected piece, whose shape its neighbours decide. */ + public bool $isConnected { + get => $this->piece->connects !== null; + } +} diff --git a/src/Canvas/TileGlyphBinding.php b/src/Canvas/TileGlyphBinding.php new file mode 100644 index 00000000..86b385ab --- /dev/null +++ b/src/Canvas/TileGlyphBinding.php @@ -0,0 +1,152 @@ +>> $bindings By tile layer name, then tile entry. + * @param array $planners Each gameplay layer's planner, by layer id. + * @param array> $free Tile entries a glyph-less piece draws, by tile layer name. + */ + private function __construct(private array $bindings, private array $planners, private array $free = []) {} + + /** The bindings of a map's tileset pieces, over the gameplay layers the map has. */ + public static function fromMap(ProjectMap $map): self + { + try { + $pieces = $map->loadPlacements(); + // A stack lays its pieces' tiles itself, so no glyph carries them ({@see TilesetStack}). + $stackLayers = $map->loadTileset()?->getStackLayers() ?? []; + } catch (InvalidArgumentException | RuntimeException) { + [$pieces, $stackLayers] = [[], []]; + } + $bindings = $planners = []; + $free = self::findFreeEntries($pieces, $stackLayers); + foreach ($map->getLayers() as $layer) { + if ($pieces === [] || $layer['id'] === MapLayers::EVENT || $layer['decoration']) { + continue; + } + $planner = GlyphTilePlanner::fromPieces($pieces, $layer['name'], $stackLayers); + $planners[$layer['id']] = $planner; + foreach ($planner->listRoles() as $glyph => $roles) { + foreach ($roles as $role) { + $piece = $pieces[$role->pieceId]; + $companions = self::findCompanions($piece, $role); + foreach ($role->tiles as $tileLayer => $cells) { + foreach ($cells as $cell) { + $bindings[$tileLayer][$cell['entry']][] = new TileBinding($layer['id'], (string) $glyph, $role, + $cell['dx'], $cell['dy'], $piece, $companions); + } + } + } + } + } + + return new self($bindings, $planners, $free); + } + + /** + * The tile entries no glyph carries, by tile layer name: those glyph-less + * pieces draw, such as a rug or a wall cap, and those a stack lays, such as + * a hill crest's, whose footprint glyphs carry no tiles. Such a tile may + * stand on any cell without its glyph. + * + * @param iterable $pieces + * @param list $stackLayers Tile layers of the tileset's stacks ({@see \Ichiloto\Engine\Rendering\Tilesets\Tileset::getStackLayers()}). + * @return array> + */ + public static function findFreeEntries(iterable $pieces, array $stackLayers): array + { + $free = []; + foreach ($pieces as $piece) { + if ($piece->connects !== null) { + continue; + } + $glyphless = PieceRole::readPiece($piece) === []; + foreach ($piece->tiles as $tileLayer => $rows) { + if (! $glyphless && ! in_array((string) $tileLayer, $stackLayers, true)) { + continue; + } + foreach ($rows as $entries) { + foreach ($entries as $entry) { + if ($entry !== (string) TileId::EMPTY) { + $free[$tileLayer][$entry] = true; + } + } + } + } + } + + return $free; + } + + /** Whether a tile is free: one a glyph-less piece also draws, which needs no glyph of its own. */ + public function isFree(string $tileLayer, string $entry): bool + { + return isset($this->free[$tileLayer][$entry]); + } + + /** @return list The glyphs a tile on a tile layer can stand for; none for a free tile. */ + public function findBindings(string $tileLayer, string $entry): array + { + return $this->bindings[$tileLayer][$entry] ?? []; + } + + /** Whether a tile on a tile layer stands for a glyph. */ + public function isBound(string $tileLayer, string $entry): bool + { + return $this->findBindings($tileLayer, $entry) !== []; + } + + /** The planner that keeps a gameplay layer's tiles with its glyphs, or null when the layer has no pieces. */ + public function getPlanner(string $layerId): ?GlyphTilePlanner + { + return $this->planners[$layerId] ?? null; + } + + /** + * An item piece's glyph cells that draw no tile, relative to a role's + * glyph cell. A connected piece has none: each of its cells is its own. + * + * @return list + */ + private static function findCompanions(TilesetPiece $piece, PieceRole $role): array + { + if ($role->cell === null) { + return []; + } + $companions = []; + foreach (PieceRole::readPiece($piece) as $glyph => $roles) { + foreach ($roles as $other) { + if ($other->tiles === [] && $other->cell !== null && $other->key !== $role->key) { + $companions[] = ['dx' => $other->cell[1] - $role->cell[1], 'dy' => $other->cell[0] - $role->cell[0], + 'glyph' => (string) $glyph, 'key' => $other->key]; + } + } + } + + return $companions; + } +} diff --git a/src/Canvas/TreeCanvas.php b/src/Canvas/TreeCanvas.php new file mode 100644 index 00000000..7782e962 --- /dev/null +++ b/src/Canvas/TreeCanvas.php @@ -0,0 +1,61 @@ +getSelectedMap(); + if ($map === null || $map->getGridSourceIssue() !== null || $this->editingMode !== self::MODE_MAP) { + return []; + } + + return [ + new PaletteItem('Map: Draw trees in the selection', '', fn() => $this->drawTreesInSelection(false)), + new PaletteItem('Map: Erase trees in the selection', '', fn() => $this->drawTreesInSelection(true)), + ]; + } + + private function drawTreesInSelection(bool $erase): void + { + $map = $this->getSelectedMap(); + if ($map === null) { + return; + } + if ($this->canvasSelection === null) { + $this->setStatus('Select a region of the canvas first; the trees go inside it.', StatusLevel::WARN); + return; + } + $this->finalizeActiveStroke(); + $cells = array_map(static fn(array $cell): array => [$cell['x'], $cell['y']], $this->getSelectionCells($this->canvasSelection)); + try { + $result = $erase ? TreeBrush::erase($map, $cells) : TreeBrush::draw($map, array_keys(TreeBrush::listTypes($map)), $cells); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + return; + } + if ($result['command'] === null) { + $this->setStatus($erase ? 'No tree stands in the selection.' + : 'No tree fits there: trunks need open ground clear of events, NPCs and other trunks, and never close a passage.', StatusLevel::WARN); + return; + } + $this->recordCommand($result['command']); + $this->setStatus($erase ? sprintf('Erased %d trees and freed their trunks. Ctrl+Z undoes it.', $result['removed']) + : sprintf('Planted %d trees with solid trunks. Ctrl+Z undoes it; Save writes them.', $result['planted'])); + $this->requestFullRender(); + } +} diff --git a/src/Console/ConsoleBinary.php b/src/Console/ConsoleBinary.php new file mode 100644 index 00000000..063d1cf8 --- /dev/null +++ b/src/Console/ConsoleBinary.php @@ -0,0 +1,126 @@ +console->path, 'new', $title, '--directory', $directory, '--no-interaction', + $install ? '--install' : '--no-install']; + if ($hero !== null && trim($hero) !== '') { + $command = [...$command, '--hero', trim($hero)]; + } + if ($battleEngine !== null) { + $command = [...$command, '--battle-engine', $battleEngine]; + } + + $process = proc_open($command, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (! is_resource($process)) { + throw new RuntimeException('The console could not be started to create the project.'); + } + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + + if ($status !== 0 || ! is_file($directory . DIRECTORY_SEPARATOR . 'ichiloto.json')) { + $message = trim(strip_tags((string) $output)); + throw new RuntimeException($message === '' ? 'The console could not create the project.' : $message); + } + + return realpath($directory) ?: $directory; + } +} diff --git a/src/Cutscenes/CutsceneAsset.php b/src/Cutscenes/CutsceneAsset.php index 6a46c19b..cae36fcb 100644 --- a/src/Cutscenes/CutsceneAsset.php +++ b/src/Cutscenes/CutsceneAsset.php @@ -10,11 +10,20 @@ use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FileSetTransactionFailure; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; +use Ichiloto\Editor\Database\InnPresentationFields; use Ichiloto\Editor\Database\PhpValueExporter; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; use RuntimeException; use Throwable; @@ -51,6 +60,17 @@ final class CutsceneAsset */ public const string COMMANDS_KEY = 'commands'; + /** + * The hidden payload key naming the sequence a record with separate + * terminal and graphical sequences shows, so its schema offers the rows + * that sequence reads (a summon's stage is graphical only). Never shown, + * never written to a file. + */ + public const string SEQUENCE_KEY = '__sequence'; + + /** The canvas a new summon stage starts with, in stage units. */ + private const array STAGE_CANVAS = ['width' => 1280, 'height' => 720]; + /** * @var array The data file's array as the editor now holds it. */ @@ -79,6 +99,26 @@ final class CutsceneAsset private ?PhpArraySourceDocument $dataDocument = null; private ?PhpArraySourceDocument $partnerDocument = null; + + /** @var array Authored bytes at load/save, even for read-only sources. */ + private array $baselineSources = []; + + /** @var array}> Undo templates, never persisted conflict/validation baselines. */ + private array $sourceTemplates = []; + + /** + * The sequence an effect or summon with separate terminal and graphical + * sequences is being edited in. The editor shows and edits one at a + * time; the file keeps both, each exactly as written unless it is the + * one edited. + */ + private EffectPresentation $presentationView = EffectPresentation::TERMINAL; + /** + * @var array An effect sequence's own FPS while the battle + * paces it, by sequence ('flat', 'terminal' or 'graphical'), so choosing + * fixed cadence again gives it back. + */ + private array $pacedFps = []; private ?string $readOnlyReason = null; private bool $isNew = false; private bool $isDeleted = false; @@ -114,16 +154,22 @@ public static function load(CutsceneType $type, string $folder, ?string $project $dataPath = $asset->dataPath(); $partnerPath = $asset->partnerPath(); - if (! is_file($dataPath) || ! is_file($partnerPath)) { - throw new RuntimeException(sprintf('%s "%s" is missing %s.', ucfirst($type->noun()), $asset->id, is_file($dataPath) ? basename($partnerPath) : basename($dataPath))); + $hasData = $type->hasDataFile(); + + if (($hasData && ! is_file($dataPath)) || ! is_file($partnerPath)) { + throw new RuntimeException(sprintf('%s "%s" is missing %s.', ucfirst($type->noun()), $asset->id, ! is_file($partnerPath) ? basename($partnerPath) : basename($dataPath))); } - $dataSource = (string) file_get_contents($dataPath); + $dataSource = $hasData ? (string) file_get_contents($dataPath) : ''; $partnerSource = (string) file_get_contents($partnerPath); + $asset->baselineSources = $hasData + ? [$dataPath => $dataSource, $partnerPath => $partnerSource] + : [$partnerPath => $partnerSource]; $reasons = []; try { - $data = $asset->evaluate($dataPath); + // An effect has no data file; all of it is its timeline. + $data = $hasData ? $asset->evaluate($dataPath) : []; } catch (Throwable $throwable) { $data = []; $reasons[] = sprintf('%s could not be evaluated (%s)', basename($dataPath), $throwable->getMessage()); @@ -147,7 +193,7 @@ public static function load(CutsceneType $type, string $folder, ?string $project } try { - $asset->dataDocument = PhpArraySourceDocument::parse($dataSource); + $asset->dataDocument = $hasData ? PhpArraySourceDocument::parse($dataSource) : null; } catch (SourceUnreadable $unreadable) { $reasons[] = sprintf('%s cannot be rewritten in place: %s', basename($dataPath), rtrim($unreadable->getMessage(), '.')); } @@ -197,6 +243,10 @@ public static function create(CutsceneType $type, string $id, string $root, arra $asset = new self($type, $id, rtrim($root, '/') . '/' . $id, $projectRoot); $asset->isNew = true; $asset->shape = CutscenePairShape::forNewAsset($type); + + if (! $type->hasDataFile()) { + unset($payload['id']); + } [$data, $partner] = $asset->shape->split($payload); $asset->shape = CutscenePairShape::of($type, $data, $partner); $asset->data = $data; @@ -233,7 +283,7 @@ public function partnerPath(): string */ public function paths(): array { - return [$this->dataPath(), $this->partnerPath()]; + return $this->type->hasDataFile() ? [$this->dataPath(), $this->partnerPath()] : [$this->partnerPath()]; } /** @@ -273,6 +323,7 @@ public function markDeleted(bool $deleted): void */ public function name(): string { + // An effect is named by its id; its timeline holds no name. $name = trim(strval($this->data['name'] ?? '')); return $name !== '' ? $name : $this->id; @@ -318,12 +369,289 @@ public function commands(): array */ public function payload(): array { - $payload = $this->shape->merge($this->data, $this->partner); + $payload = match (true) { + ! $this->hasPresentations() => $this->shape->merge($this->data, $this->partner), + // An effect is its timeline alone: the sequence is the record. + $this->type === CutsceneType::EFFECT => $this->getPresentationSequence($this->partner), + // A summon is its definition, the timeline's own keys and the sequence. + default => [ + ...$this->shape->merge($this->data, array_diff_key($this->partner, ['presentations' => true])), + ...$this->getPresentationSequence($this->partner), + ], + }; + + if (! $this->type->hasDataFile()) { + // An effect's id is its folder, as the Engine finds it; it is + // shown and renamed here like any id, and never written. + $payload = ['id' => $this->id, ...$payload]; + } + + if ($this->hasPresentations()) { + $payload[self::SEQUENCE_KEY] = $this->presentationView->value; + } + $payload[self::ORIGIN_KEY] = $this->id; return $payload; } + /** + * Whether this is an effect or a summon with separate terminal and + * graphical sequences, edited one at a time. + */ + public function hasPresentations(): bool + { + return $this->type !== CutsceneType::CINEMATIC && is_array($this->partner['presentations'] ?? null); + } + + /** The sequence being edited, for an effect or summon with separate sequences; null otherwise. */ + public function getPresentationView(): ?EffectPresentation + { + return $this->hasPresentations() ? $this->presentationView : null; + } + + /** + * Chooses which of the sequences the editor shows and edits. Nothing is + * written; the other sequence stays as it is. + */ + public function selectPresentation(EffectPresentation $presentation): void + { + $this->presentationView = $presentation; + } + + /** + * Gives a flat effect or summon separate terminal and graphical + * sequences, each starting as a copy of the flat one, so each + * presentation can then be authored on its own. A summon keeps its + * format version and editor metadata beside them, where the Engine reads + * them. A flat timeline stays flat unless this is chosen. + * + * @return bool False when it already has them, is a cinematic, or cannot be written. + */ + public function splitIntoPresentations(): bool + { + if ($this->type === CutsceneType::CINEMATIC || $this->hasPresentations() || $this->readOnlyReason !== null) { + return false; + } + $this->assertSourcesUnchanged(); + + $outer = $this->type === CutsceneType::SUMMON + ? array_intersect_key($this->partner, array_flip(SummonCutsceneDefinition::PAIRED_TIMELINE_FIELDS)) + : []; + $sequence = array_diff_key($this->partner, $outer); + $presentations = [EffectPresentation::TERMINAL->value => $sequence, EffectPresentation::GRAPHICAL->value => $sequence]; + $partner = []; + + // The sequences take the place the first sequence key had. + foreach ($this->partner as $key => $value) { + if (array_key_exists($key, $outer)) { + $partner[$key] = $value; + } elseif (! array_key_exists('presentations', $partner)) { + $partner['presentations'] = $presentations; + } + } + + $this->partner = $partner + ['presentations' => $presentations]; + $this->touchState(); + + return true; + } + + /** + * Whether the graphical effect or summon owns a cinematic stage. + */ + public function hasStage(): bool + { + return in_array($this->type, [CutsceneType::SUMMON, CutsceneType::EFFECT], true) + && ($this->type === CutsceneType::EFFECT || $this->hasPresentations()) + && is_array(($this->hasPresentations() + ? $this->getSequence(EffectPresentation::GRAPHICAL) : $this->partner)['stage'] ?? null); + } + + /** + * Gives a graphical effect or summon a cinematic stage, or takes it + * away. A new stage is the smallest the Engine reads: a 16:9 canvas + * shown from the first frame until the last, its camera on the canvas's + * centre at its own scale. Its subjects, art, covers and the sequence's + * rest frame are authored after. The terminal sequence never has one. + * + * @return bool False when nothing changed: ineligible, read-only, too + * short to restore before its end, or already so. Existing tracks/cues + * are preserved; incomplete authoring must compile before it can save. + */ + public function setStage(bool $present): bool + { + $graphical = EffectPresentation::GRAPHICAL->value; + + if (! in_array($this->type, [CutsceneType::SUMMON, CutsceneType::EFFECT], true) + || ($this->type === CutsceneType::SUMMON && ! $this->hasPresentations()) + || $this->readOnlyReason !== null || $present === $this->hasStage()) { + return false; + } + $this->assertSourcesUnchanged(); + + $sequence = $this->hasPresentations() ? $this->getSequence(EffectPresentation::GRAPHICAL) : $this->partner; + + if (! $present) { + unset($sequence['stage']); + } else { + $length = (int) ($sequence['lengthFrames'] ?? 0); + + if ($length < 2) { + return false; + } + + $stage = [ + 'canvas' => self::STAGE_CANVAS, + 'startFrame' => 0, + 'restoreFrame' => $length - 1, + 'camera' => [['id' => 'initial', 'frame' => 0, + 'focus' => ['x' => intdiv(self::STAGE_CANVAS['width'], 2), 'y' => intdiv(self::STAGE_CANVAS['height'], 2)], 'zoom' => 1]], + ]; + // Where the Engine writes it: after the sequence's clock, before its tracks. + $placed = []; + + foreach ($sequence as $key => $value) { + if (in_array($key, ['tracks', 'cues'], true) && ! array_key_exists('stage', $placed)) { + $placed['stage'] = $stage; + } + + $placed[$key] = $value; + } + + $sequence = $placed + ['stage' => $stage]; + } + + $partner = $this->partner; + if ($this->hasPresentations()) { + $partner['presentations'][$graphical] = $sequence; + } else { + $partner = $sequence; + } + $this->partner = $partner; + $this->touchState(); + + return true; + } + + /** + * @return array + */ + private function getSequence(EffectPresentation $presentation): array + { + $sequence = $this->partner['presentations'][$presentation->value] ?? []; + + return is_array($sequence) ? $sequence : []; + } + + /** + * Starts a new asset with this one's files under another id: both + * sequences of one that has two, whichever is shown. + */ + public function copyAs(string $id, string $root): self + { + $this->assertSourcesUnchanged(); + $copy = new self($this->type, $id, rtrim($root, '/') . '/' . $id, $this->projectRoot); + $copy->isNew = true; + $copy->data = $this->type->hasDataFile() && array_key_exists('id', $this->data) + ? [...$this->data, 'id' => $id] + : ($this->type->hasDataFile() ? ['id' => $id, ...$this->data] : $this->data); + $copy->partner = $this->partner; + $copy->presentationView = $this->presentationView; + $copy->pacedFps = $this->pacedFps; + $copy->shape = CutscenePairShape::of($this->type, $copy->data, $copy->partner); + $copy->loadedData = []; + $copy->loadedPartner = []; + $copy->persistedFingerprint = null; + $copy->touchState(); + + return $copy; + } + + /** + * Returns everything an edit can change, for undo and redo: both files' + * arrays, the sequence being edited and whether the asset is deleted. + * The payload alone is not enough: it is one sequence of an effect with + * two, and a split leaves it unchanged. + * + * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps: array, sourceTemplates: array}>} + */ + public function captureEditState(): array + { + $templates = []; + if ($this->readOnlyReason === null) { + if ($this->type->hasDataFile()) { + $templates['data'] = ['source' => $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data'), 'values' => $this->data]; + } + $templates[$this->type->partnerNoun()] = ['source' => $this->proposedSource($this->partnerDocument, $this->loadedPartner, + $this->partner, $this->type->partnerNoun()), 'values' => $this->partner]; + } + + return ['data' => $this->data, 'partner' => $this->partner, 'view' => $this->presentationView, 'deleted' => $this->isDeleted, + 'pacedFps' => $this->pacedFps, 'sourceTemplates' => $templates]; + } + + /** + * Puts the asset back to a state `captureEditState` returned. + * + * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool, pacedFps?: array, sourceTemplates?: array}>} $state + */ + public function restoreEditState(array $state): void + { + // Retain the actual last-saved baseline for conflict checks and + // changed-reference validation, while recovering authored syntax. + $this->sourceTemplates = $state['sourceTemplates'] ?? []; + $this->presentationView = $state['view']; + $this->pacedFps = $state['pacedFps'] ?? []; + $this->markDeleted($state['deleted']); + + if ($this->readOnlyReason !== null || ($state['data'] === $this->data && $state['partner'] === $this->partner)) { + return; + } + + $this->data = $state['data']; + $this->partner = $state['partner']; + $this->shape = CutscenePairShape::of($this->type, $this->data, $this->partner); + $this->touchState(); + } + + /** + * A sequence the battle paces has no FPS of its own: the battle phase it + * plays in sets its timing, and the Engine refuses one. Choosing + * battle_phase cadence takes the sequence's FPS out and keeps it; + * choosing fixed again puts it back, first, as sequences write it. + * + * @param array $payload + * @return array + */ + private function settleEffectCadence(array $payload): array + { + $sequence = $this->hasPresentations() ? $this->presentationView->value : 'flat'; + + if (($payload['cadence'] ?? null) === EffectCadence::BATTLE_PHASE->value) { + if (array_key_exists('fps', $payload)) { + $this->pacedFps[$sequence] = $payload['fps']; + unset($payload['fps']); + } + } elseif (! array_key_exists('fps', $payload) && array_key_exists($sequence, $this->pacedFps)) { + $payload = ['fps' => $this->pacedFps[$sequence], ...$payload]; + unset($this->pacedFps[$sequence]); + } + + return $payload; + } + + /** + * @param array $timeline + * @return array + */ + private function getPresentationSequence(array $timeline): array + { + $sequence = $timeline['presentations'][$this->presentationView->value] ?? []; + + return is_array($sequence) ? $sequence : []; + } + /** * Takes an edited payload back into the data and partner arrays. * @@ -341,8 +669,53 @@ public function apply(array $payload): void // including the ones an author was not editing. return; } + $this->assertSourcesUnchanged(); - [$data, $partner] = $this->shape->split($payload); + if (! $this->type->hasDataFile()) { + unset($payload['id']); + } + + if ($this->type === CutsceneType::EFFECT) { + $payload = $this->settleEffectCadence($payload); + } + + unset($payload[self::SEQUENCE_KEY]); + + if ($this->hasPresentations()) { + // The edited sequence goes back into its place; the other + // sequence, and the keys of this one, keep their order. + unset($payload[self::ORIGIN_KEY]); + $old = $this->getPresentationSequence($this->partner); + $edited = $this->type === CutsceneType::EFFECT + ? $payload + : array_filter($payload, static fn(string $key): bool => array_key_exists($key, $old) + || (in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) && ! in_array($key, SummonCutsceneDefinition::PAIRED_TIMELINE_FIELDS, true)), + ARRAY_FILTER_USE_KEY); + $presentations = $this->partner['presentations']; + $presentations[$this->presentationView->value] = array_intersect_key(array_replace(array_flip(array_keys($old)), $edited), $edited); + + if ($this->type === CutsceneType::EFFECT) { + $partner = [...$this->partner, 'presentations' => $presentations]; + $data = $this->data; + } else { + // A summon's definition and the timeline's own keys split as a flat pair does. + [$data, $partner] = $this->shape->split([...array_diff_key($payload, $edited), 'presentations' => $presentations]); + } + } else { + [$data, $partner] = $this->shape->split($payload); + } + + if ($data === $this->data && $partner === $this->partner) { + return; + } + + // The files keep their authored key order, as a map's data does: the + // asset holds each side as its rewritten source reads back. + if ($this->type->hasDataFile()) { + $data = $this->alignWithProposedSource($this->dataDocument, $this->loadedData, $data, 'data'); + } + + $partner = $this->alignWithProposedSource($this->partnerDocument, $this->loadedPartner, $partner, $this->type->partnerNoun()); if ($data === $this->data && $partner === $this->partner) { return; @@ -353,6 +726,20 @@ public function apply(array $payload): void $this->touchState(); } + /** + * Returns one side of the pair in the key order its rewritten source + * writes it. + * + * @param array $loaded What the file holds now. + * @param array $value What the side should hold. + * @return array + * @throws RuntimeException When the source cannot take the change; nothing is changed. + */ + private function alignWithProposedSource(?PhpArraySourceDocument $document, array $loaded, array $value, string $noun): array + { + return PhpArraySourceDocument::parse($this->proposedSource($document, $loaded, $value, $noun))->alignValue($value); + } + /** * Records the arrays the files hold and which file each key came from. * @@ -368,6 +755,52 @@ private function adoptArrays(array $data, array $partner): void $this->shape = CutscenePairShape::of($this->type, $data, $partner); } + /** Checks the owner's loaded source set, allowing recovery but never external replacement. */ + public function assertSourcesUnchanged(): void + { + $this->createSourceTransaction()->assertSourcesUnchanged(); + } + + private function createSourceTransaction(): FileSetTransaction + { + $transaction = new FileSetTransaction($this->folder); + $this->expectLoadedSources($transaction); + return $transaction; + } + + /** Requires each member to hold what it was loaded with. */ + private function expectLoadedSources(FileSetTransaction $transaction): void + { + foreach ($this->paths() as $path) { + // Null requires an unwritten/deleted member to remain absent. + $transaction->expectSource($path, $this->baselineSources[$path] ?? null, allowMissing: true); + } + } + + /** Checks both source edits without writing, so unsupported expressions never enter history. */ + public function assertSourceAccepts(): void + { + $this->assertSourcesUnchanged(); + if ($this->type->hasDataFile()) { + $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data'); + } + $this->proposedSource($this->partnerDocument, $this->loadedPartner, $this->partner, $this->type->partnerNoun()); + } + + /** Changed rest bindings must be complete; untouched graphical references survive Terminal edits. */ + public function assertInnPresentationsValid(?ReferenceCatalog $references = null): void + { + InnPresentationFields::assertChangedCommandsValid($this->loadedData, $this->data, $references); + InnPresentationFields::assertChangedCommandsValid($this->loadedPartner, $this->partner, $references); + } + + /** Only changed/new route paths must be complete before either source can be installed. */ + public function assertMovementRoutesValid(): void + { + \Ichiloto\Editor\Database\MovementRouteFields::assertChangedCommandsValid($this->loadedData, $this->data); + \Ichiloto\Editor\Database\MovementRouteFields::assertChangedCommandsValid($this->loadedPartner, $this->partner); + } + /** * Writes both files as one logical transaction. * @@ -379,75 +812,91 @@ private function adoptArrays(array $data, array $partner): void * file it had already touched. A deleted asset's pair is removed through * the same boundary. Nothing here advances until the whole operation * succeeds, so the pair on disk is always the complete old one or the - * complete new one. + * complete new one. {@see prepareSave()} works the save out; a change + * set installs it with every other document it changed. * * @param callable(string ...$paths): void|null $backup Called with the paths about to be overwritten, before they are. + * @param ReferenceCatalog|null $references The workspace's live authoring references. * @return bool True when anything was written. * @throws FileSetTransactionFailure When the pair could not be installed. */ - public function save(?callable $backup = null): bool + public function save(?callable $backup = null, ?ReferenceCatalog $references = null): bool + { + $prepared = $this->prepareSave($references); + + return $prepared !== null && SaveSet::commitOne($this->folder, $prepared, $backup) !== []; + } + + /** + * Works out this asset's save without writing it: both proposed sources, + * each touched only where it changed, and the proof that each staged + * file reads back as the edited side and hydrates through the engine. A + * deleted asset's save removes its pair. {@see SaveSet} installs it, + * alone or with the rest of a change set. Null when there is nothing to + * save. + * + * @param ReferenceCatalog|null $references The workspace's live authoring references. + * @throws RuntimeException When the asset is read-only or its sources cannot take the edits; nothing is written. + */ + public function prepareSave(?ReferenceCatalog $references = null): ?PreparedSave { + $this->assertSourcesUnchanged(); + $label = sprintf('%s %s', $this->type->noun(), $this->id); if ($this->isDeleted) { - return $this->delete($backup); + return $this->prepareDeletion($label); } - if (! $this->isDirty() && ! $this->isNew) { - return false; + $missing = array_any($this->paths(), static fn(string $path): bool => ! is_file($path)); + if (! $this->isDirty() && ! $this->isNew && ! $missing) { + return null; } if ($this->readOnlyReason !== null) { throw new RuntimeException(sprintf('%s "%s" is read-only: %s.', ucfirst($this->type->noun()), $this->id, $this->readOnlyReason)); } - // 1. Both proposed sources, each touched only where it changed. - $dataSource = $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data'); + $this->assertInnPresentationsValid($references); + $this->assertMovementRoutesValid(); + + $hasData = $this->type->hasDataFile(); + $dataSource = $hasData ? $this->proposedSource($this->dataDocument, $this->loadedData, $this->data, 'data') : ''; $partnerSource = $this->proposedSource($this->partnerDocument, $this->loadedPartner, $this->partner, $this->type->partnerNoun()); - $writeData = $this->isNew || $this->dataDocument === null || $dataSource !== $this->dataDocument->source; - $writePartner = $this->isNew || $this->partnerDocument === null || $partnerSource !== $this->partnerDocument->source; + $writeData = $hasData && (! is_file($this->dataPath()) || $this->isNew || $this->dataDocument === null || $dataSource !== $this->dataDocument->source); + $writePartner = ! is_file($this->partnerPath()) || $this->isNew || $this->partnerDocument === null || $partnerSource !== $this->partnerDocument->source; if (! $writeData && ! $writePartner) { // Dirty by fingerprint but identical in source: a same-value // round trip. Clean without writing. - $this->adoptWritten($this->dataDocument?->source ?? $dataSource, $this->partnerDocument?->source ?? $partnerSource); - - return false; - } - - $transaction = new FileSetTransaction($this->folder); - - if ($writeData) { - $transaction->write($this->dataPath(), $dataSource); - } + $kept = [$this->dataDocument?->source ?? $dataSource, $this->partnerDocument?->source ?? $partnerSource]; - if ($writePartner) { - $transaction->write($this->partnerPath(), $partnerSource); + return PreparedSave::adoptOnly($label, fn() => $this->adoptWritten(...$kept)); } - // 2. Stage both, then read back the exact bytes that will be - // installed -- evaluated where the game would evaluate them. - $staged = $transaction->stage(); - - try { - $evaluatedData = $this->evaluateSide($staged[$this->dataPath()] ?? $this->dataPath(), $this->data, basename($this->dataPath()), 'data'); - $evaluatedPartner = $this->evaluateSide($staged[$this->partnerPath()] ?? $this->partnerPath(), $this->partner, basename($this->partnerPath()), $this->type->partnerNoun()); - - // 3. Hydrate and compile through the engine. - $this->hydrate($evaluatedData, $evaluatedPartner); - } catch (Throwable $throwable) { - // Nothing has been installed: drop the staged copies and leave - // both files exactly as they were. - $transaction->rollBack(); - - throw $throwable; - } - - // 4. Back up once, then install the pair or restore it. - $transaction->commit($backup); - - $this->adoptWritten($dataSource, $partnerSource); - $this->isNew = false; - - return true; + return new PreparedSave( + $label, + function (FileSetTransaction $transaction) use ($writeData, $writePartner, $dataSource, $partnerSource): void { + $this->expectLoadedSources($transaction); + if ($writeData) { + $transaction->write($this->dataPath(), $dataSource); + } + if ($writePartner) { + $transaction->write($this->partnerPath(), $partnerSource); + } + }, + // The exact bytes that will be installed, read back where the + // game would evaluate them, then hydrated through the engine. + function (array $staged) use ($hasData): void { + $evaluatedData = $hasData + ? $this->evaluateSide($staged[$this->dataPath()] ?? $this->dataPath(), $this->data, basename($this->dataPath()), 'data') + : []; + $evaluatedPartner = $this->evaluateSide($staged[$this->partnerPath()] ?? $this->partnerPath(), $this->partner, basename($this->partnerPath()), $this->type->partnerNoun()); + $this->hydrate($evaluatedData, $evaluatedPartner); + }, + function () use ($dataSource, $partnerSource): void { + $this->adoptWritten($dataSource, $partnerSource); + $this->isNew = false; + }, + ); } /** @@ -486,7 +935,43 @@ public function hydrate(?array $data = null, ?array $partner = null): void return; } - CutsceneHydration::compileSummon($data, $partner, $this->projectRoot); + if ($this->type === CutsceneType::EFFECT) { + CutsceneHydration::checkEffect($this->id, $partner, $this->projectRoot); + + return; + } + + // Both renderers play a summon; each must accept it as it stands. + foreach (EffectPresentation::cases() as $presentation) { + CutsceneHydration::compileSummon($data, $partner, $this->projectRoot, $presentation); + } + } + + /** + * Compiles the effect as it stands in memory, unsaved edits included, as + * battle, the field or an owned stage plays it in one presentation. + * + * @throws RuntimeException When the Engine refuses it, or for another type. + */ + public function compileEffect(EffectPresentation $presentation, bool $forBattle, bool $forStage = false): CompiledEffectTimeline + { + if ($this->type !== CutsceneType::EFFECT) { + throw new RuntimeException(sprintf('%s is a %s, not an effect.', $this->id, $this->type->noun())); + } + + return CutsceneHydration::compileEffect($this->id, $this->partner, $presentation, $forBattle, $this->projectRoot, $forStage); + } + + /** Preserves the existing battle/field API while new consumers use the action-named compiler. */ + public function compiledEffect(EffectPresentation $presentation, bool $forBattle): CompiledEffectTimeline + { + return $this->compileEffect($presentation, $forBattle); + } + + /** Whether this effect declares its own graphical space, independent of its selected sequence. */ + public function isOwnedStageEffect(): bool + { + return $this->type === CutsceneType::EFFECT && CutsceneHydration::isOwnedStage($this->partner); } /** @@ -504,17 +989,32 @@ public function cinematicDefinition(): CinematicDefinition } /** - * Compiles the summon as it stands in memory, unsaved edits included. + * Hydrates the summon as it stands in memory, unsaved edits included. + * + * @throws RuntimeException When the Engine refuses it, or for a cinematic. + */ + public function summonDefinition(): SummonCutsceneDefinition + { + if ($this->type !== CutsceneType::SUMMON) { + throw new RuntimeException(sprintf('%s is a cinematic, not a summon.', $this->id)); + } + + return CutsceneHydration::summon($this->data, $this->partner, $this->projectRoot); + } + + /** + * Compiles the summon as it stands in memory, unsaved edits included, as + * one renderer plays it: the terminal by default, as the editors preview it. * * @throws RuntimeException When the Engine refuses it, or for a cinematic. */ - public function compiledSummon(): SummonCompiledCutscene + public function compiledSummon(EffectPresentation $presentation = EffectPresentation::TERMINAL): SummonCompiledCutscene { if ($this->type !== CutsceneType::SUMMON) { throw new RuntimeException(sprintf('%s is a cinematic, not a summon.', $this->id)); } - return CutsceneHydration::compileSummon($this->data, $this->partner, $this->projectRoot); + return CutsceneHydration::compileSummon($this->data, $this->partner, $this->projectRoot, $presentation); } /** @@ -527,27 +1027,38 @@ public function compiledSummon(): SummonCompiledCutscene * @param callable(string ...$paths): void|null $backup * @throws FileSetTransactionFailure When the pair could not be removed. */ - private function delete(?callable $backup): bool + /** + * The save that removes a deleted asset's pair. Only the two files are + * the asset's; anything else in the folder is left for its author, and + * the folder goes only when empty. + */ + private function prepareDeletion(string $label): PreparedSave { - if (! is_dir($this->folder)) { - // Never written: nothing on disk to take away. + $forget = function (): void { + $this->baselineSources = []; $this->captureBaseline(); + }; - return false; - } - - $transaction = new FileSetTransaction($this->folder); - - foreach ($this->paths() as $path) { - $transaction->remove($path); + if (! is_dir($this->folder)) { + // Never written: nothing on disk to take away. + return PreparedSave::adoptOnly($label, $forget); } - // Only the two files are the asset's; anything else in the folder is - // left for its author, and the folder goes only when empty. - $transaction->commit($backup); - $this->captureBaseline(); - - return true; + return new PreparedSave( + $label, + function (FileSetTransaction $transaction): void { + $this->expectLoadedSources($transaction); + foreach ($this->paths() as $path) { + $transaction->remove($path); + } + }, + adopt: function () use ($forget): void { + if (is_dir($this->folder) && (scandir($this->folder) ?: []) === ['.', '..']) { + @rmdir($this->folder); + } + $forget(); + }, + ); } /** @@ -558,6 +1069,11 @@ private function delete(?callable $backup): bool */ private function proposedSource(?PhpArraySourceDocument $document, array $loaded, array $current, string $noun): string { + if (isset($this->sourceTemplates[$noun])) { + $template = $this->sourceTemplates[$noun]; + $document = PhpArraySourceDocument::parse($template['source']); + $loaded = $template['values']; + } if ($document === null) { // No file yet: a new asset's file is written whole, in the // editor's own layout. @@ -576,7 +1092,10 @@ private function proposedSource(?PhpArraySourceDocument $document, array $loaded */ private function adoptWritten(string $dataSource, string $partnerSource): void { - $this->dataDocument = PhpArraySourceDocument::parse($dataSource); + $this->baselineSources = $this->type->hasDataFile() + ? [$this->dataPath() => $dataSource, $this->partnerPath() => $partnerSource] + : [$this->partnerPath() => $partnerSource]; + $this->dataDocument = $this->type->hasDataFile() ? PhpArraySourceDocument::parse($dataSource) : null; $this->partnerDocument = PhpArraySourceDocument::parse($partnerSource); $this->loadedData = $this->data; $this->loadedPartner = $this->partner; diff --git a/src/Cutscenes/CutsceneHydration.php b/src/Cutscenes/CutsceneHydration.php index fb05eb5f..88b98d9d 100644 --- a/src/Cutscenes/CutsceneHydration.php +++ b/src/Cutscenes/CutsceneHydration.php @@ -5,6 +5,9 @@ namespace Ichiloto\Editor\Cutscenes; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition; use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneCompiler; @@ -56,21 +59,110 @@ public static function summon(array $data, array $timeline, ?string $projectRoot } /** - * Compiles a summon through the engine's own compiler. + * Compiles a summon through the engine's own compiler, as one renderer + * plays it: the terminal never needs the graphical art, and the + * graphical presentation checks its images against the project's assets. * * @param array $data The data file's array. * @param array $timeline The timeline file's array. */ - public static function compileSummon(array $data, array $timeline, ?string $projectRoot = null): SummonCompiledCutscene + public static function compileSummon(array $data, array $timeline, ?string $projectRoot = null, + EffectPresentation $presentation = EffectPresentation::TERMINAL): SummonCompiledCutscene { return self::inProject( $projectRoot, - static fn(): SummonCompiledCutscene => new SummonCutsceneCompiler()->compile( + static fn(): SummonCompiledCutscene => new SummonCutsceneCompiler(assetRoot: self::getAssetRoot($projectRoot))->compile( SummonCutsceneDefinition::fromArrays($data, $timeline), + $presentation, ), ); } + /** + * Compiles an effect timeline through the Engine's effect library, as + * battle, the field or an owned stage plays it in one presentation. + * + * @param array $timeline The timeline file's array. + */ + public static function compileEffect( + string $id, + array $timeline, + EffectPresentation $presentation, + bool $forBattle, + ?string $projectRoot = null, + bool $forStage = false, + ): CompiledEffectTimeline { + // A paired Terminal sequence owns its cues and tracks, not the + // graphical consumer's stage/audio lifecycle. + if ($forStage && ! $forBattle && $presentation === EffectPresentation::TERMINAL && isset($timeline['presentations'])) { + try { + return self::compileEffect($id, $timeline, $presentation, false, $projectRoot); + } catch (RuntimeException $fieldFailure) { + try { + return self::compileEffect($id, $timeline, $presentation, true, $projectRoot); + } catch (RuntimeException $battleFailure) { + throw new RuntimeException(sprintf('Independent Terminal timeline is not admitted: %s; %s', + $fieldFailure->getMessage(), $battleFailure->getMessage()), previous: $battleFailure); + } + } + } + return self::inProject( + $projectRoot, + static fn(): CompiledEffectTimeline => new EffectTimelineLibrary(self::getAssetRoot($projectRoot)) + ->compile($id, $timeline, $forBattle, $presentation, forStage: $forStage), + ); + } + + /** + * Proves an effect timeline can be played: each presentation must + * compile in an admitted context. An authored stage owns its space; + * battle/field admission cannot substitute for that ownership. + * + * @param array $timeline The timeline file's array. + * @throws RuntimeException Naming each presentation neither accepts, with the Engine's reasons. + */ + public static function checkEffect(string $id, array $timeline, ?string $projectRoot = null): void + { + $failures = []; + + foreach (EffectPresentation::cases() as $presentation) { + $reasons = []; + + $contexts = self::isOwnedStage($timeline) ? ['stage'] : ['battle', 'field']; + foreach ($contexts as $context) { + try { + self::compileEffect($id, $timeline, $presentation, $context === 'battle', $projectRoot, $context === 'stage'); + $reasons = []; + break; + } catch (RuntimeException $exception) { + $reasons[] = sprintf('%s: %s', $context, $exception->getMessage()); + } + } + + if ($reasons !== []) { + $failures[] = sprintf('the %s presentation plays nowhere (%s)', $presentation->value, implode('; ', $reasons)); + } + } + + if ($failures !== []) { + throw new RuntimeException(sprintf('Effect %s cannot be played: %s.', $id, implode('; ', $failures))); + } + } + + /** Stage ownership belongs to the complete asset, not the tab being inspected. */ + public static function isOwnedStage(array $timeline): bool + { + $graphical = $timeline['presentations'][EffectPresentation::GRAPHICAL->value] ?? $timeline; + + return is_array($graphical) && array_key_exists('stage', $graphical); + } + + /** The asset root the Engine resolves an effect's images against. */ + private static function getAssetRoot(?string $projectRoot): string + { + return rtrim($projectRoot ?? (string) getcwd(), '/') . '/assets'; + } + /** * Returns whether the engine's cutscene classes are reachable from this * checkout at all. diff --git a/src/Cutscenes/CutsceneLaneOverview.php b/src/Cutscenes/CutsceneLaneOverview.php index cc3f5e21..5fdbb309 100644 --- a/src/Cutscenes/CutsceneLaneOverview.php +++ b/src/Cutscenes/CutsceneLaneOverview.php @@ -4,6 +4,10 @@ namespace Ichiloto\Editor\Cutscenes; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Editor\Database\ProjectCatalogs; +use Throwable; + /** * A duration projection over a cinematic's command tree: how long each * command, lane and block is authored to take, and where the play waits on @@ -33,12 +37,13 @@ private function __construct(public readonly array $rows, public readonly float * Builds the overview of a command list. * * @param array $commands + * @param ProjectCatalogs|null $effects The project's catalogues, so a field effect's length is known as the workspace holds it. */ - public static function of(array $commands, string $rootKey = 'commands'): self + public static function of(array $commands, string $rootKey = 'commands', ?ProjectCatalogs $effects = null): self { /** @var array $rows */ $rows = []; - [$seconds, $marks] = self::walkList($commands, $rootKey, 0, $rows); + [$seconds, $marks] = self::walkList($commands, $rootKey, 0, $rows, $effects); return new self($rows, $seconds, $marks); } @@ -72,7 +77,7 @@ public static function describe(float $seconds, array $marks): string * @param array> $rows * @return array{0: float, 1: string[]} */ - private static function walkList(array $commands, string $prefix, int $depth, array &$rows): array + private static function walkList(array $commands, string $prefix, int $depth, array &$rows, ?ProjectCatalogs $effects): array { $total = 0.0; $marks = []; @@ -83,7 +88,7 @@ private static function walkList(array $commands, string $prefix, int $depth, ar } $key = $prefix . '.' . $index; - [$seconds, $commandMarks] = self::walkCommand($command, $key, $depth, $rows); + [$seconds, $commandMarks] = self::walkCommand($command, $key, $depth, $rows, $effects); $total += $seconds; $marks = [...$marks, ...$commandMarks]; } @@ -96,7 +101,7 @@ private static function walkList(array $commands, string $prefix, int $depth, ar * @param array> $rows * @return array{0: float, 1: string[]} */ - private static function walkCommand(array $command, string $key, int $depth, array &$rows): array + private static function walkCommand(array $command, string $key, int $depth, array &$rows, ?ProjectCatalogs $effects): array { $type = strval($command['type'] ?? '?'); @@ -115,7 +120,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr $laneRowIndex = count($rows); $laneKey = $key . '.lanes.' . $laneIndex; $rows[] = ['depth' => $depth + 1, 'key' => $laneKey, 'label' => 'lane ' . $laneId, 'seconds' => 0.0, 'marks' => [], 'kind' => 'lane']; - [$laneSeconds, $laneMarks] = self::walkList($laneCommands, $laneKey, $depth + 2, $rows); + [$laneSeconds, $laneMarks] = self::walkList($laneCommands, $laneKey, $depth + 2, $rows, $effects); $rows[$laneRowIndex]['seconds'] = $laneSeconds; $rows[$laneRowIndex]['marks'] = $laneMarks; $longest = max($longest, $laneSeconds); @@ -130,7 +135,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr case 'sequence': $rowIndex = count($rows); $rows[] = ['depth' => $depth, 'key' => $key, 'label' => 'sequence', 'seconds' => 0.0, 'marks' => [], 'kind' => 'block']; - [$seconds, $marks] = self::walkList(is_array($command['commands'] ?? null) ? $command['commands'] : [], $key . '.commands', $depth + 1, $rows); + [$seconds, $marks] = self::walkList(is_array($command['commands'] ?? null) ? $command['commands'] : [], $key . '.commands', $depth + 1, $rows, $effects); $rows[$rowIndex]['seconds'] = $seconds; $rows[$rowIndex]['marks'] = $marks; @@ -149,7 +154,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr $armRowIndex = count($rows); $rows[] = ['depth' => $depth + 1, 'key' => $key . '.' . $arm, 'label' => $arm, 'seconds' => 0.0, 'marks' => [], 'kind' => 'arm']; - [$armSeconds, $armMarks] = self::walkList($command[$arm], $key . '.' . $arm, $depth + 2, $rows); + [$armSeconds, $armMarks] = self::walkList($command[$arm], $key . '.' . $arm, $depth + 2, $rows, $effects); $rows[$armRowIndex]['seconds'] = $armSeconds; $rows[$armRowIndex]['marks'] = $armMarks; $longest = max($longest, $armSeconds); @@ -175,7 +180,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr $optionKey = $key . '.options.' . $optionIndex; $optionRowIndex = count($rows); $rows[] = ['depth' => $depth + 1, 'key' => $optionKey, 'label' => sprintf('option %d', $optionIndex + 1), 'seconds' => 0.0, 'marks' => [], 'kind' => 'arm']; - [$optionSeconds, $optionMarks] = self::walkList(is_array($option['then'] ?? null) ? $option['then'] : [], $optionKey, $depth + 2, $rows); + [$optionSeconds, $optionMarks] = self::walkList(is_array($option['then'] ?? null) ? $option['then'] : [], $optionKey, $depth + 2, $rows, $effects); $rows[$optionRowIndex]['seconds'] = $optionSeconds; $rows[$optionRowIndex]['marks'] = $optionMarks; $longest = max($longest, $optionSeconds); @@ -185,7 +190,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr if (is_array($command['cancel'] ?? null)) { $cancelRowIndex = count($rows); $rows[] = ['depth' => $depth + 1, 'key' => $key . '.cancel', 'label' => 'cancel', 'seconds' => 0.0, 'marks' => [], 'kind' => 'arm']; - [$cancelSeconds, $cancelMarks] = self::walkList($command['cancel'], $key . '.cancel', $depth + 2, $rows); + [$cancelSeconds, $cancelMarks] = self::walkList($command['cancel'], $key . '.cancel', $depth + 2, $rows, $effects); $rows[$cancelRowIndex]['seconds'] = $cancelSeconds; $rows[$cancelRowIndex]['marks'] = $cancelMarks; $longest = max($longest, $cancelSeconds); @@ -198,7 +203,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr return [$longest, $rows[$rowIndex]['marks']]; } - [$seconds, $marks] = self::estimate($command); + [$seconds, $marks] = self::estimate($command, $effects); $rows[] = ['depth' => $depth, 'key' => $key, 'label' => CutsceneOutline::summarize($command), 'seconds' => $seconds, 'marks' => $marks, 'kind' => 'command']; return [$seconds, $marks]; @@ -210,7 +215,7 @@ private static function walkCommand(array $command, string $key, int $depth, arr * @param array $command * @return array{0: float, 1: string[]} */ - public static function estimate(array $command): array + public static function estimate(array $command, ?ProjectCatalogs $effects = null): array { $type = strval($command['type'] ?? ''); $seconds = static fn(string $key, float $default): float => max(0.0, is_numeric($command[$key] ?? null) ? floatval($command[$key]) : $default); @@ -224,7 +229,9 @@ public static function estimate(array $command): array 'common_event' => [0.0, [self::UNKNOWN]], 'move_route' => [self::routeSeconds($command), []], 'camera' => [self::cameraSeconds($command), []], - 'field_animation' => [self::animationSeconds($command), []], + 'field_animation' => array_key_exists('effect', $command) + ? self::effectSeconds($command['effect'], $effects) + : [self::animationSeconds($command), []], 'cinematic_music' => [max($seconds('fadeIn', 0.0), 0.0), []], default => [0.0, []], }; @@ -280,6 +287,34 @@ private static function cameraSeconds(array $command): float /** * @param array $command */ + /** + * The length of a field effect as its timeline is authored: the longer + * of its terminal and graphical sequences, since the play waits for the + * presentation in use. Without the timeline it is marked, not guessed. + * + * @return array{0: float, 1: string[]} + */ + private static function effectSeconds(mixed $effect, ?ProjectCatalogs $effects): array + { + if (! is_string($effect) || $effects === null) { + return [0.0, [self::UNKNOWN]]; + } + + $seconds = 0.0; + + foreach (EffectPresentation::cases() as $presentation) { + try { + $timeline = $effects->loadEffectTimeline($effect, false, $presentation); + } catch (Throwable) { + return [0.0, [self::UNKNOWN]]; + } + + $seconds = max($seconds, intval($timeline->defaults['lengthFrames'] ?? 0) / max(1, $timeline->fps)); + } + + return [$seconds, []]; + } + private static function animationSeconds(array $command): float { $frames = is_array($command['frames'] ?? null) ? count($command['frames']) : 0; diff --git a/src/Cutscenes/CutsceneLibrary.php b/src/Cutscenes/CutsceneLibrary.php index 4d6b08dc..941e9a5f 100644 --- a/src/Cutscenes/CutsceneLibrary.php +++ b/src/Cutscenes/CutsceneLibrary.php @@ -5,9 +5,14 @@ namespace Ichiloto\Editor\Cutscenes; use Closure; +use Ichiloto\Editor\Database\ProjectRecord; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\History\Command; +use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Storage\PreparedSave; use RuntimeException; use Throwable; @@ -31,6 +36,13 @@ */ final class CutsceneLibrary { + private ?ReferenceCatalog $authoringReferences = null; + + public function useAuthoringReferences(ReferenceCatalog $references): void + { + $this->authoringReferences = $references; + foreach ($this->databases as $database) { $database->useAuthoringReferences($references); } + } /** * @var array Assets by type value, in list order. */ @@ -109,14 +121,19 @@ private function discover(CutsceneType $type): void $hasData = is_file($dataPath); $hasPartner = is_file($partnerPath); - if (! $hasData || ! $hasPartner) { + if (($type->hasDataFile() && ! $hasData) || ! $hasPartner) { + // A folder of resources with no PHP at all, such as artwork several cutscenes share, is not a + // cutscene; the game passes over it as well. An empty folder or half a pair is reported. + if (! $hasData && ! $hasPartner && self::holdsOnlyResources($folder)) { + continue; + } $this->reportIncomplete($type, $folder, $entry, $hasData, $hasPartner); continue; } - if (preg_match('/^[a-z0-9][a-z0-9._-]*$/', $entry) !== 1) { - $this->issues[$type->value][] = ['folder' => $entry, 'message' => sprintf('The folder name "%s" is not a stable id (lowercase letters, digits, ".", "_" and "-").', $entry)]; + if (preg_match($type->getIdPattern(), $entry) !== 1) { + $this->issues[$type->value][] = ['folder' => $entry, 'message' => sprintf('The folder name "%s" is not a stable id (%s).', $entry, $type->describeIdCharacters())]; } $lower = strtolower($entry); @@ -143,13 +160,27 @@ private function discover(CutsceneType $type): void } } + /** Whether a folder holds something, none of it a PHP file, at any depth. */ + private static function holdsOnlyResources(string $folder): bool + { + $found = false; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folder, \FilesystemIterator::SKIP_DOTS)) as $file) { + if (str_ends_with($file->getFilename(), '.php')) { + return false; + } + $found = true; + } + + return $found; + } + /** * Reports a folder that holds part of a pair, and says which part. */ private function reportIncomplete(CutsceneType $type, string $folder, string $entry, bool $hasData, bool $hasPartner): void { $files = array_values(array_filter(scandir($folder) ?: [], static fn(string $file): bool => str_ends_with($file, '.php'))); - $missing = ! $hasData ? $entry . '.data.php' : $entry . $type->partnerSuffix(); + $missing = $type->hasDataFile() && ! $hasData ? $entry . '.data.php' : $entry . $type->partnerSuffix(); $misnamed = array_values(array_filter( $files, static fn(string $file): bool => (str_ends_with($file, '.data.php') || str_ends_with($file, $type->partnerSuffix())) @@ -168,13 +199,9 @@ private function reportIncomplete(CutsceneType $type, string $folder, string $en return; } - $this->issues[$type->value][] = ['folder' => $entry, 'message' => sprintf( - 'Folder "%s" is missing %s; a %s is a data file and a %s together.', - $entry, - $missing, - $type->noun(), - $type->partnerNoun(), - )]; + $this->issues[$type->value][] = ['folder' => $entry, 'message' => $type->hasDataFile() + ? sprintf('Folder "%s" is missing %s; a %s is a data file and a %s together.', $entry, $missing, $type->noun(), $type->partnerNoun()) + : sprintf('Folder "%s" is missing %s; an %s is its %s file.', $entry, $missing, $type->noun(), $type->partnerNoun())]; } /** @@ -264,9 +291,11 @@ public function dirtyAssets(): array * Returns the record category editing a type's assets, built over the * library and written back into it on every change. */ - public function records(CutsceneType $type): ProjectRecordDatabase + public function records(CutsceneType $type, bool $graphical = false): ProjectRecordDatabase { - return $this->databases[$type->value] ??= $this->buildRecords($type); + $key = $type->value . ($graphical ? ':graphical' : ''); + + return $this->databases[$key] ??= $this->buildRecords($type, $graphical); } /** @@ -277,34 +306,154 @@ public function records(CutsceneType $type): ProjectRecordDatabase public function refreshRecords(CutsceneType $type): void { $this->databases[$type->value] = $this->buildRecords($type); + unset($this->databases[$type->value . ':graphical']); + } + + /** + * Changes one asset through its type's records, the one way every + * interface does: the change runs against the records, is taken into + * the asset at once, and comes back as the command that undoes and redoes + * it by restoring the asset's whole edit state, pinned to that asset + * whatever is selected when the history fires. Null when nothing changed. + * + * @template T + * @param int $index The asset's place among its type's records. + * @param callable(ProjectRecordDatabase, int): T $change The change, against the records and the record index. + * @param (Closure(CutsceneAsset): void)|null $restored Told when an undo or redo puts the asset back, for an interface to follow. + * @return array{command: ?Command, result: T} + * @throws RuntimeException When the asset is missing or read-only; nothing is changed. + * @throws Throwable Whatever the change throws; the records are rebuilt from the untouched asset. + */ + public function changeAsset(CutsceneType $type, int $index, string $label, callable $change, ?Closure $restored = null, bool $graphical = false): array + { + $id = $this->ids($type)[$index] ?? throw new RuntimeException(sprintf('There is no %s %d.', $type->noun(), $index)); + $asset = $this->find($type, $id) ?? throw new RuntimeException(sprintf('There is no %s %s.', $type->noun(), $id)); + if (! $asset->isEditable()) { + throw new RuntimeException(sprintf('%s is read-only: %s.', ucfirst($type->noun()), $asset->readOnlyReason())); + } + $asset->assertSourcesUnchanged(); + $records = $this->records($type, $graphical); + $before = $asset->captureEditState(); + try { + $result = $change($records, $index); + $records->save(); + $asset->assertSourceAccepts(); + } catch (Throwable $failure) { + $asset->restoreEditState($before); + throw $failure; + } finally { + $this->refreshRecords($type); + } + $after = $asset->captureEditState(); + if ($after === $before) { + return ['command' => null, 'result' => $result]; + } + return ['command' => $this->createRestoringCommand($label, $type, $id, $before, $after, $restored), 'result' => $result]; + } + + /** + * Creates a new asset of a type from its schema's blank, under the + * preferred id made free and safe (or the blank's own), and returns it + * with the command that undoes and redoes its creation. It reaches disk + * on save. + * + * @param (Closure(CutsceneAsset): void)|null $restored Told when an undo or redo puts the asset back. + * @return array{asset: CutsceneAsset, command: Command} + */ + public function createAsset(CutsceneType $type, ?string $preferredId = null, ?Closure $restored = null): array + { + $blank = $this->records($type)->schema->blank; + $id = $this->freeId($type, $preferredId ?? strval($blank['id'] ?? ('new-' . $type->noun()))); + $created = CutsceneAsset::create($type, $id, $this->rootFor($type), [...$blank, 'id' => $id], $this->projectRoot); + $this->adopt($created); + + return ['asset' => $created, 'command' => $this->createPresenceCommand(sprintf('Create %s', $type->noun()), $created, $restored)]; + } + + /** + * Duplicates an asset under the preferred id made free and safe (or the + * original's with `-copy`), and returns the copy with the command that + * undoes and redoes the duplication. It reaches disk on save. + * + * @param (Closure(CutsceneAsset): void)|null $restored Told when an undo or redo puts the copy back. + * @return array{asset: CutsceneAsset, command: Command} + * @throws RuntimeException When the asset is missing. + */ + public function duplicateAsset(CutsceneType $type, string $id, ?string $preferredId = null, ?Closure $restored = null): array + { + $copy = $this->duplicate($type, $id, $this->freeId($type, $preferredId ?? $id . '-copy')); + + return ['asset' => $copy, 'command' => $this->createPresenceCommand(sprintf('Duplicate %s', $type->noun()), $copy, $restored)]; } - private function buildRecords(CutsceneType $type): ProjectRecordDatabase + /** The command that takes a just-made asset away on undo and brings it back on redo. */ + private function createPresenceCommand(string $label, CutsceneAsset $asset, ?Closure $restored): Command { - $schema = $this->schemaFor($type); + $state = $asset->captureEditState(); + + return $this->createRestoringCommand($label, $asset->type, $asset->id, [...$state, 'deleted' => true], [...$state, 'deleted' => false], $restored); + } + + /** + * The command that puts an asset into one edit state on redo and another + * on undo, pinned to the asset whatever is selected when it fires. + * + * @param array $before + * @param array $after + * @param (Closure(CutsceneAsset): void)|null $restored + */ + private function createRestoringCommand(string $label, CutsceneType $type, string $id, array $before, array $after, ?Closure $restored): Command + { + $restore = function (array $state) use ($type, $id, $restored): void { + $asset = $this->find($type, $id); + if ($asset === null) { + return; + } + $asset->restoreEditState($state); + $this->refreshRecords($type); + if ($restored !== null) { + $restored($asset); + } + }; + + return new GenericCommand($label, static fn() => $restore($after), static fn() => $restore($before)); + } + + private function buildRecords(CutsceneType $type, bool $graphical = false): ProjectRecordDatabase + { + $schema = $this->schemaFor($type, $graphical); $entries = array_map( static fn(CutsceneAsset $asset): array => $asset->payload(), array_values(array_filter($this->assets($type), static fn(CutsceneAsset $asset): bool => ! $asset->isDeleted())), ); - return ProjectRecordDatabase::overOwnedList( + $records = ProjectRecordDatabase::overOwnedList( $schema, $this->rootFor($type), $entries, function (array $written) use ($type): void { $this->writeBack($type, $written); }, + graphical: $graphical, ); + if ($this->authoringReferences !== null) { $records->useAuthoringReferences($this->authoringReferences); } + // Each record is one asset, keyed by its id: its map-local choices are on the map that asset starts on. + $records->useRecordReferences(function (ProjectRecord $record) use ($type): ?ReferenceCatalog { + $asset = $this->find($type, $record->recordId); + return $asset === null ? null : $this->authoringReferences?->createCutsceneContext($asset); + }); + return $records; } /** * Returns the schema a type's records are edited with. */ - public function schemaFor(CutsceneType $type): RecordSchema + public function schemaFor(CutsceneType $type, bool $graphical = false): RecordSchema { return match ($type) { - CutsceneType::CINEMATIC => RecordSchemaCatalog::cinematics(), + CutsceneType::CINEMATIC => \Ichiloto\Editor\Database\CutsceneSchemas::cinematics($graphical), CutsceneType::SUMMON => RecordSchemaCatalog::summons(), + CutsceneType::EFFECT => \Ichiloto\Editor\Database\CutsceneSchemas::effects($graphical), }; } @@ -407,14 +556,11 @@ public function renameNew(CutsceneType $type, string $id, string $newId): Cutsce throw new RuntimeException(sprintf('A %s "%s" already exists.', $type->noun(), $newId)); } - if (preg_match('/^[a-z0-9][a-z0-9._-]*$/', $newId) !== 1) { - throw new RuntimeException(sprintf('"%s" is not a stable id (lowercase letters, digits, ".", "_" and "-").', $newId)); + if (preg_match($type->getIdPattern(), $newId) !== 1) { + throw new RuntimeException(sprintf('"%s" is not a stable id (%s).', $newId, $type->describeIdCharacters())); } - $payload = $source->payload(); - $payload['id'] = $newId; - unset($payload[CutsceneAsset::ORIGIN_KEY]); - $renamed = CutsceneAsset::create($type, $newId, $this->rootFor($type), $payload, $this->projectRoot); + $renamed = $source->copyAs($newId, $this->rootFor($type)); foreach ($this->assets[$type->value] as $position => $asset) { if ($asset === $source) { @@ -442,14 +588,12 @@ public function duplicate(CutsceneType $type, string $id, string $newId): Cutsce throw new RuntimeException(sprintf('A %s "%s" already exists.', $type->noun(), $newId)); } - if (preg_match('/^[a-z0-9][a-z0-9._-]*$/', $newId) !== 1) { - throw new RuntimeException(sprintf('"%s" is not a stable id (lowercase letters, digits, ".", "_" and "-").', $newId)); + if (preg_match($type->getIdPattern(), $newId) !== 1) { + throw new RuntimeException(sprintf('"%s" is not a stable id (%s).', $newId, $type->describeIdCharacters())); } - $payload = $source->payload(); - $payload['id'] = $newId; - unset($payload[CutsceneAsset::ORIGIN_KEY]); - $copy = CutsceneAsset::create($type, $newId, $this->rootFor($type), $payload, $this->projectRoot); + // Both files as they stand: every sequence, not only the one shown. + $copy = $source->copyAs($newId, $this->rootFor($type)); $this->assets[$type->value][] = $copy; $this->refreshRecords($type); @@ -461,7 +605,9 @@ public function duplicate(CutsceneType $type, string $id, string $newId): Cutsce */ public function freeId(CutsceneType $type, string $preferred): string { - $stem = strtolower(trim(preg_replace('/[^a-z0-9._-]+/i', '-', $preferred) ?? '', '-.')); + // An effect id holds no dots, as the Engine requires. + $allowed = $type === CutsceneType::EFFECT ? '/[^a-z0-9_-]+/i' : '/[^a-z0-9._-]+/i'; + $stem = strtolower(trim(preg_replace($allowed, '-', $preferred) ?? '', '-.')); if ($stem === '' || preg_match('/^[a-z0-9]/', $stem) !== 1) { $stem = 'new-' . $type->noun(); @@ -498,7 +644,7 @@ public function saveAll(?Closure $backup = null): array } try { - $asset->save($backup); + $asset->save($backup, $this->authoringReferences); $saved[] = $type->noun() . ' ' . $asset->id; } catch (Throwable $throwable) { $failed[$type->noun() . ' ' . $asset->id] = $throwable->getMessage(); @@ -511,6 +657,30 @@ public function saveAll(?Closure $backup = null): array return ['saved' => $saved, 'failed' => $failed]; } + /** + * Works out every dirty asset's save without writing any, for a change + * set to install together with the rest of what it changed. + * + * @return list + * @throws RuntimeException When an asset is read-only or its sources cannot take its edits. + */ + public function prepareSaves(): array + { + $saves = []; + + foreach (CutsceneType::cases() as $type) { + foreach ($this->assets($type) as $asset) { + $prepared = $asset->isDirty() ? $asset->prepareSave($this->authoringReferences) : null; + + if ($prepared !== null) { + $saves[] = $prepared->withAdoption(fn() => $this->forgetDeleted($type)); + } + } + } + + return $saves; + } + /** * Saves one asset. * @@ -524,7 +694,7 @@ public function save(CutsceneType $type, string $id, ?Closure $backup = null): b throw new RuntimeException(sprintf('No %s "%s" to save.', $type->noun(), $id)); } - $written = $asset->save($backup); + $written = $asset->save($backup, $this->authoringReferences); $this->forgetDeleted($type); return $written; diff --git a/src/Cutscenes/CutsceneOutline.php b/src/Cutscenes/CutsceneOutline.php index e82795a7..d5c62d48 100644 --- a/src/Cutscenes/CutsceneOutline.php +++ b/src/Cutscenes/CutsceneOutline.php @@ -62,7 +62,7 @@ public static function of(CutsceneType $type, array $payload): self $key = 'tracks.' . $index; $outline->rows[] = [ 'depth' => 1, - 'text' => sprintf('%s %s · %d keyframe%s', strval($track['type'] ?? 'glyph'), strval($track['id'] ?? ('track ' . ($index + 1))), count($keyframes), count($keyframes) === 1 ? '' : 's'), + 'text' => sprintf('%s %s · %d keyframe%s%s', strval($track['type'] ?? 'glyph'), strval($track['id'] ?? ('track ' . ($index + 1))), count($keyframes), count($keyframes) === 1 ? '' : 's', self::describeTrackScope($track)), 'frame' => [CutsceneSchemas::TRACKS_KEY], 'index' => $index, 'key' => $key, @@ -81,7 +81,12 @@ public static function of(CutsceneType $type, array $payload): self $content = is_string($keyframe['content'] ?? null) ? explode("\n", $keyframe['content'])[0] : ''; $outline->rows[] = [ 'depth' => 2, - 'text' => sprintf('f%d +%d %s', $frame, $duration, $content !== '' ? $content : (isset($keyframe['assetId']) ? 'asset ' . strval($keyframe['assetId']) : '(empty)')), + 'text' => sprintf('f%d +%d %s', $frame, $duration, match (true) { + $content !== '' => $content, + isset($keyframe['assetId']) => 'asset ' . strval($keyframe['assetId']), + isset($keyframe['sourceFrame']) => self::describeImageKeyframe($keyframe), + default => '(empty)', + }), 'frame' => [CutsceneSchemas::TRACKS_KEY], 'index' => $index, 'key' => $key . '.keyframes.' . $keyframeIndex, @@ -115,6 +120,39 @@ public static function of(CutsceneType $type, array $payload): self return $outline; } + /** + * Returns where a track plays and which way it is drawn, when authored: + * one presentation only, or a battle stroke's facing. + * + * @param array $track + */ + private static function describeTrackScope(array $track): string + { + $scope = ''; + + if (is_string($track['presentation'] ?? null) && $track['presentation'] !== '') { + $scope .= ' · ' . $track['presentation'] . ' only'; + } + + if (is_string($track['facing'] ?? null) && $track['facing'] !== '') { + $scope .= ' · faces ' . $track['facing']; + } + + return $scope; + } + + /** + * Returns an image keyframe's sheet frame and flips. + * + * @param array $keyframe + */ + private static function describeImageKeyframe(array $keyframe): string + { + $flips = array_keys(array_filter(['flipX' => $keyframe['flipX'] ?? false, 'flipY' => $keyframe['flipY'] ?? false], static fn(mixed $flip): bool => $flip === true)); + + return sprintf('sheet frame %d', intval($keyframe['sourceFrame'])) . ($flips === [] ? '' : ' ' . implode(' ', $flips)); + } + /** * Walks a command list, one row per command, recursing into blocks. * @@ -441,7 +479,7 @@ public static function summarize(array $command): string 'camera' => self::cameraSummary($command), 'stage_actor' => strval((is_array($command['actor'] ?? null) ? $command['actor'] : $command)['id'] ?? '') . self::at(is_array($command['actor'] ?? null) ? $command['actor'] : $command), 'show_actor', 'hide_actor', 'remove_actor' => strval($command['actorId'] ?? $command['id'] ?? ''), - 'field_animation' => strval($command['animation'] ?? $command['id'] ?? '') . self::target($command['target'] ?? null), + 'field_animation' => (is_scalar($command['effect'] ?? null) ? 'effect ' . strval($command['effect']) : strval($command['animation'] ?? $command['id'] ?? '')) . self::target($command['target'] ?? null), 'transition' => strval($command['style'] ?? 'fade') . ' ' . strval($command['direction'] ?? 'out') . self::seconds($command, 'seconds'), 'cinematic_music' => strval($command['track'] ?? $command['music'] ?? '') . (($command['loop'] ?? false) ? ' (loop)' : ''), default => '', diff --git a/src/Cutscenes/CutscenePairShape.php b/src/Cutscenes/CutscenePairShape.php index 87dafdd9..ad512c92 100644 --- a/src/Cutscenes/CutscenePairShape.php +++ b/src/Cutscenes/CutscenePairShape.php @@ -64,7 +64,7 @@ public static function of(CutsceneType $type, array $data, array $partner): self $owners[(string) $key] = 'data'; } - if ($type === CutsceneType::SUMMON || $scriptIsMap) { + if ($type !== CutsceneType::CINEMATIC || $scriptIsMap) { foreach (array_keys($partner) as $key) { if (! isset($owners[(string) $key])) { $owners[(string) $key] = 'partner'; @@ -148,7 +148,7 @@ public function merge(array $data, array $partner): array */ public function split(array $payload): array { - unset($payload[CutsceneAsset::ORIGIN_KEY]); + unset($payload[CutsceneAsset::ORIGIN_KEY], $payload[CutsceneAsset::SEQUENCE_KEY]); $data = []; $partner = []; @@ -173,8 +173,10 @@ public function split(array $payload): array } foreach ($payload as $key => $value) { + // An effect is its timeline alone; a summon's new timeline fields + // go to its timeline and anything else to its data file. $owner = $this->keyOwners[$key] - ?? (in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) ? 'partner' : 'data'); + ?? ($this->type === CutsceneType::EFFECT || in_array($key, CinematicCommandSchema::SUMMON_TIMELINE_FIELDS, true) ? 'partner' : 'data'); if ($owner === 'partner') { $partner[$key] = $value; @@ -270,12 +272,27 @@ private function collisionReasons(array $data, array $partner): array array_keys($partner), static fn(int|string $key): bool => $key !== self::COMMANDS && array_key_exists($key, $data), )); + $reasons = []; + + if ($this->type === CutsceneType::SUMMON && is_array($partner['presentations'] ?? null)) { + // A summon's sequence is edited beside its definition and the + // timeline's own keys, so it may not hold one of theirs. + $outer = [...$data, ...array_diff_key($partner, ['presentations' => true])]; + + foreach ($partner['presentations'] as $name => $sequence) { + $clashes = is_array($sequence) ? array_keys(array_intersect_key($sequence, $outer)) : []; + + if ($clashes !== []) { + $reasons[] = sprintf('the %s sequence also declares %s, which the definition or timeline holds', $name, self::quoteKeys(array_map(strval(...), $clashes))); + } + } + } if ($shared === []) { - return []; + return $reasons; } - return [sprintf( + return [...$reasons, sprintf( 'both files declare the top-level key%s %s, and the editor can hold only one value for each', count($shared) === 1 ? '' : 's', implode(', ', array_map(static fn(int|string $key): string => '"' . $key . '"', $shared)), diff --git a/src/Cutscenes/CutsceneRecordCategory.php b/src/Cutscenes/CutsceneRecordCategory.php new file mode 100644 index 00000000..6d1371ed --- /dev/null +++ b/src/Cutscenes/CutsceneRecordCategory.php @@ -0,0 +1,212 @@ +key = $type->getRecordCategory(); + } + + public function isEditable(): bool + { + return true; + } + + public function getReadOnlyReason(): ?string + { + return null; + } + + public function isDirty(): bool + { + return array_any($this->library->assets($this->type), static fn(CutsceneAsset $asset): bool => $asset->isDirty()); + } + + public function supportsRecordCreation(): bool + { + return true; + } + + public function supportsRecordDuplication(): bool + { + return true; + } + + public function supportsRecordDeletion(): bool + { + return true; + } + + public function supportsDurableReorder(): bool + { + return false; + } + + public function getRecordLabels(): array + { + return $this->read()->getRecordLabels(); + } + + public function getRecordRows(int $index, array $frame): array + { + return $this->read()->getRecordRows($index, $frame); + } + + public function describeFrame(array $frame): ?string + { + return $this->read()->describeFrame($frame); + } + + public function getListNoun(array $frame): ?string + { + return $this->read()->getListNoun($frame); + } + + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem + { + return $this->read()->locateItem($index, $frame, $fieldId); + } + + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange + { + return $this->change($index, $label, static fn(RecordCategory $records, int $at): RecordChange + => $records->applyField($at, $frame, $fieldId, $value, $label)); + } + + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange + { + return $this->change($index, 'Add', static fn(RecordCategory $records, int $at): RecordChange + => $records->addItem($at, $frame, $fieldId, $child)); + } + + public function removeItem(int $index, array $frame, string $fieldId): RecordChange + { + return $this->change($index, 'Remove', static fn(RecordCategory $records, int $at): RecordChange + => $records->removeItem($at, $frame, $fieldId)); + } + + /** @param string|null $identity The new asset's id, made free and safe; the schema's blank id when none is given. */ + public function createRecord(?string $identity = null): RecordChange + { + ['asset' => $asset, 'command' => $command] = $this->library->createAsset($this->type, $identity); + + return new RecordChange($command, $this->findIndex($asset->id)); + } + + public function duplicateRecord(int $index, ?string $identity = null): RecordChange + { + $id = $this->library->ids($this->type)[$index] ?? throw new RecordRefusal(sprintf('There is no %s %d.', $this->type->noun(), $index)); + try { + ['asset' => $copy, 'command' => $command] = $this->library->duplicateAsset($this->type, $id, $identity); + } catch (RuntimeException $failure) { + throw new RecordRefusal($failure->getMessage(), previous: $failure); + } + + return new RecordChange($command, $this->findIndex($copy->id)); + } + + public function deleteRecord(int $index): RecordChange + { + $label = sprintf('Delete %s', $this->type->noun()); + $deleted = $this->change($index, $label, static fn(RecordCategory $records, int $at): RecordChange => $records->deleteRecord($at)); + $count = count($this->library->ids($this->type)); + + return new RecordChange($deleted->command, $count === 0 ? null : min($index, $count - 1)); + } + + public function moveRecord(int $index, int $step): RecordChange + { + throw $this->refuseRecordOperation('reordered'); + } + + public function getBackupPaths(): array + { + $paths = []; + foreach ($this->library->assets($this->type) as $asset) { + if ($asset->isDirty()) { + array_push($paths, ...array_filter([$asset->dataPath(), $asset->partnerPath()], is_file(...))); + } + } + + return $paths; + } + + public function save(): void + { + foreach ($this->library->assets($this->type) as $asset) { + if ($asset->isDirty()) { + $this->library->save($this->type, $asset->id); + } + } + } + + /** Where an asset sits among its type's records. */ + private function findIndex(string $id): ?int + { + $index = array_search($id, $this->library->ids($this->type), true); + + return is_int($index) ? $index : null; + } + + /** The type's records, read through the shared record rules. */ + private function read(): RecordCategory + { + return new RecordCategory($this->library->records($this->type, graphical: true)); + } + + /** + * Runs one record change through the library, which takes it into the + * asset and gives the step that undoes it. + * + * @param callable(RecordCategory, int): RecordChange $change + * @throws RecordRefusal When the asset is missing or read-only, or the change is refused; nothing is changed. + */ + private function change(int $index, string $label, callable $change): RecordChange + { + try { + $applied = $this->library->changeAsset($this->type, $index, $label, + static fn(ProjectRecordDatabase $records, int $at): RecordChange => $change(new RecordCategory($records), $at), graphical: true); + } catch (RecordRefusal $refusal) { + throw $refusal; + } catch (RuntimeException $failure) { + throw new RecordRefusal($failure->getMessage(), previous: $failure); + } + /** @var RecordChange $made */ + $made = $applied['result']; + + return new RecordChange($applied['command'], $made->index, $made->removed, $made->note); + } + + private function refuseRecordOperation(string $done): Throwable + { + return new RecordRefusal(sprintf('A %s is %s from the Cutscenes workspace, which names its folder first.', $this->type->noun(), $done)); + } +} diff --git a/src/Cutscenes/CutsceneType.php b/src/Cutscenes/CutsceneType.php index dc541432..6af87cb2 100644 --- a/src/Cutscenes/CutsceneType.php +++ b/src/Cutscenes/CutsceneType.php @@ -4,14 +4,18 @@ namespace Ichiloto\Editor\Cutscenes; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; + /** - * The two cutscene forms the Cutscenes workspace authors. + * The timeline forms the Cutscenes workspace authors. * * A Cinematic is a staged story sequence: a command tree the engine's event * interpreter runs on the field. A Summon is a frame-driven battle * presentation: tracks, keyframes and cues the summon compiler builds and the - * summon player plays. They share a workspace, an asset layout of one folder - * with two files per stable id, and nothing else. + * summon player plays. An Effect is a standalone effect timeline battles, + * the field and cinematics play: tracks, keyframes and cues the Engine's + * effect library compiles. Each lives in one folder named by its stable id; + * a cinematic and a summon hold two files there, an effect only its timeline. * * @package Ichiloto\Editor\Cutscenes */ @@ -19,6 +23,8 @@ enum CutsceneType: string { case CINEMATIC = 'cinematic'; case SUMMON = 'summon'; + /** A standalone effect timeline, played by battles, the field and cinematics. */ + case EFFECT = 'effect'; /** * Returns the project-relative folder holding this type's assets. @@ -28,6 +34,7 @@ public function relativeRoot(): string return match ($this) { self::CINEMATIC => 'assets/Cutscenes/Cinematics', self::SUMMON => 'assets/Cutscenes/Summons', + self::EFFECT => 'assets/' . EffectTimelineLibrary::DIRECTORY, }; } @@ -38,7 +45,7 @@ public function partnerSuffix(): string { return match ($this) { self::CINEMATIC => '.script.php', - self::SUMMON => '.timeline.php', + self::SUMMON, self::EFFECT => '.timeline.php', }; } @@ -49,10 +56,25 @@ public function partnerNoun(): string { return match ($this) { self::CINEMATIC => 'script', - self::SUMMON => 'timeline', + self::SUMMON, self::EFFECT => 'timeline', }; } + /** + * The record category an interface edits this type's assets through, as + * it edits a Database category: `cutscenes/summon` for summons. + */ + public function getRecordCategory(): string + { + return 'cutscenes/' . $this->value; + } + + /** The type a record category key names, or null for a key that names no cutscene type. */ + public static function findByRecordCategory(string $category): ?self + { + return str_starts_with($category, 'cutscenes/') ? self::tryFrom(substr($category, strlen('cutscenes/'))) : null; + } + /** * Returns the singular noun used in status messages. */ @@ -61,6 +83,7 @@ public function noun(): string return match ($this) { self::CINEMATIC => 'cinematic', self::SUMMON => 'summon', + self::EFFECT => 'effect', }; } @@ -72,6 +95,62 @@ public function label(): string return match ($this) { self::CINEMATIC => 'Cinematic', self::SUMMON => 'Summon', + self::EFFECT => 'Effect', + }; + } + + /** + * Returns the one-line description shown with the type's category. + */ + public function describeCategory(): string + { + return match ($this) { + self::CINEMATIC => 'Staged story sequences run by the event interpreter.', + self::SUMMON => 'Frame-driven battle presentations built by the summon compiler.', + self::EFFECT => 'Effect timelines played by battle animations, the field and cinematics.', + }; + } + + /** + * Returns what an asset of this type is, for a type with none yet. + */ + public function describeAsset(): string + { + return match ($this) { + self::CINEMATIC => 'A cinematic is a staged story scene: a command tree the engine runs on the field, with its own cast, camera, skip policy and finalizer.', + self::SUMMON => 'A summon is a frame-driven battle presentation: tracks, keyframes and cues the engine compiles and plays.', + self::EFFECT => 'An effect is one timeline file under assets/Animations: glyph, text and image tracks, keyframes and cues, played by battle animations, the field and cinematics. It may hold one sequence for every renderer, or separate terminal and graphical sequences.', + }; + } + + /** + * Whether the asset has an `.data.php` beside its partner file. An + * effect is its timeline alone, as the Engine reads it. + */ + public function hasDataFile(): bool + { + return $this !== self::EFFECT; + } + + /** + * Returns the pattern a stable id of this type must match: the Engine's + * own for effects, which allows no dots. + */ + public function getIdPattern(): string + { + return match ($this) { + self::EFFECT => EffectTimelineLibrary::ID_PATTERN, + self::CINEMATIC, self::SUMMON => '/^[a-z0-9][a-z0-9._-]*$/', }; } + + /** + * Returns the characters an id may hold, for messages. + */ + public function describeIdCharacters(): string + { + return $this === self::EFFECT + ? 'lowercase letters, digits, "_" and "-"' + : 'lowercase letters, digits, ".", "_" and "-"'; + } } diff --git a/src/Cutscenes/Editing/CutsceneOutlinePane.php b/src/Cutscenes/Editing/CutsceneOutlinePane.php index 5e2695d6..87a62ba9 100644 --- a/src/Cutscenes/Editing/CutsceneOutlinePane.php +++ b/src/Cutscenes/Editing/CutsceneOutlinePane.php @@ -7,6 +7,7 @@ use Atatusoft\Termutil\IO\Enumerations\Color; use Ichiloto\Editor\Cutscenes\CutsceneOutline; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Database\CutsceneSchemas; use Ichiloto\Editor\ListNavigation; use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\Status\StatusLevel; @@ -207,7 +208,7 @@ private function reorderCutsceneTreeRow(int $delta): void $delta < 0 ? 'Move up' : 'Move down', static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Moved %s.', $delta < 0 ? 'up' : 'down'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Moved %s.', $delta < 0 ? 'up' : 'down'), StatusLevel::INFO); $this->focusCutsceneTreeKey($targetKey); } } @@ -224,12 +225,15 @@ private function insertCutsceneTreeRow(): void return; } - $blank = match ($entry['row']['kind']) { - 'track' => ['type' => 'glyph', 'id' => 'track', 'keyframes' => []], - 'keyframe' => ['frame' => 0, 'duration' => 1, 'content' => '', 'position' => [0, 0]], - 'cue' => ['id' => 'cue', 'frame' => 0, 'type' => 'applyEffect'], - default => ['type' => 'wait', 'seconds' => 0.5], - }; + $blank = $entry['row']['kind'] === 'command' + ? ['type' => 'wait', 'seconds' => 0.5] + : $this->findTimelineBlank($entry['row']['kind'], $entry['payload'], $entry['listPath']); + + if ($blank === null) { + $this->setStatus(sprintf('This %s list takes no new entries here.', $entry['row']['kind']), StatusLevel::WARN); + + return; + } $list = CutsceneOutline::valueAt($entry['payload'], $entry['listPath']); $list = is_array($list) ? array_values($list) : []; $index = $entry['index']; @@ -238,6 +242,12 @@ private function insertCutsceneTreeRow(): void $blank['id'] = $this->freeOutlineId($list, $blank['id']); } + if ($entry['row']['kind'] === 'keyframe' && is_array($list[$index] ?? null)) { + // After a keyframe is after it in time: the new one starts where + // the selected one ends, never over it. + $blank['frame'] = intval($list[$index]['frame'] ?? 0) + max(1, intval($list[$index]['duration'] ?? 1)); + } + array_splice($list, $index + 1, 0, [$blank]); $targetKey = $this->replaceKeyIndex($entry['row']['key'], $index + 1); @@ -245,11 +255,40 @@ private function insertCutsceneTreeRow(): void 'Add ' . $entry['row']['kind'], static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Added a %s after the selected one.', $entry['row']['kind']), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Added a %s after the selected one.', $entry['row']['kind']), StatusLevel::INFO); $this->focusCutsceneTreeKey($targetKey); } } + /** + * Returns the blank a timeline's list takes, as its schema owns it: a + * summon's and an effect's tracks, keyframes and cues differ, and an + * effect's image keyframes differ from its glyph ones. + * + * @param array $payload + * @param array $listPath The list the entry goes into. + * @return array|null + */ + private function findTimelineBlank(string $kind, array $payload, array $listPath): ?array + { + $asset = $this->selectedCutscene(); + $schema = $asset === null ? null : $this->cutsceneLibrary()?->schemaFor($asset->type); + + if ($schema === null) { + return null; + } + + $list = match ($kind) { + 'track' => $schema->commandLists[CutsceneSchemas::TRACKS_KEY] ?? null, + 'cue' => $schema->commandLists[CutsceneSchemas::CUES_KEY] ?? null, + 'keyframe' => ($schema->commandLists[CutsceneSchemas::TRACKS_KEY] ?? null) + ?->nestedListFor((array) CutsceneOutline::valueAt($payload, array_slice($listPath, 0, -1))), + default => null, + }; + + return $list?->blank; + } + /** * Inserts a copy of the selected entry right after it. */ @@ -283,7 +322,7 @@ private function duplicateCutsceneTreeRow(): void 'Duplicate ' . $entry['row']['kind'], static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Duplicated the %s.', $entry['row']['kind']), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Duplicated the %s.', $entry['row']['kind']), StatusLevel::INFO); $this->focusCutsceneTreeKey($targetKey); } } @@ -314,7 +353,7 @@ private function removeCutsceneTreeRow(): void 'Remove ' . $kind, static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $entry['listPath'], $list), )) { - $this->setStatus(sprintf('Removed the %s (Ctrl+Z restores it).', $kind), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed the %s (Ctrl+Z restores it).', $kind), StatusLevel::INFO); $this->cutsceneTreeCursor = max(0, min($this->cutsceneTreeCursor, count($this->visibleCutsceneTreeRows()) - 1)); $this->renderCutscenesArea(); } @@ -370,7 +409,7 @@ private function nestCutsceneTreeRow(): void return CutsceneOutline::withValueAt($payload, $target, $targetList); })) { - $this->setStatus(sprintf('Nested into the %s above.', strval($previous['type'] ?? 'block')), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Nested into the %s above.', strval($previous['type'] ?? 'block')), StatusLevel::INFO); $this->focusCutsceneTreeKey($this->keyForPath([...$target, (int) $targetKey])); } } @@ -438,7 +477,7 @@ private function unnestCutsceneTreeRow(): void return CutsceneOutline::withValueAt($payload, $ownerList, $parent); })) { - $this->setStatus('Moved out of its block.', StatusLevel::SUCCESS); + $this->setStatus('Moved out of its block.', StatusLevel::INFO); $this->focusCutsceneTreeKey($this->keyForPath([...$ownerList, $ownerIndex + 1])); } } @@ -476,7 +515,7 @@ private function nudgeCutsceneTreeFrame(int $delta): bool 'Nudge frame', static fn(array $payload): array => CutsceneOutline::withValueAt($payload, $path, $next), )) { - $this->setStatus(sprintf('Frame %d.', $next), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Frame %d.', $next), StatusLevel::INFO); $this->renderCutscenesArea(); } @@ -610,8 +649,8 @@ private function createCutsceneTreeWindow(array $layout): EditorWindow $failedKey = $preview?->failure()['key'] ?? null; $selected = $this->selectedCutscene(); - if ($selected?->type === CutsceneType::SUMMON) { - $activeKeys = $this->activeSummonKeyframeKeys($selected); + if ($selected !== null && $selected->type !== CutsceneType::CINEMATIC) { + $activeKeys = $this->findActiveTimelineKeys($selected); } foreach ($rows as $position => $row) { @@ -634,7 +673,7 @@ private function createCutsceneTreeWindow(array $layout): EditorWindow $contentHeight = max(1, $layout['treeHeight'] - 2); $scroll = max(0, min(max(0, count($lines) - $contentHeight), $this->cutsceneTreeCursor - intdiv($contentHeight, 2))); $asset = $this->selectedCutscene(); - $title = $asset?->type === CutsceneType::SUMMON ? 'Timeline' : 'Command Tree'; + $title = $asset === null || $asset->type === CutsceneType::CINEMATIC ? 'Command Tree' : 'Timeline'; return new EditorWindow( title: $title, diff --git a/src/Cutscenes/Editing/CutscenePreviewPane.php b/src/Cutscenes/Editing/CutscenePreviewPane.php index beef63cd..7b619a2b 100644 --- a/src/Cutscenes/Editing/CutscenePreviewPane.php +++ b/src/Cutscenes/Editing/CutscenePreviewPane.php @@ -7,16 +7,25 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneLaneOverview; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewOrigin; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; +use Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage; use Ichiloto\Editor\Cutscenes\Preview\PreviewSnapshot; -use Ichiloto\Editor\Cutscenes\Preview\SummonPreviewSession; +use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\EditorWindow; use Ichiloto\Editor\Playtest\PlaytestLauncher; -use Ichiloto\Editor\Playtest\PlaytestOverlay; -use Ichiloto\Editor\ProjectMap; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Status\StatusLevel; use Ichiloto\Editor\UI\CutscenesScreen; +use Ichiloto\Editor\Validation\EffectValidator; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Editor\Database\ProjectCatalogs; +use Ichiloto\Engine\Battle\BattlePacing; +use Ichiloto\Engine\Battle\BattleTurnTimings; +use Ichiloto\Engine\Battle\Enumerations\BattleActionCategory; +use Ichiloto\Engine\Battle\Enumerations\BattlePace; use Throwable; /** @@ -28,7 +37,7 @@ * failure it stopped on — with Play, Pause, Step, Skip, Restart and Stop, * a jump from a failure to the command that failed, a watched-versus-skipped * comparison, a duration overview, and a route into the real game through a - * playtest overlay. The command tree stays the one thing being edited; the + * playtest launch. The command tree stays the one thing being edited; the * pane only reads it. * * @package Ichiloto\Editor\Cutscenes\Editing @@ -36,7 +45,7 @@ trait CutscenePreviewPane { private ?CinematicPreviewSession $cinematicPreview = null; - private ?SummonPreviewSession $summonPreview = null; + private ?TimelinePreviewSession $timelinePreview = null; /** One of: stage, lanes, log, compare, overview. */ private string $cutscenePreviewView = 'stage'; private float $cutscenePreviewLastTickAt = 0.0; @@ -46,8 +55,19 @@ trait CutscenePreviewPane private int $cutscenePreviewScroll = 0; /** The row under the cursor in the duration overview. */ private int $cutsceneOverviewCursor = 0; + /** The open project's effect timelines, read once, so the overview can time field effects. */ /** The asset payload the running preview was built from. */ private string $cinematicPreviewFingerprint = ''; + /** @var array Effect id => previewed as battle plays it (else as the field does), once the author chose. */ + private array $effectPreviewInBattle = []; + /** Which side the caster stands on in an effect's battle preview. */ + private bool $isEffectPreviewCasterOnLeft = true; + /** The kind of command a battle-paced effect is previewed inside, which sets how long its phase lasts. */ + private BattleActionCategory $effectPreviewAction = BattleActionCategory::PHYSICAL_ATTACK; + /** The battle pace a battle-paced effect is previewed at; the project's own until the author changes it. */ + private ?BattlePace $effectPreviewPace = null; + /** @var array Effect id => previewed as a command's source stage (else its target), once the author chose. */ + private array $effectPreviewAsSource = []; /** * Whether the preview pane is taller than its resting strip: while a @@ -56,7 +76,7 @@ trait CutscenePreviewPane private function isCutscenePreviewExpanded(): bool { return $this->cinematicPreview !== null - || $this->summonPreview !== null + || $this->timelinePreview !== null || $this->cutsceneFocus === CutscenesScreen::PANE_PREVIEW || $this->cutscenePreviewView !== 'stage'; } @@ -92,8 +112,8 @@ private function moveCutscenePreview(int $deltaX, int $deltaY): void */ private function handleCutscenePreviewInput(string $input): bool { - if ($this->selectedCutscene()?->type === CutsceneType::SUMMON) { - return $this->handleSummonPreviewInput($input); + if ($this->selectedCutscene()?->type !== null && $this->selectedCutscene()?->type !== CutsceneType::CINEMATIC) { + return $this->handleTimelinePreviewInput($input); } $preview = $this->cinematicPreview; @@ -199,7 +219,7 @@ private function startCinematicPreview(bool $play): void } if ($asset->type !== CutsceneType::CINEMATIC) { - $this->startSummonPreview(play: $play); + $this->startTimelinePreview(play: $play); return; } @@ -252,7 +272,7 @@ private function startCinematicPreview(bool $play): void $origin['mapId'] !== null ? sprintf(' on %s at %d,%d', $origin['mapId'], $origin['x'], $origin['y']) : ' without a map', $play ? 'playing' : 'paused', ), - $failure !== null ? StatusLevel::ERROR : StatusLevel::SUCCESS, + $failure !== null ? StatusLevel::ERROR : StatusLevel::INFO, ); $this->renderDatabasePanes(['preview', 'tree']); } @@ -329,7 +349,7 @@ private function skipCinematicPreview(): void } if ($preview->skip()) { - $this->setStatus('Skip accepted: the finalizer is running.', StatusLevel::SUCCESS); + $this->setStatus('Skip accepted: the finalizer is running.', StatusLevel::INFO); $preview->play(); $this->cutscenePreviewLastTickAt = microtime(true); } else { @@ -501,7 +521,7 @@ private function tickCutscenePreview(): void return; } - $this->tickSummonPreview(); + $this->tickTimelinePreview(); $preview = $this->cinematicPreview; if ($preview === null || ! $preview->isPlaying()) { @@ -525,7 +545,7 @@ private function tickCutscenePreview(): void $failure !== null ? sprintf('Preview failed: %s (J jumps to it)', $failure['message']) : sprintf('Preview %s after %.1fs.', $preview->status(), $preview->elapsed()), - $failure !== null ? StatusLevel::ERROR : StatusLevel::SUCCESS, + $failure !== null ? StatusLevel::ERROR : StatusLevel::INFO, ); } } @@ -534,22 +554,66 @@ private function disposeCinematicPreview(): void { $this->cinematicPreview?->dispose(); $this->cinematicPreview = null; - $this->summonPreview = null; + $this->timelinePreview = null; } - // -- Summons ------------------------------------------------------------- + // -- Summons and effects ------------------------------------------------- /** - * Handles the preview keys while a summon is selected. + * Handles the preview keys while a summon or an effect is selected. An + * effect also takes B, playing it as battle or as the field compiles it, + * and D, putting its battle caster on the other side. */ - private function handleSummonPreviewInput(string $input): bool + private function handleTimelinePreviewInput(string $input): bool { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; $lower = strtolower($input); + $asset = $this->selectedCutscene(); + + if ($asset?->type === CutsceneType::EFFECT && in_array($lower, ['a', 'p', 's'], true) && $this->isEffectPreviewInBattle($asset)) { + // The battle command a battle-paced effect plays inside: its kind, + // the battle's pace and the stage, which together set its phase. + match ($lower) { + 'a' => $this->effectPreviewAction = self::cycleCase(BattleActionCategory::cases(), $this->effectPreviewAction), + 'p' => $this->effectPreviewPace = self::cycleCase(BattlePace::cases(), $this->getEffectPreviewPace()), + 's' => $this->effectPreviewAsSource[$asset->id] = ! $this->isEffectPreviewSource($asset), + }; + $this->startTimelinePreview(play: $preview?->isPlaying() ?? false); + + return true; + } + + if ($asset?->type === CutsceneType::EFFECT && ($input === 'b' || $input === 'd')) { + if ($asset->isOwnedStageEffect()) { + $this->setStatus('This effect owns its stage; it has no field target or battle caster.', StatusLevel::INFO); + + return true; + } + if ($input === 'b') { + $this->effectPreviewInBattle[$asset->id] = ! $this->isEffectPreviewInBattle($asset); + } elseif ($this->isEffectPreviewInBattle($asset)) { + $this->isEffectPreviewCasterOnLeft = ! $this->isEffectPreviewCasterOnLeft; + } + + if ($input === 'b') { + // Battle and the field compile the timeline differently, and + // either may refuse it: compile now and say so. + $this->startTimelinePreview(play: $preview?->isPlaying() ?? false); + + return true; + } + + $this->setStatus($this->isEffectPreviewInBattle($asset) + ? sprintf('Caster on the %s.', $this->isEffectPreviewCasterOnLeft ? 'left' : 'right') + : 'The field has no caster; B previews battle.', StatusLevel::INFO); + $this->renderDatabasePanes(['preview', 'tree']); + + return true; + } if ($input === ' ' || $input === "\n" || $input === "\r") { if ($preview === null) { - $this->startSummonPreview(play: true); + $this->startTimelinePreview(play: true); } elseif ($preview->isPlaying()) { $preview->pause(); $this->setStatus(sprintf('Paused at frame %d.', $preview->currentFrame())); @@ -569,8 +633,8 @@ private function handleSummonPreviewInput(string $input): bool if ($input === '+' || $input === '-' || $lower === 'o' || $isHome || $isEnd) { if ($preview === null) { - $this->startSummonPreview(play: false); - $preview = $this->summonPreview; + $this->startTimelinePreview(play: false); + $preview = $this->timelinePreview; } if ($preview === null) { @@ -597,8 +661,8 @@ private function handleSummonPreviewInput(string $input): bool if ($input === '.' || $input === ',' || $input === '>' || $input === '<' || $lower === 'r' || $lower === 'l' || $lower === 'x') { if ($preview === null) { - $this->startSummonPreview(play: false); - $preview = $this->summonPreview; + $this->startTimelinePreview(play: false); + $preview = $this->timelinePreview; } if ($preview === null) { @@ -612,11 +676,11 @@ private function handleSummonPreviewInput(string $input): bool $input === '<' => $preview->seekBoundary(-1), $lower === 'r' => $preview->restart(), $lower === 'l' => $this->cutscenePreviewView = $this->cutscenePreviewView === 'timeline' ? 'stage' : 'timeline', - default => $this->summonPreview = null, + default => $this->timelinePreview = null, }; if ($lower === 'x') { - $this->setStatus('Summon preview closed.'); + $this->setStatus('Preview closed.'); } elseif ($lower === 'l') { $this->setStatus(sprintf('Preview view: %s.', $this->cutscenePreviewView)); } else { @@ -632,49 +696,62 @@ private function handleSummonPreviewInput(string $input): bool } /** - * Compiles the selected summon as it stands and opens the Engine - * playback session over it. + * Compiles the selected summon, or the effect's sequence being edited + * for battle or the field, as it stands, and opens the Engine playback + * session over it. An effect plays as its timeline says, once or + * looping; a summon once until O loops it. */ - private function startSummonPreview(bool $play): void + private function startTimelinePreview(bool $play): void { $asset = $this->selectedCutscene(); - if (! $asset instanceof CutsceneAsset || $asset->type !== CutsceneType::SUMMON) { + if (! $asset instanceof CutsceneAsset || $asset->type === CutsceneType::CINEMATIC) { return; } try { - $compiled = $asset->compiledSummon(); + $compiled = $asset->type === CutsceneType::EFFECT + ? $asset->compileEffect($asset->getPresentationView() ?? EffectPresentation::TERMINAL, + $this->isEffectPreviewInBattle($asset), forStage: $asset->isOwnedStageEffect()) + : $asset->compiledSummon(); } catch (Throwable $throwable) { - $this->summonPreview = null; - $this->setErrorStatus($throwable, 'Summon preview'); + $this->timelinePreview = null; + $this->setErrorStatus($throwable, ucfirst($asset->type->noun()) . ' preview'); $this->renderDatabasePanes(['preview']); return; } - $this->summonPreview = new SummonPreviewSession($compiled); + // A battle-paced sequence spreads its frames over the phase it plays in. + $phaseSeconds = $asset->type === CutsceneType::EFFECT && $compiled->cadence === EffectCadence::BATTLE_PHASE + ? $this->getEffectPreviewPhaseSeconds($asset) + : null; + $this->timelinePreview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false, $phaseSeconds); $this->cinematicPreviewFingerprint = $this->cutscenePayloadFingerprint($asset); $this->cutscenePreviewLastTickAt = microtime(true); $this->cutscenePreviewScroll = 0; if ($play) { - $this->summonPreview->play(); + $this->timelinePreview->play(); } $this->setStatus(sprintf( - 'Previewing %s: %d frames at %d fps (%s).', + 'Previewing %s: %d frames %s (%s)%s.', $asset->id, - $this->summonPreview->totalFrames(), - $this->summonPreview->fps(), + $this->timelinePreview->totalFrames(), + $phaseSeconds === null + ? sprintf('at %d fps', $this->timelinePreview->fps()) + : sprintf('over %.2fs, %s (A, P, S change it)', $phaseSeconds, $this->describeEffectPreviewPacing($asset)), $play ? 'playing' : 'paused', - ), StatusLevel::SUCCESS); + $asset->type === CutsceneType::EFFECT ? sprintf(', in %s', $asset->isOwnedStageEffect() ? 'its owned stage' + : ($this->isEffectPreviewInBattle($asset) ? 'battle' : 'the field')) : '', + ), StatusLevel::INFO); $this->renderDatabasePanes(['preview', 'tree']); } - private function tickSummonPreview(): void + private function tickTimelinePreview(): void { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; if ($preview === null || ! $preview->isPlaying()) { return; @@ -692,8 +769,103 @@ private function tickSummonPreview(): void $this->renderDatabasePanes(['preview', 'tree']); if ($preview->isCompleted()) { - $this->setStatus(sprintf('Summon preview finished: %d frames, %d cue%s fired.', $preview->totalFrames(), count($preview->cueLog()), count($preview->cueLog()) === 1 ? '' : 's'), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Preview finished: %d frames, %d cue%s fired.', $preview->totalFrames(), count($preview->cueLog()), count($preview->cueLog()) === 1 ? '' : 's'), StatusLevel::INFO); + } + } + + /** + * Whether the selected effect is previewed as battle plays it: as the + * author last chose, else battle when a battle animation uses it or + * nothing on the field does. + */ + private function isEffectPreviewInBattle(CutsceneAsset $asset): bool + { + if ($asset->isOwnedStageEffect()) { + return false; } + if (isset($this->effectPreviewInBattle[$asset->id])) { + return $this->effectPreviewInBattle[$asset->id]; + } + + $uses = $this->workspace instanceof ProjectWorkspace ? (EffectValidator::findUses($this->workspace)[$asset->id] ?? []) : []; + $fieldScripts = $this->workspace instanceof ProjectWorkspace + ? (EffectValidator::findScriptUses($this->workspace, $this->cutsceneLibrary()?->assets(CutsceneType::CINEMATIC) ?? [])[$asset->id] ?? []) + : []; + + return $this->effectPreviewInBattle[$asset->id] = isset($uses['battle']) || (! isset($uses['field']) && $fieldScripts === []); + } + + /** + * Whether the selected effect is previewed as a command's source stage: + * as the author last chose, else when every battle animation using it + * plays it as its source effect. + */ + private function isEffectPreviewSource(CutsceneAsset $asset): bool + { + if (isset($this->effectPreviewAsSource[$asset->id])) { + return $this->effectPreviewAsSource[$asset->id]; + } + + $battleUses = $this->workspace instanceof ProjectWorkspace ? (EffectValidator::findUses($this->workspace)[$asset->id]['battle'] ?? []) : []; + + return $this->effectPreviewAsSource[$asset->id] = $battleUses !== [] + && array_all($battleUses, static fn(string $use): bool => str_ends_with($use, ' sourceEffect')); + } + + /** The battle pace a paced effect is previewed at: the author's choice, else the project's. */ + private function getEffectPreviewPace(): BattlePace + { + $battleUi = $this->workspace?->config?->getRecord('ui.battle')->get('value'); + + return $this->effectPreviewPace ??= BattlePacing::fromBattleUiConfig(is_array($battleUi) ? $battleUi : [])->getAnimationPace(); + } + + /** + * The seconds the battle phase lasts that a paced effect plays in, from + * the Engine's own turn timings: the action animation for a source + * stage, the effect animation for a target stage. + */ + private function getEffectPreviewPhaseSeconds(CutsceneAsset $asset): float + { + $timings = BattleTurnTimings::fromTotalDuration($this->effectPreviewAction->totalDurationSeconds($this->getEffectPreviewPace())); + + return $this->isEffectPreviewSource($asset) ? $timings->actionAnimation : $timings->effectAnimation; + } + + /** The battle command a paced effect is previewed inside, as the author reads it. */ + private function describeEffectPreviewPacing(CutsceneAsset $asset): string + { + return sprintf( + 'the %s of a %s at %s pace', + $this->isEffectPreviewSource($asset) ? 'source' : 'target', + str_replace('_', ' ', $this->effectPreviewAction->value), + $this->getEffectPreviewPace()->value, + ); + } + + /** + * The case after the given one, wrapping to the first. + * + * @template T of \UnitEnum + * @param list $cases + * @param T $current + * @return T + */ + private static function cycleCase(array $cases, \UnitEnum $current): \UnitEnum + { + $index = array_search($current, $cases, true); + + return $cases[(($index === false ? -1 : $index) + 1) % count($cases)]; + } + + /** + * The stage an effect's frame is drawn on: its sequence being edited, + * battle or the field, and the caster's side. + */ + private function createEffectPreviewStage(CutsceneAsset $asset): EffectPreviewStage + { + return new EffectPreviewStage($asset->getPresentationView() ?? EffectPresentation::TERMINAL, $this->isEffectPreviewInBattle($asset), + $this->isEffectPreviewCasterOnLeft, forStage: $asset->isOwnedStageEffect()); } /** @@ -701,9 +873,9 @@ private function tickSummonPreview(): void * * @return string[] */ - private function activeSummonKeyframeKeys(CutsceneAsset $asset): array + private function findActiveTimelineKeys(CutsceneAsset $asset): array { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; if ($preview === null) { return []; @@ -742,8 +914,9 @@ private function activeSummonKeyframeKeys(CutsceneAsset $asset): array } /** - * Plays the cinematic in the real game: a playtest overlay whose start - * map carries an automatic trigger for it on the spawn tile. + * Plays the cinematic in the real game: a new game starting on its + * origin cell, which the Engine plays the cinematic on once the player + * has arrived. */ private function playtestSelectedCinematic(): void { @@ -783,10 +956,10 @@ private function playtestSelectedCinematic(): void return; } - $overlay = null; + $launch = null; try { - $overlay = PlaytestOverlay::createForCinematic( + $launch = PlaytestLaunch::fromCinematic( $this->workspace->projectRoot, $origin['mapId'], $origin['x'], @@ -797,25 +970,23 @@ private function playtestSelectedCinematic(): void $this->terminal->suspendForChildProcess(); try { - $launcher->run($overlay); + $launcher->run($launch); } finally { $this->terminal->resumeAfterChildProcess($this->lastTerminalSize); } - $this->setStatus(sprintf('Playtest finished (%s on %s at %d,%d).', $asset->id, $origin['mapId'], $origin['x'], $origin['y']), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Playtest finished (%s on %s at %d,%d).', $asset->id, $origin['mapId'], $origin['x'], $origin['y']), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Cinematic playtest'); } finally { - $overlay?->destroy(); + $launch?->destroy(); } $this->requestFullRender(); } /** - * Where a preview or playtest starts: the map event that triggers the - * cinematic when one exists, otherwise the cinematic's start map (or the - * selected map) at its first open tile. + * Where a preview or playtest starts, shared with every editor; the selected map stands in for a start map. * * @return array{mapId: string|null, x: int, y: int, marker: string|null} */ @@ -823,67 +994,11 @@ private function cinematicPreviewOrigin(CutsceneAsset $asset, ?string $startMap) { $workspace = $this->workspace; - if ($workspace instanceof ProjectWorkspace) { - foreach ($workspace->maps as $map) { - foreach ($map->getEventDefinitions() as $marker => $definition) { - if (! is_array($definition) || ! str_contains(strval($definition['class'] ?? ''), 'CinematicEventTrigger')) { - continue; - } - - $data = is_array($definition['data'] ?? null) ? $definition['data'] : []; - - if (trim(strval($data['cinematicId'] ?? '')) !== $asset->id) { - continue; - } - - $bounds = $map->getEventBounds((string) $marker); - - if ($bounds !== null) { - return ['mapId' => $map->mapId, 'x' => (int) $bounds['x'], 'y' => (int) $bounds['y'], 'marker' => (string) $marker]; - } - } - } - } - - $map = null; - - if ($startMap !== null && $workspace instanceof ProjectWorkspace) { - foreach ($workspace->maps as $candidate) { - if ($candidate->mapId === $startMap) { - $map = $candidate; - } - } - } - - $map ??= $this->getSelectedMap(); - - if (! $map instanceof ProjectMap) { + if (! $workspace instanceof ProjectWorkspace) { return ['mapId' => $startMap, 'x' => 1, 'y' => 1, 'marker' => null]; } - [$x, $y] = $this->firstOpenTile($map); - - return ['mapId' => $map->mapId, 'x' => $x, 'y' => $y, 'marker' => null]; - } - - /** - * The first tile that is not a wall-like glyph, row by row. - * - * @return array{0: int, 1: int} - */ - private function firstOpenTile(ProjectMap $map): array - { - for ($y = 0; $y < $map->getHeight(); $y++) { - for ($x = 0; $x < $map->getWidth(); $x++) { - $symbol = $map->getTileSymbol($x, $y); - - if ($symbol === ' ') { - return [$x, $y]; - } - } - } - - return [0, 0]; + return CinematicPreviewOrigin::locate($workspace, $asset, $startMap, $this->getSelectedMap()); } /** @@ -920,14 +1035,21 @@ private function createCutscenePreviewWindow(array $layout): EditorWindow if ($preview !== null) { $stale = $asset !== null && $this->cutscenePayloadFingerprint($asset) !== $this->cinematicPreviewFingerprint; $title = sprintf('Preview · %s · %.1fs%s', $preview->status(), $preview->elapsed(), $stale ? ' · edited since start (R restarts)' : ''); - } elseif ($this->summonPreview !== null) { + } elseif ($this->timelinePreview !== null) { $stale = $asset !== null && $this->cutscenePayloadFingerprint($asset) !== $this->cinematicPreviewFingerprint; - $title = sprintf('Preview · frame %d/%d%s', $this->summonPreview->currentFrame(), $this->summonPreview->totalFrames(), $stale ? ' · edited since start (R restarts)' : ''); + $title = sprintf('Preview · frame %d/%d%s', $this->timelinePreview->currentFrame(), $this->timelinePreview->totalFrames(), $stale ? ' · edited since start (R restarts)' : ''); } elseif ($this->cutscenePreviewView !== 'stage') { $title = 'Preview · ' . $this->cutscenePreviewView; } - $help = $asset?->type === CutsceneType::SUMMON + $help = $asset?->type === CutsceneType::EFFECT + ? $this->fitHelp( + $layout['previewWidth'], + 'Space:Play/Pause . ,:Step < >:Keyframes Home/End +/-:Speed O:Loop B:Battle/Field D:Caster side R:Restart L:Timeline X:Close', + 'Space:Play . ,:Step < >:Keyframes +/-:Speed O:Loop B:Battle/Field D:Side', + 'Space:Play . ,:Step B:Battle/Field', + ) + : ($asset?->type === CutsceneType::SUMMON ? $this->fitHelp( $layout['previewWidth'], 'Space:Play/Pause . ,:Step < >:Keyframes Home/End +/-:Speed O:Loop R:Restart L:Timeline X:Close', @@ -939,11 +1061,11 @@ private function createCutscenePreviewWindow(array $layout): EditorWindow 'Space:Play/Pause .:Step K:Skip R:Restart X:Stop J:Jump C:Compare V:Overview L:View Ctrl+T:Playtest', 'Space:Play .:Step K:Skip R:Restart J:Jump C:Compare L:View', 'Space:Play .:Step K:Skip L:View', - ); + )); $lines = match (true) { $asset === null => [' Select a cutscene to preview it.'], - $asset->type === CutsceneType::SUMMON => $this->summonPreviewLines($asset, $contentWidth, $contentHeight), + $asset->type !== CutsceneType::CINEMATIC => $this->timelinePreviewLines($asset, $contentWidth, $contentHeight), $this->cutscenePreviewView === 'compare' => $this->cutsceneComparisonLines($contentWidth), $this->cutscenePreviewView === 'overview' => $this->cutsceneOverviewLines($asset, $contentWidth), $this->cutscenePreviewView === 'log' => $this->cutscenePreviewLogLines(), @@ -1202,6 +1324,15 @@ private function cutsceneComparisonLines(int $contentWidth): array return $lines; } + /** + * Returns the open project's catalogues, whose effect timelines are as the + * workspace holds them, or null with no project. + */ + private function getEffectLibrary(): ?ProjectCatalogs + { + return $this->workspace instanceof ProjectWorkspace ? $this->workspace->catalogs : null; + } + /** * The overview rows of the selected cinematic: commands, then finalizer. * @@ -1220,8 +1351,8 @@ private function cutsceneOverviewRows(): array $finalizer = is_array($payload['finalizer'] ?? null) ? $payload['finalizer'] : []; return [ - ...CutsceneLaneOverview::of($commands)->rows, - ...CutsceneLaneOverview::of($finalizer, 'finalizer')->rows, + ...CutsceneLaneOverview::of($commands, effects: $this->getEffectLibrary())->rows, + ...CutsceneLaneOverview::of($finalizer, 'finalizer', $this->getEffectLibrary())->rows, ]; } @@ -1236,9 +1367,9 @@ private function cutsceneOverviewLines(CutsceneAsset $asset, int $contentWidth): { $payload = $asset->payload(); $commands = is_array($payload[CutsceneAsset::COMMANDS_KEY] ?? null) ? $payload[CutsceneAsset::COMMANDS_KEY] : []; - $overview = CutsceneLaneOverview::of($commands); + $overview = CutsceneLaneOverview::of($commands, effects: $this->getEffectLibrary()); $finalizer = is_array($payload['finalizer'] ?? null) ? $payload['finalizer'] : []; - $finalizerOverview = CutsceneLaneOverview::of($finalizer, 'finalizer'); + $finalizerOverview = CutsceneLaneOverview::of($finalizer, 'finalizer', $this->getEffectLibrary()); $preview = $this->cinematicPreview; $activeKeys = $preview !== null && ! $preview->isFinished() ? $preview->activeKeys() : []; $lines = [sprintf( @@ -1308,21 +1439,29 @@ private function describeCutsceneStanding(CutsceneAsset $asset): array } /** - * The summon view: the Engine's playhead over the compiled timeline, a - * ruler with the keyframe bars and cues, the frame as the battle field - * would compose it, and the cues the playhead crossed. + * The summon and effect view: the Engine's playhead over the compiled + * timeline, a ruler with the keyframe bars and cues, the frame as the + * battle field would compose it (an effect's anchored to its caster and + * target), and the cues the playhead crossed. * * @return string[] */ - private function summonPreviewLines(CutsceneAsset $asset, int $contentWidth, int $contentHeight): array + private function timelinePreviewLines(CutsceneAsset $asset, int $contentWidth, int $contentHeight): array { - $preview = $this->summonPreview; + $preview = $this->timelinePreview; + $stage = $asset->type === CutsceneType::EFFECT ? $this->createEffectPreviewStage($asset) : null; if ($preview === null) { return [ ...$this->describeCutsceneStanding($asset), + ...($stage === null ? [] : [sprintf(' Plays as %s, the %s sequence.%s', + $stage->forStage ? 'an owned stage' : ($stage->forBattle ? 'battle' : 'the field'), $stage->presentation->value, + $stage->forStage ? ' No field or battle surroundings.' : ($stage->forBattle + ? sprintf(' %s is the caster, %s the target.', EffectPreviewStage::CASTER_MARKER, EffectPreviewStage::TARGET_MARKER) + : sprintf(' %s is the target.', EffectPreviewStage::TARGET_MARKER)))]), '', - ' Space: play through the Engine session · . , : step · < > : keyframe boundaries · R: restart · L: timeline/stage', + ' Space: play through the Engine session · . , : step · < > : keyframe boundaries · R: restart · L: timeline/stage' + . ($stage === null || $stage->forStage ? '' : ' · B: battle/field · D: caster side'), ]; } @@ -1345,12 +1484,28 @@ private function summonPreviewLines(CutsceneAsset $asset, int $contentWidth, int $stageWidth = max(20, $contentWidth - ($infoWidth > 0 ? $infoWidth + 1 : 0)); $rulerLines = $preview->rulerLines($stageWidth); $stageHeight = max(4, $contentHeight - count($rulerLines) - 1); - $frame = $preview->frame($stageWidth, $stageHeight); + $frame = $stage?->drawFrame($preview->activeSegments(), $stageWidth, $stageHeight) ?? $preview->frame($stageWidth, $stageHeight); $info = []; if ($infoWidth > 0) { $info[] = sprintf(' %s · frame %d/%d · %.1fs', $preview->isPlaying() ? 'playing' : ($preview->isCompleted() ? 'completed' : 'paused'), $preview->currentFrame(), $preview->totalFrames(), $preview->elapsed()); $info[] = sprintf(' %gx speed · %s', $preview->speed(), $preview->isLooping() ? 'looping' : 'once'); + + if ($stage !== null) { + $info[] = sprintf(' %s · %s sequence', $stage->forStage ? 'owned stage' + : ($stage->forBattle ? sprintf('battle, caster %s', $stage->isCasterOnLeft ? 'left' : 'right') : 'field'), $stage->presentation->value); + + if ($preview->phaseDurationSeconds !== null) { + $info[] = sprintf(' paced over %.2fs:', $preview->phaseDurationSeconds); + $info[] = ' ' . $this->describeEffectPreviewPacing($asset); + $info[] = ' A action · P pace · S stage'; + } + $info[] = ' Not drawn here:'; + + foreach ($stage->describeUndrawn($preview->activeSegments()) ?: ['(nothing)'] as $undrawn) { + $info[] = ' ' . $undrawn; + } + } $info[] = ' Cues here: ' . (implode(', ', array_map(static fn(array $cue): string => strval($cue['id'] ?? '?'), $preview->cuesAt())) ?: '—'); $info[] = ' Fired:'; diff --git a/src/Cutscenes/Editing/CutscenesWorkspace.php b/src/Cutscenes/Editing/CutscenesWorkspace.php index a7c69eb2..76dbdc6c 100644 --- a/src/Cutscenes/Editing/CutscenesWorkspace.php +++ b/src/Cutscenes/Editing/CutscenesWorkspace.php @@ -25,6 +25,9 @@ use Ichiloto\Editor\UI\Modal; use Ichiloto\Editor\UI\MultilineTextEditor; use Ichiloto\Editor\UI\SettingsPaneLayout; +use Ichiloto\Editor\Validation\EffectValidator; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Throwable; /** @@ -44,6 +47,14 @@ */ trait CutscenesWorkspace { + /** The row choosing an effect's sequence; no timeline key, so no schema field, has this id. */ + private const string SEQUENCE_FIELD = '@sequence'; + private const string SEQUENCE_SHARED = 'shared'; + private const string SEQUENCE_SEPARATE = 'separate'; + private const string STAGE_FIELD = '@stage'; + private const string STAGE_NONE = 'none'; + private const string STAGE_ADDED = 'added'; + /** * The type whose assets the screen shows. */ @@ -241,11 +252,13 @@ private function cutsceneRecords(): ?ProjectRecordDatabase private function cutsceneCategoryDefinition(): DatabaseCategoryDefinition { return new DatabaseCategoryDefinition( - $this->cutsceneType === CutsceneType::CINEMATIC ? CutsceneSchemas::CINEMATICS_KEY : CutsceneSchemas::SUMMONS_KEY, + match ($this->cutsceneType) { + CutsceneType::CINEMATIC => CutsceneSchemas::CINEMATICS_KEY, + CutsceneType::SUMMON => CutsceneSchemas::SUMMONS_KEY, + CutsceneType::EFFECT => CutsceneSchemas::EFFECTS_KEY, + }, $this->cutsceneType->label(), - $this->cutsceneType === CutsceneType::CINEMATIC - ? 'Staged story sequences run by the event interpreter.' - : 'Frame-driven battle presentations built by the summon compiler.', + $this->cutsceneType->describeCategory(), true, ); } @@ -862,25 +875,40 @@ private function getCutsceneSettingsFields(): array private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array { $heading = static fn(string $title): array => ['label' => $title, 'value' => '', 'editable' => false]; - $sections = $asset->type === CutsceneType::CINEMATIC - ? [ + $sections = match ($asset->type) { + CutsceneType::CINEMATIC => [ 'Identity' => ['id', 'name', 'description', 'version'], 'Staging' => ['startMap', 'presentation.initial', 'presentation.reducedMotion'], 'Script' => ['commandListCommands'], 'Skip' => ['skip.policy', 'checkpoints', 'commandListFinalizer'], 'Metadata' => ['authoring'], - ] - : [ + ], + CutsceneType::EFFECT => [ + 'Identity' => ['id'], + 'Timing' => ['fps', 'lengthFrames', 'playback', 'cadence', 'loopFrom', 'restFrame'], + 'Impact (battle)' => ['effectTiming.mode', 'effectTiming.frame', 'effectTiming.cueId'], + 'Timeline' => ['commandListTracks', 'commandListCues'], + ], + CutsceneType::SUMMON => [ 'Identity' => ['id', 'name', 'description', 'moveName', 'version', 'linkedSummonId', 'linkedActionId', 'tags'], - 'Lore' => ['lore', 'element', 'strengths', 'weaknesses', 'attributes', 'authoring'], + 'Lore' => ['lore', 'element', 'elementAffinities', 'effectiveAgainst', 'strengths', 'weaknesses', 'attributes', 'authoring'], 'Availability' => ['availability.conditions'], 'Wielders' => ['wielders.mode', 'wielders.roles', 'wielders.characters', 'wielders.tenancy'], 'Playback' => ['playback.defaultSpeed', 'playback.allowSkip', 'playback.loopPreview', 'transitionIn.type', 'transitionIn.durationMs', 'transitionIn.color', 'transitionOut.type', 'transitionOut.durationMs', 'transitionOut.color', 'effectTiming.mode', 'effectTiming.cueId', 'effectTiming.frame', 'targetPresentation.mode', 'targetPresentation.showCasterNameBanner'], - 'Timeline' => ['formatVersion', 'fps', 'lengthFrames', 'editor', 'commandListTracks', 'commandListCues'], - ]; + 'Timeline' => ['formatVersion', 'fps', 'lengthFrames', 'restFrame', 'editor', 'commandListTracks', 'commandListCues'], + 'Stage' => ['stage.canvas', 'stage.startFrame', 'stage.restoreFrame', 'stage.background', + 'commandList' . ucfirst(CutsceneSchemas::STAGE_SUBJECTS_KEY), 'commandList' . ucfirst(CutsceneSchemas::STAGE_CAMERA_KEY), + 'commandList' . ucfirst(CutsceneSchemas::STAGE_COVERS_KEY)], + ], + }; $byId = []; $rest = []; + if ($asset->type === CutsceneType::EFFECT && ($asset->payload()['cadence'] ?? null) === EffectCadence::BATTLE_PHASE->value) { + // A sequence the battle paces has no FPS: the phase it plays in sets its timing. + $fields = array_values(array_filter($fields, static fn(array $field): bool => ($field['field'] ?? null) !== 'fps')); + } + foreach ($fields as $field) { $id = (string) ($field['field'] ?? ''); $placed = false; @@ -919,12 +947,23 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array } } - if ($rows === []) { + $stageRow = $title === 'Stage' ? $this->getStageField($asset) : null; + + if ($rows === [] && $stageRow === null) { continue; } $grouped[] = $heading($title); + if ($stageRow !== null) { + $grouped[] = $stageRow; + } + + if ($title === 'Timeline' && $asset->type === CutsceneType::SUMMON) { + // A summon's definition is shared; its timeline may be one per renderer. + $grouped = [...$grouped, ...$this->getSequenceFields($asset)]; + } + if ($title === 'Skip') { // Skip is a second ending, not a cancel: the Engine cancels // every lane and then runs the finalizer to the same final @@ -941,6 +980,10 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array } $grouped = [...$grouped, ...$rows]; + + if ($title === 'Identity' && $asset->type === CutsceneType::EFFECT) { + $grouped = [...$grouped, ...$this->getSequenceFields($asset)]; + } } if ($rest !== []) { @@ -951,6 +994,139 @@ private function groupCutsceneFields(CutsceneAsset $asset, array $fields): array return $grouped; } + /** + * Returns the row that chooses which of an effect's or summon's sequences + * the pane edits, and a note on what the other one does. A flat + * timeline's single sequence plays for every renderer; choosing separate + * gives it a terminal and a graphical sequence, each a copy of it, + * undoably. + * + * @return array> + */ + private function getSequenceFields(CutsceneAsset $asset): array + { + $view = $asset->getPresentationView(); + + if ($view === null) { + return [ + [ + 'label' => 'Sequence', + 'value' => 'Shared', + 'options' => [self::SEQUENCE_SHARED, self::SEQUENCE_SEPARATE], + 'field' => self::SEQUENCE_FIELD, + 'hint' => 'separate gives the terminal and graphical renderers a sequence each', + ], + ['label' => ' One sequence plays for the terminal and graphical renderers alike.', 'value' => '', 'editable' => false], + ]; + } + + $other = $view === EffectPresentation::TERMINAL ? EffectPresentation::GRAPHICAL : EffectPresentation::TERMINAL; + + return [ + [ + 'label' => 'Sequence', + 'value' => ucfirst($view->value), + 'options' => array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases()), + 'field' => self::SEQUENCE_FIELD, + 'hint' => 'the sequence these rows edit', + ], + ['label' => sprintf(' Edits apply to the %s sequence; the %s one is kept as written.', $view->value, $other->value), 'value' => '', 'editable' => false], + ]; + } + + /** + * Returns the row that gives a summon's graphical sequence a cinematic + * stage or takes it away; none for any other sequence, since the + * terminal never draws one. + * + * @return array|null + */ + private function getStageField(CutsceneAsset $asset): ?array + { + if ($asset->type !== CutsceneType::SUMMON || $asset->getPresentationView() !== EffectPresentation::GRAPHICAL) { + return null; + } + + return [ + 'label' => 'Stage', + 'value' => $asset->hasStage() ? self::STAGE_ADDED : self::STAGE_NONE, + 'options' => [self::STAGE_NONE, self::STAGE_ADDED], + 'field' => self::STAGE_FIELD, + 'hint' => 'a stage of its own in place of the arena, until it is restored', + ]; + } + + /** + * Gives the selected summon's graphical sequence a stage, or takes it + * away, as one undo step. + */ + private function applyStageValue(string $rawValue): void + { + $asset = $this->selectedCutscene(); + $present = strtolower(trim($rawValue)) === self::STAGE_ADDED; + + if ($asset === null || $asset->getPresentationView() !== EffectPresentation::GRAPHICAL || $present === $asset->hasStage()) { + return; + } + + $changed = $this->mutateSelectedCutscene($present ? 'Add stage' : 'Remove stage', static function () use ($asset, $present): void { + $asset->setStage($present); + }); + + if ($changed) { + $this->setStatus($present + ? sprintf('Summon "%s" has a stage. Give it subjects, art on it and a rest frame inside it. It reaches disk on save.', $asset->id) + : sprintf('Summon "%s" no longer has a stage. It reaches disk on save.', $asset->id), StatusLevel::INFO); + } + + $this->renderCutscenesArea(); + } + + /** + * Shows another of the selected effect's or summon's sequences, or gives + * a flat one separate sequences, recorded so undo puts the flat file back. + */ + private function applySequenceValue(string $rawValue): void + { + $asset = $this->selectedCutscene(); + $value = strtolower(trim($rawValue)); + + if ($asset === null || $asset->type === CutsceneType::CINEMATIC) { + return; + } + + if (! $asset->hasPresentations()) { + if ($value !== self::SEQUENCE_SEPARATE) { + return; + } + + $this->mutateSelectedCutscene(sprintf('Separate %s sequences', $asset->type->noun()), static function () use ($asset): void { + $asset->selectPresentation(EffectPresentation::TERMINAL); + $asset->splitIntoPresentations(); + }); + $this->setStatus(sprintf('%s "%s" now has a terminal and a graphical sequence. It reaches disk on save.', ucfirst($asset->type->noun()), $asset->id), StatusLevel::INFO); + $this->renderCutscenesArea(); + + return; + } + + $presentation = EffectPresentation::tryFrom($value); + + if ($presentation === null || $presentation === $asset->getPresentationView()) { + return; + } + + // Which sequence is shown is the screen's, not the file's: nothing is + // written or recorded, and the records are rebuilt from that sequence. + $asset->selectPresentation($presentation); + $this->cutsceneLibrary()?->refreshRecords($asset->type); + // A running preview plays the other sequence; it starts again on this one. + $this->disposeCinematicPreview(); + $this->databaseCommandFramePath = []; + $this->setStatus(sprintf('Editing the %s sequence of %s "%s".', $presentation->value, $asset->type->noun(), $asset->id), StatusLevel::INFO); + $this->renderCutscenesArea(); + } + /** * Returns the settings pane's width and height while the workspace * hosts the record pane. @@ -983,9 +1159,8 @@ private function mutateSelectedCutscene(string $label, callable $mutation): bool { $library = $this->cutsceneLibrary(); $asset = $this->selectedCutscene(); - $records = $this->cutsceneRecords(); - if ($library === null || $asset === null || $records === null) { + if ($library === null || $asset === null) { return false; } @@ -996,33 +1171,22 @@ private function mutateSelectedCutscene(string $label, callable $mutation): bool return false; } - $type = $asset->type; - $id = $asset->id; - $before = $this->snapshotCutscene($asset); - try { - $mutation($records, $this->getSelectedRecordIndex()); - $records->save(); + // The library owns the change: records, the asset and the undo step that restores it. + $command = $library->changeAsset($asset->type, $this->getSelectedRecordIndex(), $label, $mutation, + fn(CutsceneAsset $restored) => $this->followRestoredCutscene($restored))['command']; } catch (Throwable $throwable) { - $library->refreshRecords($type); $this->setErrorStatus($throwable, $label); $this->renderCutscenesArea(); return false; } - $library->refreshRecords($type); - $after = $this->snapshotCutscene($asset); - - if ($after === $before) { + if ($command === null) { return false; } - $this->recordCommand(new GenericCommand( - $label, - fn() => $this->restoreCutsceneSnapshot($type, $id, $after), - fn() => $this->restoreCutsceneSnapshot($type, $id, $before), - )); + $this->recordCommand($command); return true; } @@ -1086,17 +1250,17 @@ private function mutateCutscenePayload(string $label, callable $change): bool } /** - * @return array{payload: array, deleted: bool} + * @return array{data: array, partner: array, view: EffectPresentation, deleted: bool} */ private function snapshotCutscene(CutsceneAsset $asset): array { - return ['payload' => $asset->payload(), 'deleted' => $asset->isDeleted()]; + return $asset->captureEditState(); } /** * Puts an asset back to a recorded state, and the screen onto it. * - * @param array{payload: array, deleted: bool} $snapshot + * @param array{data: array, partner: array, view: EffectPresentation, deleted: bool} $snapshot */ private function restoreCutsceneSnapshot(CutsceneType $type, string $id, array $snapshot): void { @@ -1107,17 +1271,21 @@ private function restoreCutsceneSnapshot(CutsceneType $type, string $id, array $ return; } - $asset->apply($snapshot['payload']); - $asset->markDeleted($snapshot['deleted']); + $asset->restoreEditState($snapshot); $library->refreshRecords($type); + $this->followRestoredCutscene($asset); + } + /** Puts the screen onto an asset an undo or redo just restored, when the workspace is open. */ + private function followRestoredCutscene(CutsceneAsset $asset): void + { if ($this->isCutscenesOpen) { - if ($this->cutsceneType !== $type) { - $this->switchCutsceneType($type); + if ($this->cutsceneType !== $asset->type) { + $this->switchCutsceneType($asset->type); } $this->clampCutsceneSelection(); - $this->selectCutsceneById($id); + $this->selectCutsceneById($asset->id); $this->clampDatabaseSettingSelection(); $this->renderCutscenesArea(); } @@ -1142,6 +1310,18 @@ private function applyCutsceneFieldValueRecorded(array $field, string $rawValue) return; } + if ($fieldId === self::SEQUENCE_FIELD && $this->databaseCommandFramePath === []) { + $this->applySequenceValue($rawValue); + + return; + } + + if ($fieldId === self::STAGE_FIELD && $this->databaseCommandFramePath === []) { + $this->applyStageValue($rawValue); + + return; + } + $framePath = $this->databaseCommandFramePath; $this->mutateSelectedCutscene( sprintf('%s edit', $field['label'] ?? 'Cutscene field'), @@ -1189,7 +1369,7 @@ function () use ($library, $type, $oldId, $newId): void { $this->renderCutscenesArea(); }, )); - $this->setStatus(sprintf('Renamed to "%s"; the folder takes this name on save.', $newId), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Renamed to "%s"; the folder takes this name on save.', $newId), StatusLevel::INFO); $this->renderCutscenesArea(includeRoot: true); } @@ -1297,7 +1477,7 @@ private function addCutsceneSubItem(): void } $this->cutsceneFocus = CutscenesScreen::PANE_SETTINGS; - $this->setStatus('Added.', StatusLevel::SUCCESS); + $this->setStatus('Added.', StatusLevel::INFO); $this->renderCutscenesArea(); } @@ -1388,7 +1568,7 @@ private function removeCutsceneSubItem(bool $last): void } $this->clampDatabaseSettingSelection(); - $this->setStatus('Removed. Ctrl+Z restores it.', StatusLevel::SUCCESS); + $this->setStatus('Removed. Ctrl+Z restores it.', StatusLevel::INFO); $this->renderCutscenesArea(); } @@ -1408,13 +1588,10 @@ private function createCutscene(): void } $type = $this->cutsceneType; - $id = $library->freeId($type, strval($records->schema->blank['id'] ?? ('new-' . $type->noun()))); - $payload = $records->schema->blank; - $payload['id'] = $id; try { - $created = CutsceneAsset::create($type, $id, $library->rootFor($type), $payload, $this->workspace?->projectRoot); - $library->adopt($created); + ['asset' => $created, 'command' => $command] = $library->createAsset($type, + restored: fn(CutsceneAsset $restored) => $this->followRestoredCutscene($restored)); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Cutscene creation'); $this->renderCutscenesArea(); @@ -1422,11 +1599,8 @@ private function createCutscene(): void return; } - $this->recordCommand(new GenericCommand( - sprintf('Create %s', $type->noun()), - fn() => $this->restoreCutsceneSnapshot($type, $id, ['payload' => $payload, 'deleted' => false]), - fn() => $this->restoreCutsceneSnapshot($type, $id, ['payload' => $payload, 'deleted' => true]), - )); + $id = $created->id; + $this->recordCommand($command); $this->leaveCutsceneEditingState(); $this->cutsceneFilter->clear(); @@ -1434,7 +1608,7 @@ private function createCutscene(): void $this->databaseSelectedSettingIndex = 0; $this->databaseCommandFramePath = []; $this->cutsceneFocus = CutscenesScreen::PANE_SETTINGS; - $this->setStatus(sprintf('Created %s "%s". It reaches disk on save.', $type->noun(), $id), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Created %s "%s". It reaches disk on save.', $type->noun(), $id), StatusLevel::INFO); $this->renderCutscenesArea(includeRoot: true); } @@ -1450,11 +1624,9 @@ private function duplicateSelectedCutscene(): void return; } - $type = $asset->type; - $newId = $library->freeId($type, $asset->id . '-copy'); - try { - $copy = $library->duplicate($type, $asset->id, $newId); + ['asset' => $copy, 'command' => $command] = $library->duplicateAsset($asset->type, $asset->id, + restored: fn(CutsceneAsset $restored) => $this->followRestoredCutscene($restored)); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Cutscene duplication'); $this->renderCutscenesArea(); @@ -1462,18 +1634,14 @@ private function duplicateSelectedCutscene(): void return; } - $payload = $copy->payload(); - $this->recordCommand(new GenericCommand( - sprintf('Duplicate %s', $type->noun()), - fn() => $this->restoreCutsceneSnapshot($type, $newId, ['payload' => $payload, 'deleted' => false]), - fn() => $this->restoreCutsceneSnapshot($type, $newId, ['payload' => $payload, 'deleted' => true]), - )); + $newId = $copy->id; + $this->recordCommand($command); $this->leaveCutsceneEditingState(); $this->selectCutsceneById($newId); $this->databaseSelectedSettingIndex = 0; $this->databaseCommandFramePath = []; - $this->setStatus(sprintf('Duplicated as "%s". It reaches disk on save.', $newId), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Duplicated as "%s". It reaches disk on save.', $newId), StatusLevel::INFO); $this->renderCutscenesArea(includeRoot: true); } @@ -1505,7 +1673,8 @@ private function openCutsceneDeleteConfirmation(): void /** * Returns who points at an asset: maps whose triggers launch a - * cinematic, actors who start with a summon. + * cinematic, actors who start with a summon, and everything that plays + * an effect. * * @return string[] */ @@ -1521,6 +1690,12 @@ private function describeCutsceneReferences(CutsceneAsset $asset): array } } } + } elseif ($asset->type === CutsceneType::EFFECT) { + if ($this->workspace instanceof ProjectWorkspace) { + $uses = EffectValidator::findUses($this->workspace)[$asset->id] ?? []; + $scripts = EffectValidator::findScriptUses($this->workspace, $this->cutsceneLibrary()?->assets(CutsceneType::CINEMATIC) ?? []); + $references = [...($uses['battle'] ?? []), ...($uses['field'] ?? []), ...($uses['stage'] ?? []), ...($scripts[$asset->id] ?? [])]; + } } else { foreach ($this->workspace?->actorDatabase->getActors() ?? [] as $actor) { $summons = $actor->getSummons(); @@ -1557,7 +1732,7 @@ static function (ProjectRecordDatabase $records, int $index): void { $this->clampCutsceneSelection(); $this->databaseSelectedSettingIndex = 0; $this->databaseCommandFramePath = []; - $this->setStatus(sprintf('Deleted %s. Ctrl+Z restores it; the folder is removed on save.', $asset->name()), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Deleted %s. Ctrl+Z restores it; the folder is removed on save.', $asset->name()), StatusLevel::INFO); } $this->renderCutscenesArea(includeRoot: true); @@ -1584,7 +1759,7 @@ private function saveSelectedCutscene(): void $written ? sprintf('%s "%s" saved.', ucfirst($asset->type->noun()), $asset->id) : sprintf('%s "%s" is clean; nothing written.', ucfirst($asset->type->noun()), $asset->id), - StatusLevel::SUCCESS, + $written ? StatusLevel::SUCCESS : StatusLevel::INFO, ); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s save', ucfirst($asset->type->noun()))); @@ -1610,26 +1785,14 @@ private function firstDeletedCutscene(): ?CutsceneAsset } /** - * Saves every dirty asset of both types, for Save All. - * - * @return array{saved: string[], failed: array} + * Shows the cutscenes screen again after a save, when it is open. */ - private function saveAllCutscenes(): array + private function showSavedCutscenes(): void { - $library = $this->cutsceneLibrary(); - - if ($library === null) { - return ['saved' => [], 'failed' => []]; - } - - $result = $library->saveAll(fn(string ...$paths) => $this->backupBeforeSave(...$paths)); - if ($this->isCutscenesOpen) { $this->clampCutsceneSelection(); $this->renderCutscenesArea(includeRoot: true); } - - return $result; } // -- Multiline editing ----------------------------------------------------- @@ -1685,7 +1848,7 @@ private function handleMultilineEditorInput(string $input): void if (is_array($field)) { try { $this->applyDatabaseFieldValueRecorded($field, $text); - $this->setStatus(sprintf('%s applied.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s applied.', $label), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, $label); } @@ -2020,9 +2183,7 @@ private function getCutsceneSettingsLines(): array '', 'Shift+A in the list creates one.', '', - $this->cutsceneType === CutsceneType::CINEMATIC - ? 'A cinematic is a staged story scene: a command tree the engine runs on the field, with its own cast, camera, skip policy and finalizer.' - : 'A summon is a frame-driven battle presentation: tracks, keyframes and cues the engine compiles and plays.', + $this->cutsceneType->describeAsset(), ]; } diff --git a/src/Cutscenes/Preview/CinematicPreviewOrigin.php b/src/Cutscenes/Preview/CinematicPreviewOrigin.php new file mode 100644 index 00000000..1ef79487 --- /dev/null +++ b/src/Cutscenes/Preview/CinematicPreviewOrigin.php @@ -0,0 +1,83 @@ +maps as $map) { + foreach ($map->getEventDefinitions() as $marker => $definition) { + if (! is_array($definition) || ! str_contains(strval($definition['class'] ?? ''), 'CinematicEventTrigger')) { + continue; + } + + $data = is_array($definition['data'] ?? null) ? $definition['data'] : []; + + if (trim(strval($data['cinematicId'] ?? '')) !== $asset->id) { + continue; + } + + // Where the runtime places the marker: the first cell it triggers on, painted or its subject's. + $first = $map->getEventTriggerArea((string) $marker)?->firstCell; + + if ($first !== null) { + return ['mapId' => $map->mapId, 'x' => (int) $first->x, 'y' => (int) $first->y, 'marker' => (string) $marker]; + } + } + } + + $map = null; + + if ($startMap !== null) { + foreach ($workspace->maps as $candidate) { + if ($candidate->mapId === $startMap) { + $map = $candidate; + } + } + } + + $map ??= $openMap; + + if (! $map instanceof ProjectMap) { + return ['mapId' => $startMap, 'x' => 1, 'y' => 1, 'marker' => null]; + } + + [$x, $y] = self::findFirstOpenTile($map); + + return ['mapId' => $map->mapId, 'x' => $x, 'y' => $y, 'marker' => null]; + } + + /** + * The first tile that is not a wall-like glyph, row by row. + * + * @return array{0: int, 1: int} + */ + private static function findFirstOpenTile(ProjectMap $map): array + { + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if ($map->getTileSymbol($x, $y) === ' ') { + return [$x, $y]; + } + } + } + + return [0, 0]; + } +} diff --git a/src/Cutscenes/Preview/CinematicPreviewSession.php b/src/Cutscenes/Preview/CinematicPreviewSession.php index 025e3bb6..2d325ec1 100644 --- a/src/Cutscenes/Preview/CinematicPreviewSession.php +++ b/src/Cutscenes/Preview/CinematicPreviewSession.php @@ -4,7 +4,6 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Engine\Audio\BackgroundMusicState; use Ichiloto\Engine\Battle\BattleResult; use Ichiloto\Engine\Core\Vector2; @@ -13,11 +12,7 @@ use Ichiloto\Engine\Events\Interpreter\EventExecutionSession; use Ichiloto\Engine\Events\Interpreter\EventExecutionStatus; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; -use Ichiloto\Engine\Util\Config\ConfigStore; -use Ichiloto\Engine\Util\Config\PlaySettings; -use Ichiloto\Engine\Util\Config\ProjectConfig; -use Ichiloto\Engine\Util\Debug; -use Ichiloto\Engine\Util\Interfaces\ConfigInterface; +use Ichiloto\Engine\Rendering\Transport\RendererGridConfig; use Throwable; /** @@ -57,9 +52,6 @@ final class CinematicPreviewSession private ?string $battleOutcome = null; /** @var array */ private array $log = []; - /** @var array */ - private array $previousConfigs = []; - private bool $configured = false; /** @var array Row keys executing when the session last ticked. */ private array $lastActiveKeys = []; /** @var array|null}> The same, with the commands. */ @@ -68,6 +60,8 @@ final class CinematicPreviewSession private bool $outcomeAnnounced = false; /** What was playing before the cinematic started, as the engine held it. */ private ?BackgroundMusicState $musicBefore = null; + /** The isolated field the cinematic plays in. */ + private PreviewField $field; /** * @param array{mapId?: string|null, x?: int, y?: int, width?: int, height?: int, autoAdvance?: bool, battleOutcome?: string} $options @@ -108,34 +102,25 @@ public function restart(): self private function boot(): void { - $width = max(20, intval($this->options['width'] ?? 60)); - $height = max(8, intval($this->options['height'] ?? 18)); $mapId = $this->options['mapId'] ?? $this->definition->startMap; $mapId = is_string($mapId) && trim($mapId) !== '' ? trim($mapId) : ''; + $this->field = PreviewField::open($this->projectRoot, $mapId, + new Vector2(intval($this->options['x'] ?? 0), intval($this->options['y'] ?? 0)), + intval($this->options['width'] ?? 60), intval($this->options['height'] ?? 18), fn(): float => $this->elapsed); + $this->scene = $this->field->scene; + $this->presentation = $this->field->presentation; + if ($mapId === '') { + $this->note('No start map: the cinematic plays over undefined terrain.'); + } elseif ($this->field->mapFailure !== null) { + $this->note(sprintf('Map %s could not be loaded: %s', $mapId, $this->field->mapFailure)); + } else { + $this->note(sprintf('Map %s loaded.', $mapId)); + } - $this->run(function () use ($width, $height, $mapId): void { - $this->scene = new PreviewGameScene(new PreviewSceneManager(), $width, $height, $mapId); - $camera = $this->scene->previewCamera(); - $this->presentation = new PreviewPresentation($camera); + $this->run(function (): void { $this->presentation->autoAdvance = (bool) ($this->options['autoAdvance'] ?? false); $this->interpreter = new EventInterpreter($this->scene, $this->presentation); $this->scene->installInterpreter($this->interpreter); - $player = new PreviewPlayer(new Vector2(intval($this->options['x'] ?? 0), intval($this->options['y'] ?? 0))); - $this->scene->installPlayer($player); - - if ($mapId !== '') { - try { - $this->scene->previewMap->loadForPreview($mapId); - $this->note(sprintf('Map %s loaded.', $mapId)); - } catch (Throwable $throwable) { - $this->scene->previewMap->unload(); - $this->note(sprintf('Map %s could not be loaded: %s', $mapId, $throwable->getMessage())); - } - } else { - $this->note('No start map: the cinematic plays over undefined terrain.'); - } - - $camera->attach($player); $audio = $this->scene->getGame()->audioManager; $startingMusic = is_array($this->options['startingMusic'] ?? null) ? $this->options['startingMusic'] : null; @@ -268,6 +253,12 @@ public function moveChoice(int $delta): bool return $this->presentation->moveHighlight($delta); } + /** What waits on the author: `dialogue`, `choice`, or null. */ + public function getWaitKind(): ?string + { + return $this->presentation->getWaitKind(); + } + /** * Plays to the end without waiting on dialogue, choosing the first option * of every choice and resolving battles as configured. @@ -295,19 +286,7 @@ public function runToCompletion(float $maxSeconds = 600.0): bool */ public function dispose(): void { - if (! $this->configured) { - return; - } - - foreach ([ProjectConfig::class, PlaySettings::class] as $class) { - if (isset($this->previousConfigs[$class])) { - ConfigStore::put($class, $this->previousConfigs[$class]); - } else { - ConfigStore::remove($class); - } - } - - $this->configured = false; + $this->field->dispose(); } // ------------------------------------------------------------------ @@ -434,7 +413,7 @@ public function skipRefusalReason(): ?string public function waitDescription(): ?string { if ($this->status() === self::STATUS_BATTLE) { - return sprintf('Battle in progress — resolved as %s on the next step (preview assumption)', $this->battleOutcomeLabel()); + return sprintf('Battle in progress: resolved as %s on the next step (preview assumption)', $this->battleOutcomeLabel()); } return $this->presentation->describeWait(); @@ -546,20 +525,54 @@ public function activeKeys(): array */ public function frame(): array { - $rows = []; - $this->run(function () use (&$rows): void { - $camera = $this->scene->previewCamera(); - $camera->clearFrame(); - $this->scene->previewMap->render(); - $this->scene->npcManager?->render(); - $this->scene->player?->render(); - $this->scene->cinematicStage?->render(); - $this->scene->cinematicPresentation?->render(); - $this->presentation->render(); - $rows = $camera->frame(); - }); + return $this->field->frame(); + } + + /** + * One exchange with the editor window's graphical view of the scene + * ({@see PreviewField::exchangeScene()}), at the preview's playhead. + * + * @param list $events + * @param string|null $sessionId The field epoch the feedback answers. + * @return list}> + */ + public function exchangeScene(array $events, ?string $sessionId = null): array + { + return $this->field->exchangeScene($events, $sessionId); + } + + /** The graphical view's grid ({@see PreviewField::getSceneGrid()}). */ + public function getSceneGrid(): RendererGridConfig + { + return $this->field->getSceneGrid(); + } + + /** The field's graphical-host epoch, without attaching a graphical view. */ + public function getSceneSessionId(): string + { + return $this->field->sessionId; + } - return $rows; + /** @return list The current map's diagnostics, including transfers and recovery. */ + public function getDiagnostics(): array + { + return $this->field->getDiagnostics(); + } + + /** Ends the editor window's graphical view of the scene, if one is attached. */ + public function detachScene(): void + { + $this->field->detachScene(); + } + + /** + * The captured screen's size, in columns and rows. + * + * @return array{int, int} + */ + public function getScreenSize(): array + { + return $this->field->getScreenSize(); } /** @@ -567,7 +580,7 @@ public function frame(): array */ public function resize(int $width, int $height): void { - $this->scene->previewCamera()->resize(max(20, $width), max(8, $height)); + $this->field->resize($width, $height); } /** @@ -667,6 +680,8 @@ private function advance(float $seconds): void $this->resolveBattle(); } + // What the field shows by itself, then the cinematic, in a game frame's order. + $this->scene->advanceFieldPresentation($seconds); $this->presentation->update(); $this->interpreter->update($seconds); // The engine's own audio upkeep, as a game frame runs it. @@ -963,54 +978,10 @@ private function note(string $text): void } /** - * Runs Engine code inside the project root with the preview - * configuration installed and any stray terminal output discarded. + * Runs Engine code in the preview's field ({@see PreviewField::run()}). */ private function run(callable $operation): void { - $this->installConfiguration(); - - ProjectDirectoryContext::run($this->projectRoot, function () use ($operation): void { - ob_start(); - - try { - $operation(); - } finally { - ob_end_clean(); - } - }); - } - - private function installConfiguration(): void - { - if ($this->configured) { - return; - } - - foreach ([ProjectConfig::class, PlaySettings::class] as $class) { - if (ConfigStore::has($class)) { - $this->previousConfigs[$class] = ConfigStore::get($class); - } - } - - $width = max(20, intval($this->options['width'] ?? 60)); - $height = max(8, intval($this->options['height'] ?? 18)); - ConfigStore::put(ProjectConfig::class, new PreviewConfig([ - 'accessibility' => ['reducedMotion' => false], - 'save' => ['autosave' => false], - 'ui' => ['hud' => ['location' => false]], - // The engine records what a cinematic asked for either way; with - // music off it never starts a player process for it. - 'audio' => ['music' => false, 'sfx' => false], - ])); - ConfigStore::put(PlaySettings::class, new PreviewConfig([ - 'screen' => ['width' => $width, 'height' => $height], - 'width' => $width, - 'height' => $height, - ])); - - $logDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ichiloto-editor-preview-logs'; - Debug::configure(['log_level' => Debug::ERROR, 'log_directory' => $logDirectory]); - $this->configured = true; + $this->field->run($operation); } } diff --git a/src/Cutscenes/Preview/EffectBattlePreview.php b/src/Cutscenes/Preview/EffectBattlePreview.php new file mode 100644 index 00000000..8c8a0e96 --- /dev/null +++ b/src/Cutscenes/Preview/EffectBattlePreview.php @@ -0,0 +1,151 @@ + $this->preview->totalFrames; } + + /** + * @param string $lane `source` or `target`: where the effect plays in the action shown. + * @param list $contexts Every action of the party that plays it. + * @param int $binding Which of them is shown. + * @param list $targets + * @param array $phases + */ + private function __construct( + private readonly BattleCommandPreview $preview, + private readonly BattleCommandTimeline $plan, + public readonly string $lane, + public readonly array $contexts, + public readonly int $binding, + public readonly string $caster, + public readonly array $targets, + public readonly array $phases, + ) { + } + + /** + * @param callable(string, EffectPresentation): CompiledEffectTimeline $compile An effect by id, compiled for battle, as it stands. + * @param BattlePresentationCatalog|null $catalog The battle presentation, which a graphical preview requires. + * @param int $binding Which of the actions that play the effect to show; the first when it is out of range. + * @throws InvalidArgumentException When no action of the battle test party plays the effect. + */ + public static function create( + string $effectId, + BattleConfig $battle, + SkillCatalog $skills, + AnimationLibrary $animations, + BattlePacing $pacing, + ?BattlePresentationCatalog $catalog, + string $assetRoot, + EffectPresentation $presentation, + callable $compile, + int $binding = 0, + ): self { + $state = new GameState(); + $found = []; + foreach ($battle->partyRoster->battlers as $caster) { + if (! $caster instanceof Character) { + continue; + } + $candidates = []; + foreach ([BattleCommandType::ATTACK, BattleCommandType::SKILL, BattleCommandType::MAGIC, BattleCommandType::ITEM] as $type) { + foreach (BattleCommandCatalog::buildOptions($caster, $battle->party, $type->label(), [], $state) as $option) { + // A summon's choreography is previewed as the summon, not through an animation. + if (! $skills->isSummonAction($option->action->name)) { + $candidates[] = $option->action; + } + } + } + foreach (ActionAnimationResolver::findEffectBindings($effectId, $animations, $caster, $candidates) as $match) { + $found[] = [...$match, 'caster' => $caster]; + } + } + if ($found === []) { + throw new InvalidArgumentException(sprintf('No action of the battle test party plays %s in battle, so it has no battle preview.', $effectId)); + } + $binding = $binding >= 0 && $binding < count($found) ? $binding : 0; + ['lane' => $lane, 'animation' => $animation, 'action' => $action, 'caster' => $caster] = $found[$binding]; + + // The animation's two effects as the battle plays them in this presentation; a graphical target + // drawing nothing in the terminal keeps its terminal sequence beside it, as the battle does. + $source = $animation->sourceEffect === null ? null : $compile($animation->sourceEffect, $presentation); + $target = $animation->targetEffect === null ? null : $compile($animation->targetEffect, $presentation); + $terminalTarget = $presentation === EffectPresentation::GRAPHICAL && $target !== null && ! $target->hasTerminalContent + ? $compile($animation->targetEffect, EffectPresentation::TERMINAL) : null; + // The action's own binding sizes the target effect, so each grade previews at its authored scale. + $plan = new BattleCommandTimeline($pacing->getTurnTimings($action), $source, $target, $terminalTarget, + targetScale: ActionAnimationResolver::getExplicitAnimationScale($action)); + $targets = BattleTargetPolicy::resolveTargets($action->targetScope, $caster, $battle->partyRoster->battlers, $battle->troop->members->toArray()); + if ($targets === []) { + throw new InvalidArgumentException(sprintf('%s has no one to act on in the battle test.', $action->name)); + } + + return new self( + new BattleCommandPreview($battle, $plan, $caster, $targets, BattlePoseRole::getForAction($action), + $presentation === EffectPresentation::TERMINAL ? null : $catalog, $assetRoot, $presentation), + $plan, + $lane, + array_map(static fn(array $context): array => ['caster' => $context['caster']->name, 'action' => $context['action']->name, 'lane' => $context['lane']], $found), + $binding, + $caster->name, + array_map(static fn($target): string => $target->name, $targets), + $plan->phases, + ); + } + + /** + * One command frame as the battle draws it. + * + * @return array + * @throws InvalidArgumentException When the frame is outside the command. + */ + public function readFrame(int $frame, bool $reducedMotion): array + { + return $this->preview->getFrameAtIndex($frame, $reducedMotion)->toArray(); + } + + /** + * The command frame that draws one of the effect's own frames, in the lane it plays in; a frame + * before it maps to its first, one after it to its last. + */ + public function findCommandFrame(int $authoredFrame): int + { + $phase = $this->phases[$this->lane]; + $frame = $this->plan->getCommandFrameForAuthoredFrame($this->lane, max(0, $authoredFrame)); + + return $frame ?? ($authoredFrame <= 0 ? $phase['start'] : $phase['start'] + $phase['length'] - 1); + } +} diff --git a/src/Cutscenes/Preview/EffectPreviewStage.php b/src/Cutscenes/Preview/EffectPreviewStage.php new file mode 100644 index 00000000..9193e8ed --- /dev/null +++ b/src/Cutscenes/Preview/EffectPreviewStage.php @@ -0,0 +1,249 @@ +> $segments The playhead's active segments. + * @return string[] + */ + public function drawFrame(array $segments, int $width, int $height): array + { + $width = max(1, $width); + $height = max(1, $height); + $cells = array_fill(0, $height, array_fill(0, $width, ' ')); + $origins = $this->findOrigins($width, $height); + + foreach ($origins as $anchor => $origin) { + $this->place($cells, $anchor === 'caster' ? self::CASTER_MARKER : self::TARGET_MARKER, $origin['x'], $origin['y']); + } + + $commands = []; + + foreach ($this->composeSegments($segments) as $segment) { + if (! in_array($segment['layer'] ?? '', ['glyph', 'text'], true)) { + continue; + } + + foreach (array_filter((array) ($segment['drawCommands'] ?? []), is_array(...)) as $command) { + $commands[] = $command; + } + } + + usort($commands, static fn(array $a, array $b): int => ($a['zIndex'] ?? 0) <=> ($b['zIndex'] ?? 0)); + + foreach ($commands as $command) { + if (($command['visible'] ?? true) === false) { + continue; + } + + $anchor = strval($command['payload']['anchor'] ?? 'target'); + $base = in_array($anchor, ['screen', 'legacy-screen'], true) + ? ['x' => 0, 'y' => 0] + : ($origins[$anchor] ?? $origins['target'] ?? ['x' => 0, 'y' => 0]); + + $command = $this->orient($command); + $position = is_array($command['position'] ?? null) ? $command['position'] : []; + $x = $base['x'] + intval($position['x'] ?? 0); + $y = $base['y'] + intval($position['y'] ?? 0); + + foreach (explode("\n", trim(strval($command['content'] ?? ''), "\r\n")) as $row => $line) { + $this->place($cells, $line, $x, $y + $row); + } + } + + return array_map(static fn(array $row): string => implode('', $row), $cells); + } + + /** Seekable Engine projection, with no scene, audio or gameplay state to retain. */ + public function renderCanvas(CompiledEffectTimeline $timeline, string $assetRoot, int $frame, + int $width, int $height, bool $reducedMotion = false): ?PresentationCanvas + { + if (! $this->forStage || $this->presentation !== EffectPresentation::GRAPHICAL) { + return null; + } + + $length = intval($timeline->defaults['lengthFrames'] ?? 1); + $stage = CinematicStage::fromArray($timeline->defaults['stage'] ?? [], $length, intval($timeline->defaults['restFrame'] ?? 0)); + $frame = max(0, min($frame, $length - 1)); + + return CinematicStagePresentation::compose($stage->getFrame($frame, $reducedMotion), + $timeline->playbackSegments, $assetRoot, max(1, $width), max(1, $height)); + } + + /** + * Describes the active draws a terminal cannot show: images with their + * sheet frame and flips, flashes and shakes with their subject. + * + * @param array> $segments The playhead's active segments. + * @return string[] + */ + public function describeUndrawn(array $segments): array + { + $lines = $this->forStage && $this->presentation === EffectPresentation::TERMINAL + ? ['stage images and camera: graphical only'] : []; + + foreach ($this->composeSegments($segments) as $segment) { + $layer = strval($segment['layer'] ?? ''); + + if (in_array($layer, ['glyph', 'text'], true)) { + continue; + } + + foreach (array_filter((array) ($segment['drawCommands'] ?? []), is_array(...)) as $command) { + $command = $this->orient($command); + $payload = is_array($command['payload'] ?? null) ? $command['payload'] : []; + $subject = strval($payload['anchor'] ?? 'target'); + $lines[] = match ($layer) { + 'image' => sprintf('%s frame %d%s%s on %s', basename(strval($command['assetId'] ?? '?')), intval($payload['sourceFrame'] ?? 0), + ($payload['flipX'] ?? false) === true ? ' flipX' : '', ($payload['flipY'] ?? false) === true ? ' flipY' : '', $subject), + 'flash' => sprintf('flash %s on %s', strval($payload['color'] ?? $command['color'] ?? 'white'), $subject), + 'shake' => sprintf('shake %s on %s', strval($payload['amplitude'] ?? 1), $subject), + default => sprintf('%s on %s', $layer, $subject), + }; + } + } + + // Image tracks never reach the terminal's composition; say so once. + if ($this->presentation === EffectPresentation::TERMINAL) { + foreach ($segments as $segment) { + if (($segment['layer'] ?? '') === 'image') { + $lines[] = 'image tracks: graphical only'; + + break; + } + } + } + + return $lines; + } + + /** + * Turns a battle stroke with a facing toward its recipient, the target, + * exactly as the battle turns it; the field draws every stroke as authored. + * + * @param array $command + * @return array + */ + private function orient(array $command): array + { + if (! $this->forBattle) { + return $command; + } + + return BattleEffectDirection::orientCommand($command, $this->isCasterOnLeft ? 0 : 1, $this->isCasterOnLeft ? 1 : 0); + } + + /** + * @param array> $segments + * @return list> + */ + private function composeSegments(array $segments): array + { + return EffectSegmentComposition::compose(array_values($segments), $this->presentation); + } + + /** + * Where each subject stands on a stage of this size. + * + * @return array + */ + private function findOrigins(int $width, int $height): array + { + $y = intdiv($height, 2); + + if ($this->forStage) { + return []; + } + + if (! $this->forBattle) { + return ['target' => ['x' => intdiv($width, 2), 'y' => $y]]; + } + + $left = intdiv($width, 4); + $right = $width - 1 - intdiv($width, 4); + + return [ + 'caster' => ['x' => $this->isCasterOnLeft ? $left : $right, 'y' => $y], + 'target' => ['x' => $this->isCasterOnLeft ? $right : $left, 'y' => $y], + ]; + } + + /** + * Writes a line into the cells from a column, clipping at the edges and + * keeping a wide symbol whole. + * + * @param array> $cells + */ + private function place(array &$cells, string $line, int $x, int $y): void + { + if ($y < 0 || $y >= count($cells)) { + return; + } + + $width = count($cells[$y]); + $column = $x; + + foreach (TerminalText::visibleSymbols($line) as $symbol) { + $symbolWidth = max(1, TerminalText::displayWidth($symbol)); + + if (trim($symbol) !== '' && $column >= 0 && $column + $symbolWidth <= $width) { + $cells[$y][$column] = $symbol; + + for ($offset = 1; $offset < $symbolWidth; $offset++) { + $cells[$y][$column + $offset] = ''; + } + } + + $column += $symbolWidth; + } + } +} diff --git a/src/Cutscenes/Preview/PreviewCamera.php b/src/Cutscenes/Preview/PreviewCamera.php index f1128fed..b8e6bb05 100644 --- a/src/Cutscenes/Preview/PreviewCamera.php +++ b/src/Cutscenes/Preview/PreviewCamera.php @@ -4,32 +4,22 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Engine\Core\Rect; -use Ichiloto\Engine\Core\Vector2; -use Ichiloto\Engine\IO\Console\TerminalText; use Ichiloto\Engine\Rendering\Camera; +use Ichiloto\Engine\Scenes\Interfaces\SceneInterface; /** - * A camera that draws into a frame buffer instead of the terminal. - * - * Everything the Engine paints during a cinematic — the map, staged actors, - * NPCs, the player, title cards, narration boxes, field animations, and - * transition covers — reaches the terminal through the camera, so capturing - * here gives the preview pane an honest picture of the moment without the - * Engine ever writing a byte over the editor's own screen. + * The camera of a preview scene: the field's own camera, drawing through the + * Engine's Console as the game's does, so a preview's picture is taken in an + * isolated Console capture ({@see \Ichiloto\Engine\IO\Console\Console::capturePresentation()}) + * and the editor's own screen is never written. The field camera's UI + * lifecycle belongs to the game's UI manager, which a preview scene has none + * of, so it does nothing here. */ final class PreviewCamera extends Camera { - /** @var string[] The captured rows, one per screen line. */ - private array $frame = []; - - public function __construct(int $width, int $height) + public function __construct(SceneInterface $scene, int $width, int $height) { - $this->screen = new Rect(0, 0, max(1, $width), max(1, $height)); - $this->position = new Vector2(0, 0); - $this->player = null; - $this->worldSpace = []; - $this->clearFrame(); + parent::__construct($scene, max(1, $width), max(1, $height)); } /** @@ -38,92 +28,6 @@ public function __construct(int $width, int $height) public function resize(int $width, int $height): void { $this->resizeViewport($width, $height); - $this->clearFrame(); - } - - /** - * Blanks the frame before the scene is composed again. - */ - public function clearFrame(): void - { - $this->frame = array_fill(0, $this->screen->getHeight(), str_repeat(' ', $this->screen->getWidth())); - } - - /** - * Returns the captured rows. - * - * @return string[] - */ - public function frame(): array - { - return $this->frame; - } - - /** - * Paints the world (the map tiles) into the frame. - */ - public function renderMap(): void - { - $renderOffset = $this->getRenderOffset(); - $visibleWidth = $this->getVisibleWorldWidth(); - $visibleHeight = $this->getVisibleWorldHeight(); - - for ($row = 0; $row < $visibleHeight; $row++) { - $worldRow = $this->worldSpace[(int) $this->position->y + $row] ?? null; - - if ($worldRow === null) { - continue; - } - - $content = is_array($worldRow) - ? implode('', array_slice($worldRow, (int) $this->position->x, $visibleWidth)) - : TerminalText::sliceSymbols((string) $worldRow, (int) $this->position->x, $visibleWidth); - - $this->draw(TerminalText::padRight($content, $visibleWidth), (int) $renderOffset->x, (int) $renderOffset->y + $row); - } - } - - public function draw(iterable|string $content, int $x = 0, int $y = 0): void - { - $lines = []; - - // A row handed over with a newline inside it would break the - // terminal's row, so it continues on the next row here as well. - foreach (is_string($content) ? [$content] : $content as $line) { - foreach (explode("\n", (string) $line) as $part) { - $lines[] = $part; - } - } - - $height = $this->screen->getHeight(); - $width = $this->screen->getWidth(); - $index = 0; - - foreach ($lines as $line) { - $row = $y + $index; - $index++; - - if ($row < 0 || $row >= $height) { - continue; - } - - $this->writeRow($row, max(0, min($x, $width - 1)), (string) $line); - } - } - - public function renderOnScreen(array $output, Vector2 $worldSpacePosition): void - { - $screenSpacePosition = $this->getScreenSpacePosition($worldSpacePosition); - $this->renderAtScreenPosition($output, $screenSpacePosition); - } - - public function renderAtScreenPosition(array|string $output, Vector2 $screenSpacePosition): void - { - $rows = is_array($output) ? array_values($output) : [$output]; - - foreach ($rows as $rowIndex => $row) { - $this->draw((string) $row, (int) $screenSpacePosition->x, (int) $screenSpacePosition->y + $rowIndex); - } } public function start(): void @@ -153,67 +57,4 @@ public function suspend(): void public function update(): void { } - - /** - * Writes text into one frame row, symbol by symbol, honouring display - * width so a wide glyph occupies the cells it would on a terminal. - */ - private function writeRow(int $row, int $x, string $text): void - { - $width = $this->screen->getWidth(); - $plain = TerminalText::stripAnsi(TerminalText::formatStyles($text)); - $plain = preg_replace('/\x1b\[[0-9;]*m/', '', $plain) ?? $plain; - $cells = TerminalText::visibleSymbols($this->frame[$row] ?? str_repeat(' ', $width)); - $cells = $this->normalizeCells($cells, $width); - $cursor = $x; - - foreach (TerminalText::visibleSymbols($plain) as $symbol) { - $symbolWidth = max(1, TerminalText::displayWidth($symbol)); - - if ($cursor >= $width || $cursor + $symbolWidth > $width) { - break; - } - - $cells[$cursor] = $symbol; - - for ($offset = 1; $offset < $symbolWidth; $offset++) { - $cells[$cursor + $offset] = ''; - } - - $cursor += $symbolWidth; - } - - $this->frame[$row] = implode('', $cells); - } - - /** - * Expands a row into exactly `$width` cells, one per terminal column. - * - * @param string[] $symbols - * @return string[] - */ - private function normalizeCells(array $symbols, int $width): array - { - $cells = []; - $column = 0; - - foreach ($symbols as $symbol) { - $symbolWidth = max(1, TerminalText::displayWidth($symbol)); - $cells[$column] = $symbol; - - for ($offset = 1; $offset < $symbolWidth; $offset++) { - $cells[$column + $offset] = ''; - } - - $column += $symbolWidth; - } - - for (; $column < $width; $column++) { - $cells[$column] = ' '; - } - - ksort($cells); - - return array_slice($cells, 0, $width, true); - } } diff --git a/src/Cutscenes/Preview/PreviewChoiceOwner.php b/src/Cutscenes/Preview/PreviewChoiceOwner.php new file mode 100644 index 00000000..d8e62271 --- /dev/null +++ b/src/Cutscenes/Preview/PreviewChoiceOwner.php @@ -0,0 +1,44 @@ + $options */ + public function __construct( + public readonly string $title, + public readonly string $prompt, + public readonly array $options, + public int $highlighted, + private readonly Rect $bounds, + ) { + } + + public function getModalPresentation(): ModalPresentation + { + return new ModalPresentation($this->title, $this->prompt, $this->options, $this->highlighted, true); + } + + public function getPresentationBounds(): Rect + { + return $this->bounds; + } + + public function getPresentationPriority(): PresentationPriority + { + return PresentationPriority::MODAL; + } +} diff --git a/src/Cutscenes/Preview/PreviewDialogueOwner.php b/src/Cutscenes/Preview/PreviewDialogueOwner.php new file mode 100644 index 00000000..61105cce --- /dev/null +++ b/src/Cutscenes/Preview/PreviewDialogueOwner.php @@ -0,0 +1,66 @@ +pagination->getSnapshot($this->page); + } + + public function getPresentationBounds(): Rect + { + $origin = $this->pagination->position->getCoordinates($this->pagination->windowWidth, $this->pagination->windowHeight); + + return new Rect((int) $origin->x, (int) $origin->y, $this->pagination->windowWidth, $this->pagination->windowHeight); + } + + public function getPresentationPriority(): PresentationPriority + { + return PresentationPriority::MODAL; + } + + /** The page shown, from the first. */ + public function getPage(): int + { + return $this->page; + } + + /** The text of the page shown. */ + public function getPageText(): string + { + return $this->pagination->pages[$this->page]; + } + + /** Moves to the next page; false on the last, which the author's continue closes instead. */ + public function turnPage(): bool + { + if ($this->page >= count($this->pagination->pages) - 1) { + return false; + } + $this->page++; + + return true; + } +} diff --git a/src/Cutscenes/Preview/PreviewField.php b/src/Cutscenes/Preview/PreviewField.php new file mode 100644 index 00000000..9c7b536b --- /dev/null +++ b/src/Cutscenes/Preview/PreviewField.php @@ -0,0 +1,400 @@ + $this->scene->previewMap->mapFailure; + } + /** Opaque graphical-host epoch, reserved without opening a graphical view. */ + public private(set) string $sessionId; + /** The editor window's graphical view of the field, while one is attached. */ + private ?PreviewSceneHost $sceneHost = null; + /** @var array */ + private array $previousConfigs = []; + private bool $configured = false; + /** The field's screen in Terminal cells, which the graphical view's camera viewport never changes. */ + private int $screenWidth; + private int $screenHeight; + + /** + * @param Closure(): float $readTime The owner's clock, in seconds. + */ + private function __construct( + private readonly string $projectRoot, + int $width, + int $height, + private readonly Closure $readTime, + ) { + $this->screenWidth = max(20, $width); + $this->screenHeight = max(8, $height); + $this->sessionId = bin2hex(random_bytes(16)); + } + + /** + * Opens the field on a map, with the player standing on a cell and the + * camera following them. A map that cannot be loaded leaves undefined + * terrain and says why ({@see $mapFailure}). + * + * @param Closure(): float $readTime The owner's clock, in seconds. + */ + public static function open(string $projectRoot, string $mapId, Vector2 $spawn, int $width, int $height, Closure $readTime): self + { + $field = new self($projectRoot, $width, $height, $readTime); + $field->run(function () use ($field, $mapId, $spawn): void { + $field->scene = new PreviewGameScene(new PreviewSceneManager(), $field->screenWidth, $field->screenHeight, $mapId); + $field->scene->installFieldEffects(EffectPresentation::TERMINAL); + $camera = $field->scene->previewCamera(); + $field->presentation = new PreviewPresentation($field->screenWidth, $field->screenHeight); + $player = new PreviewPlayer($field->scene, $spawn); + $field->scene->installPlayer($player); + try { + $field->scene->previewMap->loadForPreview($mapId); + } catch (Throwable) { + // The map manager owns the undefined terrain and its diagnostic. + } + $camera->attach($player); + }); + + return $field; + } + + /** + * Opens the field on a map as the editor holds it, unsaved edits included + * ({@see PreviewMapManager::loadProjectMapForPreview()}), with the player + * standing on a cell facing a heading, or with nobody on it. A character + * shown in the player's place gives their graphical art. The camera + * follows the player, or holds the map's top-left corner without one. + * A map that cannot be loaded leaves undefined terrain and says why. + * + * @param Closure(): float $readTime The owner's clock, in seconds. + */ + public static function openProjectMap(string $projectRoot, ProjectMap $map, ?Vector2 $spawn, int $width, int $height, + Closure $readTime, MovementHeading $heading = MovementHeading::NONE, ?CharacterSheet $appearance = null): self + { + $field = new self($projectRoot, $width, $height, $readTime); + $field->run(function () use ($field, $map, $spawn, $heading, $appearance): void { + $field->scene = new PreviewGameScene(new PreviewSceneManager(), $field->screenWidth, $field->screenHeight, $map->mapId); + $field->scene->installFieldEffects(EffectPresentation::TERMINAL); + $camera = $field->scene->previewCamera(); + $field->presentation = new PreviewPresentation($field->screenWidth, $field->screenHeight); + $player = $spawn === null ? null : new PreviewPlayer($field->scene, $spawn, heading: $heading, appearance: $appearance); + if ($player !== null) { + $field->scene->installPlayer($player); + } + try { + $field->scene->previewMap->loadProjectMapForPreview($map); + } catch (Throwable) { + // The map manager owns the undefined terrain and its diagnostic. + } + if ($player !== null) { + $camera->attach($player); + } else { + $camera->detach(); + $camera->moveTo(0, 0); + } + }); + + return $field; + } + + /** + * Holds the camera still with its top-left as near a map cell as the map + * allows, instead of following the player. + */ + public function holdCameraAt(int $x, int $y): void + { + $this->run(function () use ($x, $y): void { + $camera = $this->scene->previewCamera(); + $camera->detach(); + $camera->moveTo($x, $y); + }); + } + + /** + * The map cell the screen's top-left shows, as the camera projects the + * world: negative where a map smaller than the screen sits centred on it. + * + * @return array{x: int, y: int} + */ + public function getCameraOrigin(): array + { + return $this->scene->previewCamera()->getWorldOrigin(); + } + + /** + * The game's graphical field as it stands, composed for a still picture + * by a renderer of the given capabilities that has no window to answer: + * the map's world, its sprites at this moment and the camera's viewport, + * as the editor window's graphical view composes them + * ({@see PreviewSceneHost}). The field cell is the graphical field's, so + * the camera keeps the screen it has; the Terminal picture is unchanged + * once the composition is taken. + * + * @param Closure(string): bool $supports The still renderer's capabilities. + */ + public function composeStill(Closure $supports): SceneFrameComposition + { + $assetRoot = $this->projectRoot . '/assets'; + $grid = new RendererGridConfig($this->screenWidth, $this->screenHeight, FieldViewport::TILE_SIZE, FieldViewport::TILE_SIZE); + $composition = null; + $this->run(function () use ($assetRoot, $grid, $supports, &$composition): void { + $this->scene->setPresentationContext(new ScenePresentationContext($grid, $supports, + collectPresentations: fn(): array => $this->presentation->getActivePresentations(), readTime: $this->readTime, + assetRoot: $assetRoot)); + $this->scene->installFieldEffects(EffectPresentation::GRAPHICAL); + try { + $composition = new SceneFrameComposer($assetRoot)->composeFrame($this->scene, $grid, $supports, + fn(array $excluded, array $worldLayers) => Console::capturePresentation($grid->columns, $grid->rows, + $this->renderScene(...), $excluded, $worldLayers, $this->scene->getPresentationWorld() !== null)); + } finally { + $this->scene->setPresentationContext(null); + $this->scene->installFieldEffects(EffectPresentation::TERMINAL); + } + }); + + return $composition ?? throw new \LogicException('The field composed no graphical picture.'); + } + + /** + * Runs Engine code inside the project root with the preview + * configuration installed and any stray terminal output discarded. + */ + public function run(callable $operation): void + { + $this->installConfiguration(); + + ProjectDirectoryContext::run($this->projectRoot, function () use ($operation): void { + ob_start(); + + try { + $operation(); + } finally { + ob_end_clean(); + } + }); + } + + /** + * Advances what the field shows by itself on the owner's clock: walking + * frames, NPC and stage animation and the map's effects, as a game frame + * advances them ({@see \Ichiloto\Engine\Scenes\Game\GameScene::advanceFieldPresentation()}). + */ + public function advance(float $seconds): void + { + $this->run(fn() => $this->scene->advanceFieldPresentation(max(0.0, $seconds))); + } + + /** + * Shows an effect on the field at one of its frames, at the player + * ({@see PreviewGameScene::showFieldEffect()}). + * + * @param Closure(EffectPresentation): CompiledEffectTimeline $compile The effect, compiled for a presentation. + */ + public function showFieldEffect(Closure $compile, int $frame): void + { + $this->run(fn() => $this->scene->showFieldEffect($compile, $frame)); + } + + /** + * The composed picture of the field at this moment. + * + * @return string[] + */ + public function frame(): array + { + $rows = []; + $this->run(function () use (&$rows): void { + // The same isolated capture the graphical view composes from, with nothing left out. + $rows = Console::capturePresentation($this->screenWidth, $this->screenHeight, $this->renderScene(...))->rows ?? []; + }); + + return $rows; + } + + /** + * One exchange with the editor window's graphical view of the field + * ({@see PreviewSceneHost::exchange()}): its renderer event lines in, what + * it should apply out. The view's grid is the field's screen in the + * game's graphical text cells, so it shows what the game's graphical + * field would. While attached, the Engine sizes the field's camera to + * the graphical field's viewport, as the game does, so the Terminal + * picture is the game's Terminal view only while detached. + * + * @param list $events + * @param string|null $sessionId The epoch the feedback answers; null discovers the current epoch without applying events. + * @return list}> + */ + public function exchangeScene(array $events, ?string $sessionId = null): array + { + // Check ownership before allocating a host or decoding old READY/ACK/error lines. + if ($sessionId !== $this->sessionId) { + return []; + } + $grid = $this->getSceneGrid(); + $messages = []; + $this->run(function () use ($grid, $events, &$messages): void { + $this->sceneHost ??= new PreviewSceneHost($this->scene, $this->presentation, + $this->projectRoot . '/assets', $grid, $this->renderScene(...), $this->readTime, $this->sessionId); + $messages = $this->sceneHost->exchange($events); + }); + + return $messages; + } + + /** The graphical view's grid: the field's screen in the game's graphical text cells. */ + public function getSceneGrid(): RendererGridConfig + { + return new RendererGridConfig($this->screenWidth, $this->screenHeight, + RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT); + } + + /** @return list Why the current field has no usable map, if it does not. */ + public function getDiagnostics(): array + { + return $this->scene->previewMap->getDiagnostics(); + } + + /** Ends the editor window's graphical view of the field, if one is attached. */ + public function detachScene(): void + { + if ($this->sceneHost === null) { + return; + } + // In the project, as every step of the field runs, so the Terminal effects it restores find their files. + $this->run(fn() => $this->sceneHost->dispose()); + $this->sceneHost = null; + $this->sessionId = bin2hex(random_bytes(16)); + } + + /** + * The captured screen's size, in columns and rows. + * + * @return array{int, int} + */ + public function getScreenSize(): array + { + return [$this->screenWidth, $this->screenHeight]; + } + + /** + * Resizes the captured screen. + */ + public function resize(int $width, int $height): void + { + // The graphical view's grid is the screen's, so a new size starts it again. + $previousSessionId = $this->sessionId; + $this->detachScene(); + if ($this->sessionId === $previousSessionId) { + $this->sessionId = bin2hex(random_bytes(16)); + } + $this->screenWidth = max(20, $width); + $this->screenHeight = max(8, $height); + $this->scene->previewCamera()->resize($this->screenWidth, $this->screenHeight); + $this->presentation->resizeScreen($this->screenWidth, $this->screenHeight); + } + + /** + * Ends the graphical view and releases the Engine configuration the field installed. + */ + public function dispose(): void + { + $this->detachScene(); + if (! $this->configured) { + return; + } + + foreach ([ProjectConfig::class, PlaySettings::class] as $class) { + if (isset($this->previousConfigs[$class])) { + ConfigStore::put($class, $this->previousConfigs[$class]); + } else { + ConfigStore::remove($class); + } + } + + $this->configured = false; + } + + /** Draws everything the field draws, in the game's order. */ + private function renderScene(): void + { + $this->scene->previewMap->render(); + $this->scene->renderEventCues($this->scene->previewMap->eventTriggers); + $this->scene->npcManager?->render(); + $this->scene->player?->render(); + $this->scene->cinematicStage?->render(); + $this->scene->cinematicPresentation?->render(); + $this->presentation->render(); + } + + private function installConfiguration(): void + { + if ($this->configured) { + return; + } + + foreach ([ProjectConfig::class, PlaySettings::class] as $class) { + if (ConfigStore::has($class)) { + $this->previousConfigs[$class] = ConfigStore::get($class); + } + } + + ConfigStore::put(ProjectConfig::class, new PreviewConfig([ + 'accessibility' => ['reducedMotion' => false], + 'save' => ['autosave' => false], + 'ui' => ['hud' => ['location' => false]], + // The engine records what the field asks for either way; with + // music off it never starts a player process for it. + 'audio' => ['music' => false, 'sfx' => false, 'voice' => false], + ])); + ConfigStore::put(PlaySettings::class, new PreviewConfig([ + 'screen' => ['width' => $this->screenWidth, 'height' => $this->screenHeight], + 'width' => $this->screenWidth, + 'height' => $this->screenHeight, + ])); + + $logDirectory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'ichiloto-editor-preview-logs'; + Debug::configure(['log_level' => Debug::ERROR, 'log_directory' => $logDirectory]); + $this->configured = true; + } +} diff --git a/src/Cutscenes/Preview/PreviewGameScene.php b/src/Cutscenes/Preview/PreviewGameScene.php index b233a65f..246da45a 100644 --- a/src/Cutscenes/Preview/PreviewGameScene.php +++ b/src/Cutscenes/Preview/PreviewGameScene.php @@ -4,10 +4,17 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Closure; +use Ichiloto\Engine\Animations\Field\FieldEffectManager; +use Ichiloto\Engine\Animations\Field\FieldEffectAnchor; +use Ichiloto\Engine\Animations\Field\FieldEffectSession; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; use Ichiloto\Engine\Core\GameState; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicController; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicPresentationManager; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicStageManager; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicSubjectResolver; use Ichiloto\Engine\Entities\Party; use Ichiloto\Engine\Events\Interpreter\EventExecutionSession; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; @@ -38,6 +45,12 @@ final class PreviewGameScene extends GameScene public PreviewMapManager $previewMap; + /** The id the effect an author previews plays under on the field. */ + private const string SHOWN_EFFECT_ID = 'editor-preview'; + + /** @var array{0: Closure(EffectPresentation): CompiledEffectTimeline, 1: int}|null The effect an author previews, at its frame. */ + private ?array $shownEffect = null; + public function __construct( public readonly PreviewSceneManager $previewSceneManager, int $screenWidth, @@ -48,7 +61,7 @@ public function __construct( $this->gameState = new GameState(); $this->hasDeferredAutoSave = false; $this->currentMapId = $initialMapId; - $this->camera = new PreviewCamera($screenWidth, $screenHeight); + $this->camera = new PreviewCamera($this, $screenWidth, $screenHeight); $this->party = new Party(); $this->knowledge = new KnowledgeProgressService(KnowledgeCatalog::fromProject()); $this->bestiary = new Bestiary($this->knowledge); @@ -60,9 +73,50 @@ public function __construct( $this->cinematicController = new CinematicController($this); } + /** + * Gives the scene the field's effects for a presentation, with the loaded + * map's installed, as the game's field has them: Terminal glyphs for the + * Terminal picture, graphical sprites for the graphical view. + */ + public function installFieldEffects(EffectPresentation $presentation): void + { + $this->fieldEffects?->clear(); + $this->fieldEffects = new FieldEffectManager(getcwd() . '/assets', $presentation); + // A new manager learns what the attached renderer draws, as the scene's own did; without it, it draws nothing. + if ($this->getPresentationContext() !== null) { + $this->synchronizeFieldViewport(); + } + $this->previewMap->installFieldEffects(); + if ($this->shownEffect !== null) { + $this->showFieldEffect(...$this->shownEffect); + } + } + + /** + * Shows an effect on the field at one of its frames, at the player the + * camera follows, as a cinematic's field animation presents one: an + * effect session this scene owns, compiled for the field's presentation + * and held at the frame for inspection, so no cue plays and no clock + * moves it. It stays shown, in whichever presentation the field is, + * until another is shown. + * + * @param Closure(EffectPresentation): CompiledEffectTimeline $compile The effect, compiled for a presentation. + */ + public function showFieldEffect(Closure $compile, int $frame): void + { + $this->shownEffect = [$compile, $frame]; + $presentation = $this->cinematicPresentation ?? throw new \LogicException('The preview scene has no cinematic presentation.'); + $presentation->removeEffect(self::SHOWN_EFFECT_ID); + $session = new FieldEffectSession(self::SHOWN_EFFECT_ID, FieldEffectAnchor::createAtPosition( + new CinematicSubjectResolver($this)->position(['kind' => 'player'])), + $compile($this->isGraphicalFieldPresented() ? EffectPresentation::GRAPHICAL : EffectPresentation::TERMINAL)); + $session->playback->seek($frame); + $session->playback->pause(); + $presentation->presentEffect($session); + } + public function installPlayer(PreviewPlayer $player): void { - $player->bindScene($this); $this->player = $player; } @@ -82,7 +136,7 @@ public function previewCamera(): PreviewCamera return $camera; } - public function transferPlayer(Location $location, bool $useConfiguredTransition = true): void + public function transferPlayer(Location $location, bool $useConfiguredTransition = true, ?\Closure $onCommitted = null): bool { $this->transfers[] = [ 'map' => $location->mapFilename, @@ -103,21 +157,28 @@ public function transferPlayer(Location $location, bool $useConfiguredTransition $this->currentMapId = $location->mapFilename; + $loaded = false; try { $this->previewMap->loadForPreview($location->mapFilename); + $loaded = $location->mapFilename !== ''; } catch (\Throwable) { - // An unknown destination is a validation finding, not a preview - // crash: the cinematic continues over undefined terrain and the - // final state still reports the map it asked for. - $this->previewMap->unload(); + // The map manager clears failed geometry and records why; the + // cinematic can continue over explicitly diagnosed undefined terrain. } if ($this->player !== null && $this->camera->followsPlayer) { $this->camera->resetPosition($this->player); } + // Only a destination that actually loaded commits the transfer, so its owner's completion runs before + // events resume or anything saves. A map that failed keeps its diagnosed undefined-terrain preview but + // never completes the trigger that sent the player there. + if ($loaded && $onCommitted !== null) { + $onCommitted(); + } $this->eventInterpreter?->resumeAfterTransfer(); $this->autoSave(); + return true; } public function onEventSessionStarted(EventExecutionSession $session): void diff --git a/src/Cutscenes/Preview/PreviewMapManager.php b/src/Cutscenes/Preview/PreviewMapManager.php index a34423c4..b48afdae 100644 --- a/src/Cutscenes/Preview/PreviewMapManager.php +++ b/src/Cutscenes/Preview/PreviewMapManager.php @@ -4,11 +4,19 @@ namespace Ichiloto\Editor\Cutscenes\Preview; +use Closure; +use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Events\Triggers\EventTrigger; use Ichiloto\Engine\Core\Game; -use Ichiloto\Engine\Core\Vector2; use Ichiloto\Engine\Field\MapManager; +use Ichiloto\Engine\Field\MapCollisionResolver; +use Ichiloto\Engine\Field\MapGraphics; +use Ichiloto\Engine\Field\MapSourceReader; +use Ichiloto\Engine\Field\MapTerrainKinds; +use Ichiloto\Engine\Field\MapPhysicalOccupancy; use Ichiloto\Engine\Field\Player; use Ichiloto\Engine\Scenes\Game\GameScene; +use Throwable; /** * A map manager that loads the author's map files for their tiles, collision @@ -21,8 +29,15 @@ */ final class PreviewMapManager extends MapManager { + private const string NO_MAP_DIAGNOSTIC = 'No map is selected: the preview shows undefined terrain.'; + /** @var array The data array of the map last loaded. */ public array $mapData = []; + public private(set) ?string $mapFailure = null; + /** @var list The loaded map's events, bound to the preview's world state; their cues show, they never run. */ + public private(set) array $eventTriggers = []; + /** @var list The outcome of the current map load, never a retired map's failure. */ + private array $diagnostics = [self::NO_MAP_DIAGNOSTIC]; public function __construct(Game $game, GameScene $gameScene) { @@ -36,28 +51,115 @@ public function __construct(Game $game, GameScene $gameScene) */ public function loadForPreview(string $mapId): array { - $map = $this->readMapDataFromFile($mapId); - $this->mapData = $map; - $this->calculateMapDimensions(); - $dictionaryPath = getcwd() . '/assets/Maps/collisions.php'; - $dictionary = is_file($dictionaryPath) ? $this->loadCollisionDictionary($dictionaryPath) : []; - $this->collisionMap = $this->generateCollisionMap($this->tileMap, $dictionary); - $this->gameScene->npcManager?->configure(is_array($map['npcs'] ?? null) ? $map['npcs'] : []); + if ($mapId === '') { + $this->unload(); + + return []; + } + + return $this->load($mapId, function () use ($mapId): array { + $map = $this->readMapDataFromFile($mapId); + $kinds = MapTerrainKinds::loadForMap(getcwd() . '/assets', $map); + if (array_key_exists(MapPhysicalOccupancy::DATA_KEY, $map)) { + return [$map, MapCollisionResolver::resolveMap($this->layers, $map, kinds: $kinds)->collisionGrid]; + } + $dictionaryPath = getcwd() . '/assets/Maps/collisions.php'; + $dictionary = is_file($dictionaryPath) ? $this->loadCollisionDictionary($dictionaryPath) : []; + $dictionary = $kinds?->composeDictionary($dictionary) ?? $dictionary; + + return [$map, $this->layers === null + ? $this->generateCollisionMap($this->tileMap, $dictionary) + : $this->generateLayerCollisionMap($this->layers, $dictionary)]; + }); + } + + /** + * Loads a map as the editor holds it, unsaved edits included, for its + * tiles, collision and NPCs, as {@see loadForPreview()} loads its files: + * the Engine prepares the map's source as it prepares one read from disk, + * with the graphics, tile layers, terrain kinds and collision the editor + * reads from the same map. + * + * @return array The map's data array. + */ + public function loadProjectMapForPreview(ProjectMap $projectMap): array + { + return $this->load($projectMap->mapId, function () use ($projectMap): array { + $source = MapSourceReader::readDeclarations($projectMap->mapId, $projectMap->getEditableData(), + $projectMap->getLayerSet(), $projectMap->getEventLayerCells()); + $map = $this->installMapSource($projectMap->mapId, $source, $projectMap->loadTerrainKinds(), $projectMap->loadGraphics(...), + static fn(): array => MapGraphics::readLayersFromSources($projectMap->getTileLayerSources(), $projectMap->mapId, + $source['layers'])); + + return [$map, $projectMap->getResolvedCollisionMap()]; + }); + } + + /** + * Loads a map read by `$read`, which installs its geometry and answers its + * data and collision; a failure leaves undefined terrain and says why. + * + * @param Closure(): array{0: array, 1: int[][]} $read + * @return array The map's data array. + */ + private function load(string $mapId, Closure $read): array + { + try { + [$map, $collisions] = $read(); + $this->mapData = $map; + $this->calculateMapDimensions(); + $this->collisionMap = $collisions; + // Bound to the preview's world state for their cues alone; the preview never runs them. + $this->eventTriggers = $this->createEventTriggers(array_values(array_filter((array) ($map['events'] ?? []), is_array(...))), + strval($map['id'] ?? '')); + $this->gameScene->npcManager?->configure(is_array($map['npcs'] ?? null) ? $map['npcs'] : []); + $this->installFieldEffects(); + } catch (Throwable $failure) { + $this->unload(); + $this->mapFailure = $failure->getMessage(); + $this->diagnostics = [sprintf('Map %s could not be loaded: %s The preview shows undefined terrain.', + $mapId, $this->mapFailure)]; + + throw $failure; + } + $this->mapFailure = null; + $this->diagnostics = []; return $map; } + /** @return list The current map's load diagnostics. */ + public function getDiagnostics(): array + { + return $this->diagnostics; + } + + /** + * Gives the scene's field effects the loaded map's own, as the game's + * field installs them when it loads a map: its declared effects, piece + * effects and event cues. + */ + public function installFieldEffects(): void + { + if ($this->mapData === [] || $this->gameScene->fieldEffects === null) { + return; + } + // The map names itself by its data's id, as the game's field reads it. + $this->gameScene->fieldEffects->installMap(strval($this->mapData['id'] ?? ''), $this->mapData['fieldEffects'] ?? null, + $this->graphics, $this->eventTriggers); + } + /** * Clears the world so an unknown map reads as undefined terrain. */ public function unload(): void { $this->mapData = []; - $this->tileMap = []; - $this->collisionMap = []; - $this->calculateMapDimensions(); - $this->camera->worldSpace = []; + $this->eventTriggers = []; + $this->clearMapGeometry(); $this->gameScene->npcManager?->configure([]); + $this->mapFailure = null; + $this->diagnostics = [self::NO_MAP_DIAGNOSTIC]; } public function loadMap(string $filename, Player $player): self @@ -68,13 +170,10 @@ public function loadMap(string $filename, Player $player): self return $this; } - public function scrollMap(Player $player, Vector2 $moveDirection): bool - { - return false; - } - + /** Draws the map as the field does, with its world objects' Terminal cells. */ public function render(?int $x = null, ?int $y = null): void { + $this->refreshTerminalPresentation(); $this->camera->renderMap(); } } diff --git a/src/Cutscenes/Preview/PreviewPlayer.php b/src/Cutscenes/Preview/PreviewPlayer.php index f266d002..9081ac23 100644 --- a/src/Cutscenes/Preview/PreviewPlayer.php +++ b/src/Cutscenes/Preview/PreviewPlayer.php @@ -4,80 +4,50 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Assegai\Collections\ItemList; +use Ichiloto\Engine\Core\Enumerations\MovementHeading; use Ichiloto\Engine\Core\Rect; use Ichiloto\Engine\Core\Vector2; -use Ichiloto\Engine\Events\EventManager; use Ichiloto\Engine\Events\Interfaces\EventInterface; -use Ichiloto\Engine\Events\Interfaces\ObserverInterface; -use Ichiloto\Engine\Events\Interfaces\StaticObserverInterface; -use Ichiloto\Engine\Events\Triggers\EventTrigger; +use Ichiloto\Engine\Rendering\Sprites\CharacterSheet; use Ichiloto\Engine\Field\Player; -use Ichiloto\Engine\Rendering\Camera; +use Ichiloto\Engine\Field\PlayerPresentationConfig; use Ichiloto\Engine\Scenes\Game\GameScene; /** - * The player as a cinematic sees them: a position, a facing and a sprite that - * the interpreter's player commands move, with the field's own input, HUD and - * trigger machinery left out. + * Runtime player movement and presentation on an isolated preview field. + * PreviewMapManager omits ordinary map events and encounters; notifications + * stay local instead of reaching the game's movement observers. */ final class PreviewPlayer extends Player { - public ?string $facing = null; + public ?string $facing { + get => match ($this->heading) { + MovementHeading::NORTH => 'up', + MovementHeading::EAST => 'right', + MovementHeading::SOUTH => 'down', + MovementHeading::WEST => 'left', + default => null, + }; + } /** @var string[] */ public array $blockedMessages = []; /** + * The player of a preview scene, with the field art the project gives the + * player ({@see PlayerPresentationConfig}), as the game's field builds it. + * A preview showing a particular character in the player's place gives + * their graphical art; the Terminal sprites stay the player's own. + * * @param string[] $sprite */ - public function __construct(Vector2 $position, array $sprite = ['@']) - { - $this->position = $position; - $this->shape = new Rect(0, 0, 1, 1); - $this->sprite = $sprite; - $this->observers = new ItemList(ObserverInterface::class); - $this->staticObservers = new ItemList(StaticObserverInterface::class); - $this->events = new ItemList(EventTrigger::class); - $this->eventManager = EventManager::getInstance(new PreviewGame()); - } - - public function bindScene(GameScene $scene): void - { - $this->scene = $scene; - } - - public function tryMove(Vector2 $direction, Camera $camera): bool - { - $scene = $this->scene ?? null; - $destinationX = intval($this->position->x + $direction->x); - $destinationY = intval($this->position->y + $direction->y); - - if ($scene instanceof GameScene && ! $scene->mapManager?->canMoveTo($destinationX, $destinationY)) { - $this->face($direction, $camera); - - return false; - } - - $this->position->x = $destinationX; - $this->position->y = $destinationY; - $this->face($direction, $camera); - - if ($camera->followsPlayer) { - $camera->resetPosition($this); - } - - return true; - } - - public function face(Vector2 $direction, Camera $camera): void + public function __construct(GameScene $scene, Vector2 $position, array $sprite = ['@'], + MovementHeading $heading = MovementHeading::NONE, ?CharacterSheet $appearance = null) { - $this->facing = match (true) { - $direction->y < 0 => 'up', - $direction->y > 0 => 'down', - $direction->x < 0 => 'left', - default => 'right', - }; + $presentation = PlayerPresentationConfig::load(); + parent::__construct($scene, 'Player', $position, new Rect(0, 0, 1, 1), $sprite, $heading, + directionalSprites: $presentation->terminal->toArray(), + graphicalSprites: $appearance ?? $presentation->graphical); } public function render(): void diff --git a/src/Cutscenes/Preview/PreviewPresentation.php b/src/Cutscenes/Preview/PreviewPresentation.php index 556869d1..1db4c5e0 100644 --- a/src/Cutscenes/Preview/PreviewPresentation.php +++ b/src/Cutscenes/Preview/PreviewPresentation.php @@ -4,141 +4,161 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Engine\Events\Interpreter\EventPresentationInterface; +use Closure; +use Ichiloto\Engine\Core\Rect; +use Ichiloto\Engine\Events\Interpreter\EventDialoguePresentationInterface; +use Ichiloto\Engine\IO\Console\Console; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialogueContext; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialoguePageLayout; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialoguePaginationBuilder; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Engine\UI\Interfaces\LayeredPresentationInterface; /** * Dialogue and choices as the preview pane presents them. * * The interpreter hands over text and choice prompts exactly as it would to - * the field's windows; here they wait for the author (Enter to continue, - * ↑/↓ and Enter to choose) unless the session is told to advance on its own, - * which a run to completion or a skip comparison does. + * the field's windows. A line is paged as the game pages it + * ({@see DialoguePaginationBuilder}) and each page waits for the author + * (Enter to continue, ↑/↓ and Enter to choose) unless the session is told to + * advance on its own, which a run to completion or a skip comparison does. + * What is on screen is offered to the Engine's scene composer as the game's + * own windows are ({@see getActivePresentations()}), so a graphical preview + * draws it the game's way, and is drawn into the Terminal picture on its own + * UI layer. + * + * Pages and boxes are measured on the preview's text screen, as the game's + * dialogue is on its console screen, never on the field camera: a graphical + * field's camera counts physical ground cells, which are far fewer than the + * text cells a page is laid out in. */ -final class PreviewPresentation implements EventPresentationInterface +final class PreviewPresentation implements EventDialoguePresentationInterface { - private ?string $text = null; - private string $speaker = ''; - /** @var string[]|null */ - private ?array $options = null; - private string $prompt = ''; - private string $title = ''; - private int $highlighted = 0; - private ?int $choice = null; + private ?PreviewDialogueOwner $dialogue = null; + private ?PreviewChoiceOwner $choice = null; + private ?int $result = null; private bool $confirmed = false; public bool $autoAdvance = false; + /** + * The graphical dialogue's page layout while a graphical preview is attached; null pages for the terminal. + * + * @var (Closure(string, DialogueContext, string): ?DialoguePageLayout)|null + */ + public ?Closure $pageLayout = null; /** @var array */ public array $log = []; - public function __construct(private readonly PreviewCamera $camera) + /** + * @param int $screenWidth The preview's text screen, in columns. + * @param int $screenHeight The preview's text screen, in rows. + */ + public function __construct(private int $screenWidth, private int $screenHeight) + { + } + + /** Takes the preview's new text screen size; the next line is paged and placed on it. */ + public function resizeScreen(int $width, int $height): void { + $this->screenWidth = $width; + $this->screenHeight = $height; } public function beginText(string $text, string $name = ''): void + { + $this->beginDialogue($text, $name, new DialogueContext()); + } + + public function beginDialogue(string $text, string $speaker, DialogueContext $context): void { $this->reset(); - $this->text = $text; - $this->speaker = $name; - $this->log[] = ['kind' => 'text', 'text' => $text, 'speaker' => $name]; + $layout = $this->pageLayout === null ? null : ($this->pageLayout)($speaker, $context, ''); + $this->dialogue = new PreviewDialogueOwner(DialoguePaginationBuilder::buildPagination($text, $speaker, '', $context, + $this->screenWidth, $this->screenHeight, $layout)); + $this->log[] = ['kind' => 'text', 'text' => $text, 'speaker' => $speaker]; } public function beginChoice(string $prompt, array $options, string $title = ''): void { $this->reset(); - $this->prompt = $prompt; - $this->title = $title; - $this->options = array_values(array_map(strval(...), $options)); + $options = array_values(array_map(strval(...), $options)); + $width = $this->getBoxWidth(); + $height = count($options) + count(TerminalText::wrapToWidth($prompt, $width - 4)) + ($title === '' ? 2 : 3); + $this->choice = new PreviewChoiceOwner($title, $prompt, $options, 0, new Rect( + max(0, intdiv($this->screenWidth - $width, 2)), max(0, $this->screenHeight - $height - 1), $width, $height)); $this->log[] = ['kind' => 'choice', 'text' => $prompt, 'speaker' => $title]; } public function update(): void { - if ($this->autoAdvance) { - if ($this->options !== null && $this->choice === null) { - $this->choice = 0; - } - - $this->confirmed = true; + if (! $this->autoAdvance) { + return; + } + if ($this->choice !== null && $this->result === null) { + $this->result = 0; } + while ($this->dialogue?->turnPage()) { + } + $this->confirmed = true; } public function render(): void { - if ($this->text === null && $this->options === null) { + if ($this->isComplete()) { return; } - - $width = $this->camera->screen->getWidth(); - $height = $this->camera->screen->getHeight(); - $boxWidth = max(12, min($width, 60)); - $inner = $boxWidth - 4; - $rows = []; - $border = '+' . str_repeat('-', $boxWidth - 2) . '+'; - $rows[] = $border; - - if ($this->text !== null) { - if ($this->speaker !== '') { - $rows[] = '| ' . TerminalText::padRight($this->speaker . ':', $inner) . ' |'; - } - - foreach (TerminalText::wrapToWidth($this->text, $inner) as $line) { - $rows[] = '| ' . TerminalText::padRight($line, $inner) . ' |'; - } - - $rows[] = '| ' . TerminalText::padRight('', $inner - 1) . '▼ |'; - } else { - if ($this->title !== '') { - $rows[] = '| ' . TerminalText::padRight($this->title . ':', $inner) . ' |'; - } - - foreach (TerminalText::wrapToWidth($this->prompt, $inner) as $line) { - $rows[] = '| ' . TerminalText::padRight($line, $inner) . ' |'; - } - - foreach ($this->options ?? [] as $index => $option) { - $marker = $index === $this->highlighted ? '>' : ' '; - $rows[] = '| ' . TerminalText::padRight($marker . ' ' . $option, $inner) . ' |'; - } - } - - $rows[] = $border; - $x = max(0, intdiv($width - $boxWidth, 2)); - $y = max(0, $height - count($rows) - 1); - $this->camera->draw($rows, $x, $y); + $owner = $this->dialogue ?? $this->choice; + PresentationLayerPolicy::ui($owner, fn() => $this->drawBox()); } public function isComplete(): bool { - if ($this->text === null && $this->options === null) { - return true; - } - - return $this->confirmed; + return ($this->dialogue === null && $this->choice === null) || $this->confirmed; } public function choiceResult(): ?int { - return $this->choice; + return $this->result; } public function reset(): void { - $this->text = null; - $this->speaker = ''; - $this->options = null; - $this->prompt = ''; - $this->title = ''; - $this->highlighted = 0; + $this->dialogue = null; $this->choice = null; + $this->result = null; $this->confirmed = false; } + /** + * What is on screen waiting for the author, as the Engine's presentation + * owners, the one taking input first. + * + * @return list + */ + public function getActivePresentations(): array + { + return $this->isWaiting() ? array_values(array_filter([$this->choice, $this->dialogue])) : []; + } + /** * Whether something is on screen waiting for the author. */ public function isWaiting(): bool { - return ($this->text !== null || $this->options !== null) && ! $this->confirmed; + return ($this->dialogue !== null || $this->choice !== null) && ! $this->confirmed; + } + + /** + * What the author answers now: `choice` (move the highlight, then + * confirm), `dialogue` (confirm), or null when nothing waits. + */ + public function getWaitKind(): ?string + { + return match (true) { + ! $this->isWaiting() => null, + $this->choice !== null => 'choice', + default => 'dialogue', + }; } /** @@ -150,15 +170,20 @@ public function describeWait(): ?string return null; } - if ($this->options !== null) { - return sprintf('Choice (%d options): ↑/↓ select, Enter confirm', count($this->options)); + if ($this->choice !== null) { + return sprintf('Choice (%d options): ↑/↓ select, Enter confirm', count($this->choice->options)); } - return 'Dialogue waiting: Enter to continue'; + $pages = count($this->dialogue->pagination->pages); + + return $pages > 1 + ? sprintf('Dialogue waiting (page %d of %d): Enter to continue', $this->dialogue->getPage() + 1, $pages) + : 'Dialogue waiting: Enter to continue'; } /** - * Confirms the text, or the highlighted option. + * Turns to the dialogue's next page, or confirms its last page or the + * highlighted option. */ public function confirm(): bool { @@ -166,8 +191,12 @@ public function confirm(): bool return false; } - if ($this->options !== null) { - $this->choice = $this->highlighted; + if ($this->dialogue?->turnPage()) { + return true; + } + + if ($this->choice !== null) { + $this->result = $this->choice->highlighted; } $this->confirmed = true; @@ -177,13 +206,59 @@ public function confirm(): bool public function moveHighlight(int $delta): bool { - if ($this->options === null || $this->confirmed) { + if ($this->choice === null || $this->confirmed) { return false; } - $count = count($this->options); - $this->highlighted = (($this->highlighted + $delta) % $count + $count) % $count; + $count = count($this->choice->options); + $this->choice->highlighted = (($this->choice->highlighted + $delta) % $count + $count) % $count; return true; } + + /** The Terminal window: the dialogue's page or the choice, bordered, where the game places it. */ + private function drawBox(): void + { + $boxWidth = $this->getBoxWidth(); + $inner = $boxWidth - 4; + $border = '+' . str_repeat('-', $boxWidth - 2) . '+'; + $rows = [$border]; + + if ($this->dialogue !== null) { + $speaker = $this->dialogue->pagination->speaker; + if ($speaker !== '') { + $rows[] = '| ' . TerminalText::padRight($speaker . ':', $inner) . ' |'; + } + foreach (explode("\n", $this->dialogue->getPageText()) as $line) { + foreach (TerminalText::wrapToWidth($line, $inner) as $wrapped) { + $rows[] = '| ' . TerminalText::padRight($wrapped, $inner) . ' |'; + } + } + $rows[] = '| ' . TerminalText::padRight('', $inner - 1) . '▼ |'; + } elseif ($this->choice !== null) { + if ($this->choice->title !== '') { + $rows[] = '| ' . TerminalText::padRight($this->choice->title . ':', $inner) . ' |'; + } + foreach (TerminalText::wrapToWidth($this->choice->prompt, $inner) as $line) { + $rows[] = '| ' . TerminalText::padRight($line, $inner) . ' |'; + } + foreach ($this->choice->options as $index => $option) { + $marker = $index === $this->choice->highlighted ? '>' : ' '; + $rows[] = '| ' . TerminalText::padRight($marker . ' ' . $option, $inner) . ' |'; + } + } + + $rows[] = $border; + // A screen window, as the game's text box is: on the text screen, not clipped to the field camera. + $x = max(0, intdiv($this->screenWidth - $boxWidth, 2)); + $y = max(0, $this->screenHeight - count($rows) - 1); + foreach (array_slice($rows, 0, $this->screenHeight) as $index => $row) { + Console::write(TerminalText::truncateToWidth($row, $this->screenWidth - $x), $x, min($y + $index, $this->screenHeight - 1)); + } + } + + private function getBoxWidth(): int + { + return $this->dialogue?->pagination->windowWidth ?? max(12, min($this->screenWidth, 60)); + } } diff --git a/src/Cutscenes/Preview/PreviewRendererRelay.php b/src/Cutscenes/Preview/PreviewRendererRelay.php new file mode 100644 index 00000000..4de10ad2 --- /dev/null +++ b/src/Cutscenes/Preview/PreviewRendererRelay.php @@ -0,0 +1,145 @@ + Sent and not yet collected by the window, in order. */ + private array $outgoing = []; + private int $outgoingBytes = 0; + /** @var list Answered by the window and not yet polled by the client. */ + private array $incoming = []; + private ?RendererSessionConfig $session = null; + + public function start(RendererSessionConfig $session): void + { + $this->session = $session; + $this->outgoing = $this->incoming = []; + $this->outgoingBytes = 0; + $this->state = RendererTransportState::RUNNING; + } + + public function isRunning(): bool + { + return $this->state === RendererTransportState::RUNNING; + } + + public function send(RendererMessage $message): void + { + if (! $this->isRunning()) { + throw new RendererTransportException('The preview window is not attached.'); + } + $this->outgoing[] = $message; + $this->outgoingBytes += strlen($message->encode()); + } + + public function trySend(RendererMessage $message): bool + { + if (! $this->isRunning()) { + throw new RendererTransportException('The preview window is not attached.'); + } + if ($this->outgoingBytes >= self::MAX_PENDING_BYTES) { + return false; + } + $this->send($message); + + return true; + } + + public function getPendingWriteBytes(): int + { + return $this->outgoingBytes; + } + + public function pollEvents(float $waitSeconds = 0.0): array + { + $events = $this->incoming; + $this->incoming = []; + + return $events; + } + + public function shutdown(): ?int + { + $this->state = RendererTransportState::STOPPED; + $this->outgoing = []; + $this->outgoingBytes = 0; + + return 0; + } + + public function getState(): RendererTransportState + { + return $this->state; + } + + public function getExitCode(): ?int + { + return $this->state === RendererTransportState::STOPPED ? 0 : null; + } + + public function getDiagnostics(): string + { + return sprintf('Preview window relay: %d message(s), %d byte(s) waiting; %d event(s) unread.', + count($this->outgoing), $this->outgoingBytes, count($this->incoming)); + } + + /** + * The window's answers, as renderer event lines, for the client to poll. + * + * @param list $lines + * @throws \Ichiloto\Engine\Rendering\Transport\Exceptions\RendererProtocolException When a line is not a renderer event. + */ + public function receive(array $lines): void + { + foreach ($lines as $line) { + $this->incoming[] = RendererEvent::fromJson($line); + } + } + + /** + * What has been sent since the window last collected, in order: each + * message's type and payload, never its envelope. + * + * @return list}> + */ + public function collect(): array + { + $messages = array_map(static fn(RendererMessage $message): array => ['type' => $message->type->value, 'payload' => $message->payload], + $this->outgoing); + $this->outgoing = []; + $this->outgoingBytes = 0; + + return $messages; + } + + /** The session the client started, with the capabilities it requires and asks for. */ + public function getSession(): ?RendererSessionConfig + { + return $this->session; + } +} diff --git a/src/Cutscenes/Preview/PreviewSceneHost.php b/src/Cutscenes/Preview/PreviewSceneHost.php new file mode 100644 index 00000000..025cee42 --- /dev/null +++ b/src/Cutscenes/Preview/PreviewSceneHost.php @@ -0,0 +1,138 @@ +relay = new PreviewRendererRelay(); + $this->client = new RendererClient($this->relay); + $this->client->start(new RendererSessionConfig('Cinematic preview', $assetRoot, $grid, RendererProtocolVersion::V2)); + $this->presentation = new RendererPresentation($this->client, $grid); + $this->composer = new SceneFrameComposer($assetRoot); + } + + /** + * One exchange with the window: takes its answers (READY, frame + * acknowledgements and rejections, as renderer event lines), presents the + * scene as it is now, and returns what the window should apply, in order. + * + * @param list $events + * @return list}> + * @throws RendererTransportException When the window reports an error. + */ + public function exchange(array $events): array + { + $this->relay->receive($events); + $this->client->pump(); + $invalidate = false; + $expectedGeneration = null; + foreach ($this->client->drainEvents() as $event) { + if ($event->type === RendererEventType::READY) { + $this->ready = true; + } elseif ($event->type === RendererEventType::FRAME_REJECTED) { + $invalidate = true; + $expectedGeneration = max($expectedGeneration ?? 0, $event->expectedGeneration ?? 0); + } elseif ($event->type === RendererEventType::FRAME_ACK) { + $this->presentation->acknowledge($event->generation, $event->presented); + } elseif ($event->type === RendererEventType::ERROR) { + throw new RendererTransportException('Preview window error: ' . $event->message, $this->relay->getDiagnostics()); + } + } + foreach ($this->client->drainFrameAcknowledgements() as $acknowledgement) { + $this->presentation->acknowledge($acknowledgement->generation, $acknowledgement->presented); + } + if ($invalidate) { + $this->presentation->invalidate(expectedGeneration: $expectedGeneration); + } + if ($this->ready) { + $this->attachContext(); + $composition = $this->composer->composeFrame($this->scene, $this->grid, $this->client->supports(...), + fn(array $excluded, array $worldLayers) => Console::capturePresentation($this->grid->columns, $this->grid->rows, + $this->renderTerminal, $excluded, $worldLayers, + $this->scene instanceof RetainedWorldProviderInterface && $this->scene->getPresentationWorld() !== null)); + $this->presentation->presentFrame($composition->prepareFrame($this->presentation), $composition->screenOverlay); + } + + return $this->relay->collect(); + } + + /** Detaches the scene's presentation context and the dialogue's graphical paging, and closes the relay. */ + public function dispose(): void + { + if ($this->scene->getPresentationContext() !== null) { + $this->scene->setPresentationContext(null); + if ($this->scene instanceof PreviewGameScene) { + $this->scene->installFieldEffects(EffectPresentation::TERMINAL); + } + } + $this->dialogue->pageLayout = null; + $this->client->shutdown(); + } + + /** Selects the graphical field for the composer, with the window's capabilities and the preview's playhead. */ + private function attachContext(): void + { + if ($this->scene->getPresentationContext() !== null) { + return; + } + $this->scene->setPresentationContext(new ScenePresentationContext($this->grid, $this->client->supports(...), + collectPresentations: fn(): array => $this->dialogue->getActivePresentations(), readTime: $this->readTime, + assetRoot: $this->assetRoot, + // Text such as a sign's label is laid out by this window, through this preview's own client: never the game's. + textPreparation: $this->client)); + // The graphical field draws its effects as sprites, as the game's does in a graphical window. + if ($this->scene instanceof PreviewGameScene) { + $this->scene->installFieldEffects(EffectPresentation::GRAPHICAL); + } + $width = $this->grid->columns * $this->grid->cellWidth; + $this->dialogue->pageLayout = fn(string $speaker, $context, string $help) => $this->composer->getDialoguePageLayout($speaker, $context, $help, $width); + } +} diff --git a/src/Cutscenes/Preview/SummonBattlePreview.php b/src/Cutscenes/Preview/SummonBattlePreview.php new file mode 100644 index 00000000..1d21133f --- /dev/null +++ b/src/Cutscenes/Preview/SummonBattlePreview.php @@ -0,0 +1,127 @@ + $this->preview->totalFrames; } + + /** + * @param string $caster The member who casts it. + * @param list $targets Whom it is cast at. + * @param array $phases The command's phases, in command frames. + */ + private function __construct( + private readonly BattleCommandPreview $preview, + private readonly BattleCommandTimeline $plan, + public readonly string $caster, + public readonly array $targets, + public readonly array $phases, + ) { + } + + /** + * A graphical preview plays the graphical compile, with the terminal one + * as its paired lane; a terminal preview plays the terminal compile alone, + * at its own cadence, and reads no graphical image. + * + * @param SummonCompiledCutscene|null $graphical The summon compiled for the graphical battle, which a graphical preview requires. + * @param SummonCompiledCutscene $terminal The same summon compiled for the terminal arena. + * @param BattlePresentationCatalog|null $catalog The battle presentation, which a graphical preview requires. + * @throws InvalidArgumentException When no member may cast it, its action is unknown or it has no one to target. + */ + public static function create( + SummonCutsceneDefinition $definition, + ?SummonCompiledCutscene $graphical, + SummonCompiledCutscene $terminal, + BattleConfig $battle, + SkillCatalog $skills, + BattlePacing $pacing, + ?BattlePresentationCatalog $catalog, + string $assetRoot, + EffectPresentation $presentation, + ): self { + $battlers = $battle->partyRoster->battlers; + $policy = $definition->wielders; + $caster = array_find($battlers, static fn($battler): bool => $battler instanceof Character + && ($policy === null || ($policy->isValid() && $policy->allowsCharacter($battler)))) + ?? throw new InvalidArgumentException(sprintf('No one in the battle test party may call %s; add a member who may.', $definition->name)); + $skill = $skills->findSkill($definition->linkedActionId) + ?? throw new InvalidArgumentException(sprintf('%s calls the action %s, which no skill defines.', $definition->name, $definition->linkedActionId)); + $action = new SkillBattleAction($skill); + $opponents = $battle->troop->members->toArray(); + $targets = BattleTargetPolicy::resolveTargets($action->targetScope, $caster, $battlers, $opponents); + if ($targets === []) { + throw new InvalidArgumentException(sprintf('%s has no one to target in the battle test.', $definition->name)); + } + $timings = $pacing->getTurnTimings($action); + $plan = $presentation === EffectPresentation::TERMINAL + ? new BattleCommandTimeline($timings, target: $terminal) + : new BattleCommandTimeline($timings, target: $graphical ?? throw new InvalidArgumentException('A graphical preview plays the graphical compile.'), terminalTarget: $terminal); + + return new self( + new BattleCommandPreview($battle, $plan, $caster, $targets, BattlePoseRole::SUMMON, + $presentation === EffectPresentation::TERMINAL ? null : $catalog, $assetRoot, $presentation), + $plan, + $caster->name, + array_map(static fn($target): string => $target->name, $targets), + $plan->phases, + ); + } + + /** + * One command frame as the battle draws it: its phase, the graphical + * canvas (none without a graphical arena), the terminal arena's lines, + * the cues at it and those crossed reaching it, and what went wrong + * presenting it. + * + * @return array + * @throws InvalidArgumentException When the frame is outside the command. + */ + public function readFrame(int $frame, bool $reducedMotion): array + { + return $this->preview->getFrameAtIndex($frame, $reducedMotion)->toArray(); + } + + /** + * The command frame that draws one of the summon's own frames, so its + * timeline and its battle share one playhead; a frame before the summon + * plays maps to its first, one after it to its last. + */ + public function findCommandFrame(int $authoredFrame): int + { + $target = $this->phases['target']; + $frame = $this->plan->getCommandFrameForAuthoredFrame('target', max(0, $authoredFrame)); + + return $frame ?? ($authoredFrame <= 0 ? $target['start'] : $target['start'] + $target['length'] - 1); + } +} diff --git a/src/Cutscenes/Preview/SummonPreviewSession.php b/src/Cutscenes/Preview/TimelinePreviewSession.php similarity index 85% rename from src/Cutscenes/Preview/SummonPreviewSession.php rename to src/Cutscenes/Preview/TimelinePreviewSession.php index b44d3100..aec9304e 100644 --- a/src/Cutscenes/Preview/SummonPreviewSession.php +++ b/src/Cutscenes/Preview/TimelinePreviewSession.php @@ -4,22 +4,24 @@ namespace Ichiloto\Editor\Cutscenes\Preview; -use Ichiloto\Engine\Cutscenes\Summons\SummonCompiledCutscene; -use Ichiloto\Engine\Cutscenes\Summons\SummonPlaybackSession; +use Ichiloto\Engine\Animations\Timelines\CompiledEffectTimeline; +use Ichiloto\Engine\Animations\Timelines\EffectPlaybackSession; use Ichiloto\Engine\IO\Console\TerminalText; /** - * A summon timeline played by the Engine's own `SummonPlaybackSession`. + * A compiled timeline, a summon's or an effect's, played by the Engine's + * shared `EffectPlaybackSession`. * * The Engine compiles the timeline and owns the playhead, the frame clock, * the active segments and the cue schedule; the editor adds a clock it can - * pause or step, a log of the cues the playhead crossed, and a picture of - * the frame drawn with the battle field's rules (position, content lines, - * asset placeholder, visibility) into a buffer the pane can show. + * pause or step, a log of the cues the playhead crossed, a ruler, and for a + * summon a picture of the frame drawn with the battle field's rules + * (position, content lines, asset placeholder, visibility). An effect's + * frame is anchored to its subjects; `EffectPreviewStage` draws it. */ -final class SummonPreviewSession +final class TimelinePreviewSession { - private SummonPlaybackSession $session; + private EffectPlaybackSession $session; private bool $playing = false; private float $elapsed = 0.0; private bool $loop = false; @@ -27,9 +29,17 @@ final class SummonPreviewSession /** @var array */ private array $cueLog = []; - public function __construct(public readonly SummonCompiledCutscene $cutscene) - { - $this->session = new SummonPlaybackSession($cutscene, loop: false); + /** + * @param bool|null $loop Whether playback wraps at the last frame; null plays as the timeline's own playback says. + * @param float|null $phaseDurationSeconds For an effect paced by the battle, the seconds its battle phase lasts; its frames spread over them. + */ + public function __construct( + public readonly CompiledEffectTimeline $timeline, + ?bool $loop = false, + public readonly ?float $phaseDurationSeconds = null, + ) { + $this->session = new EffectPlaybackSession($timeline, loop: $loop, phaseDurationSeconds: $phaseDurationSeconds); + $this->loop = $this->session->isLooping; $this->session->pause(); } @@ -87,7 +97,7 @@ private function rebuildSession(): void { $frame = $this->session->currentFrame; $wasPlaying = $this->isPlaying(); - $this->session = new SummonPlaybackSession($this->cutscene, loop: $this->loop, speed: $this->speed); + $this->session = new EffectPlaybackSession($this->timeline, loop: $this->loop, speed: $this->speed, phaseDurationSeconds: $this->phaseDurationSeconds); $this->session->seek($frame); if ($wasPlaying) { @@ -247,7 +257,7 @@ public function cueLog(): array */ public function cuesAt(?int $frame = null): array { - return array_values(array_filter($this->session->cuesAt($frame), is_array(...))); + return $this->session->getCuesAt($frame); } /** @@ -257,7 +267,7 @@ public function cuesAt(?int $frame = null): array */ public function activeSegments(?int $frame = null): array { - return array_values(array_filter($this->session->activeSegments($frame), is_array(...))); + return $this->session->getActiveSegments($frame); } /** @@ -269,7 +279,7 @@ public function boundaries(): array { $frames = []; - foreach ($this->cutscene->playbackSegments as $segment) { + foreach ($this->timeline->playbackSegments as $segment) { if (! is_array($segment)) { continue; } @@ -278,7 +288,7 @@ public function boundaries(): array $frames[] = intval($segment['endFrame'] ?? 0) + 1; } - foreach ($this->cutscene->cueSchedule as $cue) { + foreach ($this->timeline->cueSchedule as $cue) { if (is_array($cue)) { $frames[] = intval($cue['frame'] ?? 0); } @@ -291,7 +301,7 @@ public function boundaries(): array } /** - * The frame as the battle field would compose it, as plain rows. + * A summon's frame as the battle field would compose it, as plain rows. * * @return string[] */ @@ -382,7 +392,7 @@ public function rulerLines(int $width): array /** @var array $tracks */ $tracks = []; - foreach ($this->cutscene->playbackSegments as $segment) { + foreach ($this->timeline->playbackSegments as $segment) { if (! is_array($segment)) { continue; } @@ -406,7 +416,7 @@ public function rulerLines(int $width): array $cues = array_fill(0, $barWidth, ' '); $hasCues = false; - foreach ($this->cutscene->cueSchedule as $cue) { + foreach ($this->timeline->cueSchedule as $cue) { if (is_array($cue)) { $column = min($barWidth - 1, $scale(intval($cue['frame'] ?? 0))); $cues[$column] = '◆'; diff --git a/src/Cutscenes/Source/ArraySourceWriter.php b/src/Cutscenes/Source/ArraySourceWriter.php index fcaad440..7376c221 100644 --- a/src/Cutscenes/Source/ArraySourceWriter.php +++ b/src/Cutscenes/Source/ArraySourceWriter.php @@ -12,24 +12,33 @@ * * The values an author edits are arrays; the file is source. This walks both * together: a scalar that changed is rewritten where its literal sits, a - * keyed entry that appeared is added on lines of its own, one that vanished + * keyed entry that appeared is added on lines of its own, ahead of the entry + * that follows it in the new value so the file reads in that order (at the + * end when nothing the file holds follows it), one that vanished * is cut with its heading, and a list is aligned so that an entry moved * keeps its bytes and an entry edited keeps everything but the changed * field. A value written as a variable is retargeted rather than expanded: - * a nowdoc referenced once is edited between its markers, and one shared by - * several entries is left alone while the edited entry gets a new, safely - * named variable of its own. Text with line breaks is always written as a - * nowdoc, so ASCII art stays readable and exact -- trailing spaces, - * backslashes, Unicode and all. + * a nowdoc referenced once is edited between its markers, and a literal + * named once is edited where it is assigned, as the data it is. One shared + * by several entries is left alone for them while the edited entry gets a + * value of its own -- a new, safely named variable for text -- when the + * shared value is plain data a copy holds in full. Text with line breaks + * is always written as a nowdoc, so ASCII art stays readable and exact -- + * trailing spaces, backslashes, Unicode and all. * * What it cannot express -- a change under an expression, under a key it - * could not read, a list turned into a map -- it refuses by path, so nothing - * is written that a reader would not have written by hand. + * could not read, a list turned into a map, under a variable the file builds + * with its own code -- it refuses by path, so nothing is written that a + * reader would not have written by hand. A variable built in code is never + * replaced by a literal of what the code produced. * * @package Ichiloto\Editor\Cutscenes\Source */ final class ArraySourceWriter { + /** The longest a value written on one line may be; a longer one is written an entry per line. */ + private const int INLINE_WIDTH = 100; + /** * Entries queued for the end of an array, per parent node: several * appends into one array become one composable edit at flush time. @@ -38,6 +47,31 @@ final class ArraySourceWriter */ private array $appends = []; + /** + * Keyed entries queued ahead of an entry the file already holds, per + * entry: several inserted before one entry become one edit, in order. + * + * @var array}> + */ + private array $inserts = []; + + /** + * Entries removed from arrays whose entries share their lines, per + * parent node, cut together at flush time so the separators that joined + * them go with them. + * + * @var array}> + */ + private array $sharedLineRemovals = []; + + /** + * Arrays whose last surviving entry lost its comma with the entries + * after it, by node. + * + * @var array + */ + private array $separatorCuts = []; + /** * Entry indexes this rewrite removes (or moves away), per parent node, * so an append knows which entry will actually precede it. @@ -49,9 +83,15 @@ final class ArraySourceWriter /** @var array */ private array $edits = []; + /** @var list The variables written as data the diff is inside, innermost last. */ + private array $hosts = []; + /** @var array Variable names claimed by this plan. */ private array $claimedVariables = []; + /** @var array|null Class imports in the returned array's scope. */ + private ?array $classImports = null; + private function __construct(private readonly PhpArraySourceDocument $document) { } @@ -67,8 +107,26 @@ private function __construct(private readonly PhpArraySourceDocument $document) * instance when nothing differs. * @throws SourcePreservationRefusal When a change cannot be expressed in the source. */ - public static function rewrite(PhpArraySourceDocument $document, array $old, array $new): PhpArraySourceDocument + public static function rewrite(PhpArraySourceDocument $document, array $old, array $new, array $keyRenames = []): PhpArraySourceDocument { + foreach ($keyRenames as $rename) { + $path = $rename['path']; + $oldKey = array_pop($path); + $parent = &$old; + foreach ($path as $step) { + $parent = &$parent[$step]; + } + if (! is_array($parent) || ! array_key_exists($oldKey, $parent) || array_key_exists($rename['key'], $parent)) { + throw new SourcePreservationRefusal('The key rename does not match the loaded data.'); + } + $document = $document->withEdits([$document->renameKeyEdit($rename['path'], $rename['key'])]); + $renamed = []; + foreach ($parent as $key => $value) { + $renamed[$key === $oldKey ? $rename['key'] : $key] = $value; + } + $parent = $renamed; + unset($parent); + } if ($old === $new) { return $document; } @@ -89,16 +147,49 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo return; } + if ($node->kind === SourceNode::VARIABLE) { + $this->diffVariable($node, $old, $new, $path); + + return; + } + + if (is_array($new) && $this->outgrowsItsLine($node, $new, $path)) { + // Plain data written on one line that would grow past the width is written an entry per line instead. + $this->edits[] = $this->document->replaceValueEdit($path, $this->literalFor($new, $path, true, true)); + + return; + } + if (is_array($new)) { $this->diffArray($node, is_array($old) ? $old : null, $new, $path); return; } - // A scalar or null now sits here. + if ($node->kind === SourceNode::EXPRESSION && ($edits = $this->findEnumCaseEdits($node, $old, $new)) !== null) { + array_push($this->edits, ...$edits); + + return; + } + + // An enum case's value, written as `Enum::CASE->value`, stays written + // that way: the new value is the matching case of the same enum. + if ($node->kind === SourceNode::EXPRESSION && ($literal = $this->findEnumValueExpression($node, $new)) !== null) { + $this->edits[] = $this->document->replaceValueEdit($path, $literal); + + return; + } + + // A scalar or null now sits here. An array written as plain data (literals only, no comments) holds nothing + // authored beyond its values, so the new value takes its place; anything more stays refused below. match ($node->kind) { SourceNode::SCALAR => $this->edits[] = $this->replacementFor($node, $path, $new), - SourceNode::VARIABLE => $this->retargetVariable($node, $path, $new), + SourceNode::ARRAY => $this->isPlainData($node) + ? $this->edits[] = $this->replacementFor($node, $path, $new) + : throw new SourcePreservationRefusal(sprintf( + 'The value at %s is an array in the file and cannot be replaced by a single value without rewriting it.', + PhpArraySourceDocument::describePath($path), + )), SourceNode::EXPRESSION => throw new SourcePreservationRefusal(sprintf( 'The value at %s is written as an expression the editor cannot rewrite; change it in the file, or write it as plain data.', PhpArraySourceDocument::describePath($path), @@ -110,6 +201,59 @@ private function diff(SourceNode $node, mixed $old, mixed $new, array $path): vo }; } + /** + * Whether plain data the file writes on one line would run past the + * width written that way with its new value. + * + * @param array $new + * @param array $path + */ + private function outgrowsItsLine(SourceNode $node, array $new, array $path): bool + { + if ($node->kind !== SourceNode::ARRAY || $path === [] || ! $this->isPlainData($node) + || str_contains(substr($this->document->source, $node->start, $node->end - $node->start), "\n") + || ! $this->fitsOnOneLine($new)) { + return false; + } + + $line = self::renderOneLine($new); + + // Text with line breaks is written as a variable, which this leaves to the entry-by-entry diff. + return strlen($line) > self::INLINE_WIDTH && ! str_contains($line, "\n"); + } + + /** + * A plain value written on one line, as an entry that fits there is. + * + * @param array $value + */ + private static function renderOneLine(array $value): string + { + $isList = array_is_list($value); + $parts = []; + foreach ($value as $key => $item) { + $literal = is_array($item) ? self::renderOneLine($item) : PhpValueExporter::export($item); + $parts[] = $isList ? $literal : var_export($key, true) . ' => ' . $literal; + } + + return '[' . implode(', ', $parts) . ']'; + } + + /** Whether an array's source is only literal keys and values, with no comment, expression or variable in it. */ + private function isPlainData(SourceNode $node): bool + { + foreach ($node->entries as $entry) { + $value = $entry->value; + if ($value->kind === SourceNode::ARRAY ? !$this->isPlainData($value) : $value->kind !== SourceNode::SCALAR) { + return false; + } + } + foreach (\PhpToken::tokenize('document->source, $node->start, $node->end - $node->start)) as $token) { + if ($token->is([T_COMMENT, T_DOC_COMMENT])) { return false; } + } + return ! $node->hasOpaqueKey; + } + /** * @param array|null $old * @param array $new @@ -125,9 +269,7 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat )); } - // A scalar or a variable holding what is now an array: the - // reference is replaced by the literal, and any variable stays - // for whatever else names it. + // A scalar holding what is now an array: the literal takes its place. $this->edits[] = $this->replacementFor($node, $path, $new); return; @@ -135,7 +277,7 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat if ($node->hasOpaqueKey) { throw new SourcePreservationRefusal(sprintf( - 'The array at %s has a key the editor cannot read, so it will not rewrite the array.', + 'The array at %s has a key or spread the editor cannot read, so it will not rewrite the array.', PhpArraySourceDocument::describePath($path), )); } @@ -162,8 +304,12 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat return; } - // Keyed: recurse into keys both have, add the new ones, cut the gone. - foreach ($new as $key => $value) { + // Keyed: recurse into keys both have, add the new ones where the new + // value orders them, cut the gone. + $keys = array_keys($new); + + foreach ($keys as $position => $key) { + $value = $new[$key]; $entry = $node->entryFor($key); if ($entry !== null && array_key_exists($key, $old)) { @@ -172,13 +318,23 @@ private function diffArray(SourceNode $node, ?array $old, array $new, array $pat continue; } - $this->queueAppend($node, $key, $this->literalFor($value, [...$path, $key])); + // An array whose entries share their lines takes a new entry the same way when it fits. + $literal = $this->literalFor($value, [...$path, $key], $this->sharesEntryLines($node)); + $anchor = $this->findFollowingEntry($node, $old, array_slice($keys, $position + 1)); + + if ($anchor === null) { + $this->queueAppend($node, $key, $literal); + + continue; + } + + $this->inserts[spl_object_id($anchor)] ??= ['node' => $node, 'anchor' => $anchor, 'entries' => []]; + $this->inserts[spl_object_id($anchor)]['entries'][] = ['key' => $key, 'literal' => $literal]; } foreach ($node->entries as $index => $entry) { if ($entry->key !== null && ! array_key_exists($entry->key, $new)) { - $this->edits[] = $this->document->removeEntryEdit([...$path, $entry->key]); - $this->removedEntries[spl_object_id($node)][$index] = true; + $this->queueRemoval($node, $index, [...$path, $entry->key]); } } } @@ -321,6 +477,12 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) $previousNew = $anchorNew; } + $this->emitListMatches($node, $old, $new, $path, $matchedOld, $matchedNew); + } + + /** Emit aligned entries through the same source/comment ownership rules for every list. */ + private function emitListMatches(SourceNode $node, array $old, array $new, array $path, array $matchedOld, array $matchedNew): void + { // 6. Which matched entries stay in place: the longest run of them // whose old order agrees with their new order. The rest move. ksort($matchedNew); @@ -349,27 +511,23 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) foreach ($old as $i => $entry) { if (! isset($matchedOld[$i])) { - $this->edits[] = $this->document->removeEntryEdit([...$path, $i]); - $this->removedEntries[spl_object_id($node)][$i] = true; + $this->queueRemoval($node, $i, [...$path, $i]); } } + $anchor = null; + $anchors = []; + for ($j = count($new) - 1; $j >= 0; $j--) { + $anchors[$j] = $anchor; + if (isset($kept[$j])) { $anchor = $kept[$j]; } + } + $inline = $this->prefersInline($node); foreach ($new as $j => $item) { if (isset($kept[$j])) { continue; } - $anchor = null; - - for ($next = $j + 1; $next < $newCount; $next++) { - if (isset($kept[$next])) { - $anchor = $kept[$next]; - - break; - } - } - - $position = $anchor ?? count($node->entries); + $position = $anchors[$j] ?? count($node->entries); if (isset($moved[$j])) { $this->emitMove($node, $moved[$j], $old[$moved[$j]], $item, $path, $position); @@ -378,7 +536,7 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) } if ($position >= count($node->entries)) { - $this->queueAppend($node, null, $this->literalFor($item, [...$path, $j], self::prefersInline($node))); + $this->queueAppend($node, null, $this->literalFor($item, [...$path, $j], $inline)); continue; } @@ -387,11 +545,54 @@ private function diffList(SourceNode $node, array $old, array $new, array $path) $path, $position, null, - $this->literalFor($item, [...$path, $j], self::prefersInline($node)), + $this->literalFor($item, [...$path, $j], $inline), ); } } + /** + * Returns the entry a new key goes ahead of: the first of the keys after + * it in the new value that the file holds and keeps. Null puts the new + * key at the end. + * + * @param array $old + * @param list $following + */ + private function findFollowingEntry(SourceNode $node, array $old, array $following): ?SourceEntry + { + foreach ($following as $key) { + $entry = $node->entryFor($key); + + if ($entry !== null && array_key_exists($key, $old)) { + return $entry; + } + } + + return null; + } + + /** + * Removes an entry: at once, with its lines and heading, from an array + * written a line per entry; at flush time, with the separators that + * joined it, from one whose entries share their lines. + * + * @param array $path The entry's path. + */ + private function queueRemoval(SourceNode $node, int $index, array $path): void + { + $id = spl_object_id($node); + $this->removedEntries[$id][$index] = true; + + if (! $this->document->sharesLines($node)) { + $this->edits[] = $this->document->removeEntryEdit($path); + + return; + } + + $this->sharedLineRemovals[$id] ??= ['node' => $node, 'indexes' => []]; + $this->sharedLineRemovals[$id]['indexes'][] = $index; + } + /** * Queues an entry for the end of an array. Appends flush together, one * composable edit per parent, so they never collide with each other or @@ -406,9 +607,9 @@ private function queueAppend(SourceNode $node, int|string|null $key, string $lit // same way, joined after the last entry. $last = $node->entries === [] ? null : $node->entries[count($node->entries) - 1]; - if ($last !== null && $this->document->lineIndentBefore($last->start) === null) { - $keyText = $key === null ? '' : var_export($key, true) . ' => '; - $this->appends[$id]['inline'][] = $keyText . $literal; + if ($last !== null && $this->sharesEntryLines($node)) { + // A value too long for the line continues under the line it joins. + $this->appends[$id]['inline'][] = $this->document->renderInlineEntry($key, $literal, $last->start); return; } @@ -416,11 +617,38 @@ private function queueAppend(SourceNode $node, int|string|null $key, string $lit $this->appends[$id]['lines'][] = $this->document->renderEntryLine($node, $key, $literal); } + /** Whether an array's entries share their lines, as a one-line objective's keys do. */ + private function sharesEntryLines(SourceNode $node): bool + { + $last = $node->entries === [] ? null : $node->entries[count($node->entries) - 1]; + + return $last !== null && $this->document->lineIndentBefore($last->start) === null; + } + /** * Emits one edit per parent with queued appends. */ private function flushAppends(): void { + foreach ($this->sharedLineRemovals as $id => $removal) { + $indexes = $removal['indexes']; + sort($indexes); + $planned = $this->document->removeSharedLineEntriesEdits($removal['node'], $indexes); + array_push($this->edits, ...$planned['edits']); + + if ($planned['separatorCut']) { + $this->separatorCuts[$id] = true; + } + } + + $this->sharedLineRemovals = []; + + foreach ($this->inserts as $insert) { + $this->edits[] = $this->document->insertEntriesBeforeEdit($insert['node'], $insert['anchor'], $insert['entries']); + } + + $this->inserts = []; + foreach ($this->appends as $id => $append) { $node = $append['node']; $removed = $this->removedEntries[$id] ?? []; @@ -434,17 +662,8 @@ private function flushAppends(): void if ($append['inline'] !== []) { // After the last entry this rewrite keeps; right at the body - // start when it keeps none. The comma belongs to whichever - // entry the appended items now follow. - if ($lastSurviving !== null) { - $anchor = $lastSurviving->separatorEnd; - $prefix = $lastSurviving->separatorEnd === $lastSurviving->end ? ', ' : ' '; - } else { - $anchor = (int) $node->bodyStart; - $prefix = ''; - } - - $this->edits[] = [$anchor, $anchor, $prefix . implode(', ', $append['inline'])]; + // start when it keeps none. + $this->edits[] = $this->document->planInlineAppend($node, $append['inline'], $lastSurviving, isset($this->separatorCuts[$id])); } if ($append['lines'] === []) { @@ -462,16 +681,18 @@ private function flushAppends(): void /** * Returns whether a list writes each entry on one line -- a keyframe * list in the game's own timelines does -- so a new entry reads like its - * neighbours. + * neighbours. A list whose entries span several lines each (a states + * file) gets new entries written the same way. */ - private static function prefersInline(SourceNode $list): bool + private function prefersInline(SourceNode $list): bool { if ($list->entries === []) { return false; } foreach ($list->entries as $entry) { - if ($entry->value->kind !== SourceNode::ARRAY) { + if ($entry->value->kind !== SourceNode::ARRAY + || str_contains(substr($this->document->source, $entry->value->start, $entry->value->end - $entry->value->start), "\n")) { return false; } } @@ -480,39 +701,57 @@ private static function prefersInline(SourceNode $list): bool } /** - * Plans removing an entry and re-inserting its own bytes -- with any - * patches its new value needs applied inside them -- ahead of another - * position. + * Plans the patches a value that moves needs, against its current place, + * and returns those inside its bytes, last first, for the move to apply + * to the bytes it carries. Everything the diff defers to the end -- + * removals from arrays whose entries share their lines, insertions, + * appends -- is planned now, inside the moving bytes, rather than + * against bytes the move cuts. A variable defined for the value lives in + * the prelude and stays a global edit. * - * @param array $path The list's path. + * @param array $path The value's path. + * @return list */ - private function emitMove(SourceNode $list, int $from, mixed $oldValue, mixed $newValue, array $path, int $position): void + private function planPatchesWithin(SourceNode $value, mixed $oldValue, mixed $newValue, array $path, int $start, int $end): array { - $entry = $list->entries[$from]; - [$start, $end] = $this->document->blockSpan($entry); - - // Patches for the entry, planned against its current place ... + $deferred = [$this->appends, $this->inserts, $this->sharedLineRemovals]; + [$this->appends, $this->inserts, $this->sharedLineRemovals] = [[], [], []]; $before = count($this->edits); - $this->diff($entry->value, $oldValue, $newValue, [...$path, $from]); + $this->diff($value, $oldValue, $newValue, $path); + $this->flushAppends(); + [$this->appends, $this->inserts, $this->sharedLineRemovals] = $deferred; $inner = []; - $outer = []; foreach (array_splice($this->edits, $before) as $edit) { if ($edit[0] >= $start && $edit[1] <= $end) { $inner[] = $edit; } else { - // A variable defined for it lives in the prelude and stays global. - $outer[] = $edit; + $this->edits[] = $edit; } } - $this->edits = [...$this->edits, ...$outer]; + usort($inner, static fn(array $a, array $b): int => [$b[0], $b[1]] <=> [$a[0], $a[1]]); + + return $inner; + } - // ... applied to its own bytes, from the end forward. + /** + * Plans removing an entry and re-inserting its own bytes -- with any + * patches its new value needs applied inside them -- ahead of another + * position. + * + * @param array $path The list's path. + */ + private function emitMove(SourceNode $list, int $from, mixed $oldValue, mixed $newValue, array $path, int $position): void + { + $entry = $list->entries[$from]; + [$start, $end] = $this->document->blockSpan($entry); + + // Patches for the entry, planned against its current place, applied + // to its own bytes from the end forward. $block = substr($this->document->source, $start, $end - $start); - usort($inner, static fn(array $a, array $b): int => [$b[0], $b[1]] <=> [$a[0], $a[1]]); - foreach ($inner as [$editStart, $editEnd, $text]) { + foreach ($this->planPatchesWithin($entry->value, $oldValue, $newValue, [...$path, $from], $start, $end) as [$editStart, $editEnd, $text]) { $block = substr($block, 0, $editStart - $start) . $text . substr($block, $editEnd - $start); } @@ -659,6 +898,139 @@ private static function identityOf(mixed $entry): ?string return $id === '' ? null : $id; } + /** + * Verified enum literals retain spacing and comments. Nullable and cross-enum + * edits are explicit data replacements; record schemas own admissible types. + * Aliases to cases and computed values are not case literals. + * + * @return list|null + */ + private function findEnumCaseEdits(SourceNode $node, mixed $old, mixed $new): ?array + { + if (! $old instanceof \UnitEnum || ($new !== null && ! $new instanceof \UnitEnum)) { + return null; + } + $prefix = 'document->source, $node->start, $node->end - $node->start)), + static fn(\PhpToken $token): bool => ! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + if (count($tokens) !== 3 + || ! $tokens[0]->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED]) + || ! $tokens[1]->is(T_DOUBLE_COLON) || ! $tokens[2]->is(T_STRING) + || $tokens[2]->text !== $old->name) { + return null; + } + $class = $this->resolveClassName($tokens[0]->text); + if ($class === null || strcasecmp($class, $old::class) !== 0) { + return null; + } + $edit = static function (\PhpToken $token, string $replacement) use ($node, $prefix): array { + $start = $node->start + $token->pos - strlen($prefix); + + return [$start, $start + strlen($token->text), $replacement]; + }; + if ($new === null) { + return [$edit($tokens[0], 'null'), $edit($tokens[1], ''), $edit($tokens[2], '')]; + } + $edits = [$edit($tokens[2], $new->name)]; + if ($old::class !== $new::class) { + $edits[] = $edit($tokens[0], '\\' . $new::class); + } + + return $edits; + } + + /** + * The expression for a new value where the file writes an enum case's + * value as `Enum::CASE->value`: the same enum, the case holding the new + * value. Null when the expression is anything else, or no case holds it. + */ + private function findEnumValueExpression(SourceNode $node, mixed $new): ?string + { + $text = trim(substr($this->document->source, $node->start, $node->end - $node->start)); + + if ((! is_string($new) && ! is_int($new)) + || preg_match('/^(\\\\?[A-Za-z_][A-Za-z0-9_\\\\]*)::[A-Za-z_][A-Za-z0-9_]*->value$/', $text, $match) !== 1) { + return null; + } + + $class = $this->resolveClassName($match[1]); + + if ($class === null || ! is_subclass_of($class, \BackedEnum::class)) { + return null; + } + + $case = $class::tryFrom($new); + + return $case === null ? null : $match[1] . '::' . $case->name . '->value'; + } + + /** A class name as the file writes it, resolved through its own imports. */ + private function resolveClassName(string $name): ?string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $first = explode('\\', $name)[0]; + $class = $this->getClassImports()[strtolower($first)] ?? null; + + return $class === null ? null : $class . substr($name, strlen($first)); + } + + /** @return array Simple class imports, never text inside strings or nested uses. */ + private function getClassImports(): array + { + if ($this->classImports !== null) { + return $this->classImports; + } + $imports = []; + $tokens = array_values(array_filter(\PhpToken::tokenize($this->document->source), + static fn(\PhpToken $token): bool => ! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT]))); + $depth = 0; + + foreach ($tokens as $index => $token) { + if ($token->pos >= $this->document->root()->start) { + break; + } + if ($token->is(['(', '[', '{', T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES])) { + $depth++; + } elseif ($token->is([')', ']', '}'])) { + $depth--; + } + if ($depth !== 0) { + continue; + } + if ($token->is(T_NAMESPACE)) { + $imports = []; + continue; + } + if (! $token->is(T_USE)) { + continue; + } + $class = $tokens[$index + 1] ?? null; + if ($class === null || ! $class->is([T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED])) { + continue; + } + $end = $index + 2; + $name = ltrim($class->text, '\\'); + $alias = substr((string) strrchr('\\' . $name, '\\'), 1); + if (($tokens[$end] ?? null)?->is(T_AS)) { + $explicitAlias = $tokens[$end + 1] ?? null; + if ($explicitAlias === null || ! $explicitAlias->is(T_STRING)) { + continue; + } + $alias = $explicitAlias->text; + $end += 2; + } + if (($tokens[$end] ?? null)?->is(';')) { + $imports[strtolower($alias)] = $name; + } + } + + return $this->classImports = $imports; + } + /** * Plans replacing a node with the literal for a value. * @@ -667,16 +1039,71 @@ private static function identityOf(mixed $entry): ?string */ private function replacementFor(SourceNode $node, array $path, mixed $value): array { - return $this->document->replaceValueEdit($path, $this->literalFor($value, $path)); + // An entry sharing its line with its neighbours keeps a value that fits there on that line. + $entry = $this->document->entryAt($path); + $inline = $entry !== null && $this->document->lineIndentBefore($entry->start) === null && $this->fitsOnOneLine($value); + + return $this->document->replaceValueEdit($path, $this->literalFor($value, $path, $inline)); } /** - * Retargets a value written as a variable reference. + * Changes a value written as a variable reference. + * + * A variable the file builds with its own code is refused: replacing the + * reference with a literal of its result would leave the code running + * for nothing. A literal only this entry names is diffed where it is + * assigned. Otherwise the variable is left for whatever else names it + * and this entry is retargeted. + * + * @param array $path + */ + private function diffVariable(SourceNode $node, mixed $old, mixed $new, array $path): void + { + $name = $node->variable ?? ''; + + if ($this->document->isBuiltInCode($node)) { + throw new SourcePreservationRefusal(sprintf( + 'The value at %s is $%s, which the file\'s own code builds or uses, so the editor will not replace it with a copy of its value. Change it in the file, or write it as plain data.', + PhpArraySourceDocument::describePath($path), + $name, + )); + } + + $written = $this->document->resolveNode($node); + + if ($written !== $node) { + $this->hosts[] = $name; + + try { + $this->diff($written, $old, $new, $path); + } finally { + array_pop($this->hosts); + } + + return; + } + + $variable = $this->document->variable($name); + + if ($variable?->value !== null && ! $this->isPlainData($variable->value)) { + throw new SourcePreservationRefusal(sprintf( + 'The value at %s is $%s, which %d values share and which holds comments, variables or expressions a copy of it would lose. Change it in the file.', + PhpArraySourceDocument::describePath($path), + $name, + $this->document->referenceCount($name), + )); + } + + $this->retargetVariable($node, $path, $new); + } + + /** + * Retargets a value written as a variable reference to data. * * A string edited under a nowdoc named nowhere else is edited between * the nowdoc's markers. Anything else leaves the variable alone -- other * entries name it -- and this entry gets a variable of its own for - * text, or the literal itself for a number or flag. + * text, or the literal itself for a number, flag or array. * * @param array $path */ @@ -697,7 +1124,7 @@ private function retargetVariable(SourceNode $node, array $path, mixed $new): vo } $fresh = $this->claimVariable($name); - $this->edits[] = $this->document->defineVariableEdit($fresh, $new); + $this->edits[] = $this->document->defineVariableEdit($fresh, $new, $this->findHost()); $this->edits[] = $this->document->replaceValueEdit($path, '$' . $fresh); return; @@ -711,12 +1138,14 @@ private function retargetVariable(SourceNode $node, array $path, mixed $new): vo * text with a line break so that art stays art. * * @param array $path + * @param bool $inline Whether to write it on one line when it fits: because its neighbours are, or as a child. + * @param bool $child Whether it is a child of a value being written, which keeps to one line only within the width. */ - private function literalFor(mixed $value, array $path, bool $inline = false): string + private function literalFor(mixed $value, array $path, bool $inline = false, bool $child = false): string { if (is_string($value) && str_contains($value, "\n")) { $fresh = $this->claimVariable(self::stemFor($path)); - $this->edits[] = $this->document->defineVariableEdit($fresh, $value); + $this->edits[] = $this->document->defineVariableEdit($fresh, $value, $this->findHost()); return '$' . $fresh; } @@ -726,25 +1155,28 @@ private function literalFor(mixed $value, array $path, bool $inline = false): st } $isList = array_is_list($value); + // Each child is written once: a child that fits on its line stays there, as authors write a condition or a point. + $parts = []; + + foreach ($value as $key => $item) { + $literal = $this->literalFor($item, [...$path, $key], true, true); + $parts[] = $isList ? $literal : var_export($key, true) . ' => ' . $literal; + } if ($inline && $this->fitsOnOneLine($value)) { - $parts = []; + $literal = '[' . implode(', ', $parts) . ']'; - foreach ($value as $key => $item) { - $literal = $this->literalFor($item, [...$path, $key], true); - $parts[] = $isList ? $literal : var_export($key, true) . ' => ' . $literal; + // An entry matches its neighbours' lines; a child's small values in a long list still make a line nobody + // reads at once, so past the width it is written one per line. + if (! $child || strlen($literal) <= self::INLINE_WIDTH) { + return $literal; } - - return '[' . implode(', ', $parts) . ']'; } $lines = ['[']; - foreach ($value as $key => $item) { - $literal = self::indentContinuation($this->literalFor($item, [...$path, $key])); - $lines[] = $isList - ? " {$literal}," - : ' ' . var_export($key, true) . " => {$literal},"; + foreach ($parts as $part) { + $lines[] = ' ' . self::indentContinuation($part) . ','; } $lines[] = ']'; @@ -798,6 +1230,15 @@ private static function indentContinuation(string $literal): string )); } + /** + * Returns the variable written as data the diff is inside, which any + * variable it defines must precede. + */ + private function findHost(): ?string + { + return $this->hosts === [] ? null : $this->hosts[count($this->hosts) - 1]; + } + /** * Returns a variable name no other assignment or planned variable uses. */ diff --git a/src/Cutscenes/Source/PhpArraySourceDocument.php b/src/Cutscenes/Source/PhpArraySourceDocument.php index 3b559971..7c9e8177 100644 --- a/src/Cutscenes/Source/PhpArraySourceDocument.php +++ b/src/Cutscenes/Source/PhpArraySourceDocument.php @@ -20,6 +20,12 @@ * new entry on lines of its own in the array's indentation, a nowdoc's * content between its markers, a new variable before the `return`. * + * A variable written as data -- assigned once, by a literal, and named + * nowhere else but by plain references -- is part of the data: when one + * entry names it, a path through that entry reaches the variable's own + * literal, so it is edited where it is written. A variable the file builds + * with its own code is not data; it is kept and never rewritten. + * * It is deliberately narrow about what it reads. A file that is not a * header, top-level assignments and one returned array literal is refused * as unreadable, and a value it cannot classify -- a call, an arithmetic @@ -39,6 +45,8 @@ final class PhpArraySourceDocument * @param array $variables Top-level assignments by name. * @param int $returnStart The offset of the `return` keyword. * @param int $statementEnd The offset just past the returned statement's `;`. + * @param array $references How many values name each variable, in the returned array and in + * the literals of other variables. */ private function __construct( public readonly string $source, @@ -46,6 +54,7 @@ private function __construct( private readonly array $variables, private readonly int $returnStart, private readonly int $statementEnd, + private readonly array $references, ) { } @@ -137,15 +146,168 @@ public static function parse(string $source): self } } + $references = []; + self::countReferences($rootParse['node'], $references); + + foreach ($variables as $variable) { + if ($variable->value !== null) { + self::countReferences($variable->value, $references); + } + } + return new self( $source, $rootParse['node'], - $variables, + self::classifyVariables($tokens, $variables, $references), $tokens[$returnAt]->pos, $tokens[$after]->pos + 1, + $references, ); } + /** + * Keeps a text or literal variable as data only while the file names it + * nowhere but in its one assignment and in plain references: a variable + * code also names (`$slots[$role] = ...`, `foreach ($slots as ...)`, + * `"{$slots}"`) is one that code may build or read, so it becomes + * `OTHER`, as does a literal naming itself. + * + * @param PhpToken[] $tokens + * @param array $variables + * @param array $references + * @return array + */ + private static function classifyVariables(array $tokens, array $variables, array $references): array + { + $occurrences = []; + + foreach ($tokens as $token) { + if ($token->is(T_VARIABLE)) { + $name = substr($token->text, 1); + $occurrences[$name] = ($occurrences[$name] ?? 0) + 1; + } + } + + foreach ($variables as $name => $variable) { + if ($variable->kind === SourceVariable::OTHER) { + continue; + } + + $own = []; + + if ($variable->value !== null) { + self::countReferences($variable->value, $own); + } + + if (isset($own[$name]) || ($occurrences[$name] ?? 0) !== 1 + ($references[$name] ?? 0)) { + $variables[$name] = new SourceVariable($name, SourceVariable::OTHER, $variable->start, $variable->end); + } + } + + return $variables; + } + + /** + * Adds up the plain references to each variable under a node. + * + * @param array $counts + */ + private static function countReferences(SourceNode $node, array &$counts): void + { + if ($node->kind === SourceNode::VARIABLE) { + $counts[$node->variable ?? ''] = ($counts[$node->variable ?? ''] ?? 0) + 1; + + return; + } + + foreach ($node->entries as $entry) { + self::countReferences($entry->value, $counts); + } + } + + /** + * Returns the literal a reference stands for when the file writes it as + * data that this reference alone names, so a change under it is made + * where it is written; any other node is itself. + */ + public function resolveNode(SourceNode $node): SourceNode + { + if ($node->kind !== SourceNode::VARIABLE) { + return $node; + } + + $variable = $this->variables[$node->variable ?? ''] ?? null; + + if ($variable === null || $variable->kind !== SourceVariable::LITERAL || $variable->value === null || $this->referenceCount($variable->name) !== 1) { + return $node; + } + + return $variable->value; + } + + /** + * Returns a value with its keyed entries in the order this source writes + * them, however deeply nested, as the file reads back once written. + * + * The source-preserving writer keeps an authored key where it stands and + * places a new key where the value orders it, so an edit that set a key + * again, or rebuilt an entry, may hold the same keys in another order + * than the file. That order is the file's: an editor holding the value + * adopts it, so its value is the one the file reads back as. Lists keep + * the order the value gives, and keys the source does not hold stay + * after the ones it does. + * + * @param array $value + * @return array + */ + public function alignValue(array $value): array + { + return $this->alignToNode($this->root(), $value); + } + + /** + * @param array $value + * @return array + */ + private function alignToNode(SourceNode $node, array $value): array + { + $node = $this->resolveNode($node); + + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { + return $value; + } + + $ordered = []; + + foreach ($node->entries as $index => $entry) { + $key = $node->isList ? $index : $entry->key; + + if ($key === null || ! array_key_exists($key, $value)) { + continue; + } + + $ordered[$key] = is_array($value[$key]) ? $this->alignToNode($entry->value, $value[$key]) : $value[$key]; + } + + return $ordered + $value; + } + + /** + * Returns whether a reference names a value the file builds with its own + * code -- or a variable it never assigns at the top level -- rather than + * one it writes as data. + */ + public function isBuiltInCode(SourceNode $reference): bool + { + if ($reference->kind !== SourceNode::VARIABLE) { + return false; + } + + $variable = $this->variables[$reference->variable ?? ''] ?? null; + + return $variable === null || $variable->kind === SourceVariable::OTHER; + } + /** * Returns the returned array. */ @@ -189,6 +351,8 @@ public function entryAt(array $path): ?SourceEntry $entry = null; foreach ($path as $step) { + $node = $this->resolveNode($node); + if ($node->kind !== SourceNode::ARRAY) { return null; } @@ -206,7 +370,8 @@ public function entryAt(array $path): ?SourceEntry } /** - * Returns the node a path addresses; the root for an empty path. + * Returns the node a path addresses; the root for an empty path. A + * reference to data only it names is the literal it stands for. * * @param array $path The path. * @return SourceNode|null The node. @@ -217,30 +382,18 @@ public function nodeAt(array $path): ?SourceNode return $this->root; } - return $this->entryAt($path)?->value; + $entry = $this->entryAt($path); + + return $entry === null ? null : $this->resolveNode($entry->value); } /** - * Returns how many values in the returned array name a variable. + * Returns how many values name a variable: in the returned array and in + * the literals other variables are written as. */ public function referenceCount(string $name): int { - return $this->countReferences($this->root, $name); - } - - private function countReferences(SourceNode $node, string $name): int - { - if ($node->kind === SourceNode::VARIABLE) { - return $node->variable === $name ? 1 : 0; - } - - $count = 0; - - foreach ($node->entries as $entry) { - $count += $this->countReferences($entry->value, $name); - } - - return $count; + return $this->references[$name] ?? 0; } /** @@ -278,7 +431,8 @@ public function variableContent(string $name): ?string /** * Returns the indentation the entries of an array are written at, or - * what they would be written at if it has none yet. + * what they would be written at if it has none yet: one indentation + * step deeper than the line the array opens on. */ public function entryIndent(SourceNode $array): string { @@ -290,9 +444,88 @@ public function entryIndent(SourceNode $array): string } } - $ownIndent = $this->lineIndentBefore($array->start) ?? ''; + return $this->getLineIndent($array->start) . $this->getIndentStep($array); + } + + /** + * Returns the whitespace that starts the line holding an offset, whatever + * else precedes the offset on that line: the indentation of `'key' => [` + * for the `[` of that line. + */ + private function getLineIndent(int $offset): string + { + $lineStart = $this->lineStartOf($offset); + $lead = substr($this->source, $lineStart, $offset - $lineStart); + + return substr($lead, 0, strlen($lead) - strlen(ltrim($lead, " \t"))); + } + + /** + * Returns the indentation step the file uses around an array: how much + * deeper the nearest enclosing array writes its entries than the line it + * opens on. Two spaces when no enclosing array shows one. + */ + private function getIndentStep(SourceNode $array): string + { + foreach (array_reverse($this->findAncestorsAnywhere($array)) as $ancestor) { + $opening = $this->getLineIndent($ancestor->start); + + foreach ($ancestor->entries as $entry) { + $indent = $this->lineIndentBefore($entry->start); + + if ($indent === null) { + continue; + } + + if (strlen($indent) > strlen($opening) && str_starts_with($indent, $opening)) { + return substr($indent, strlen($opening)); + } + + break; + } + } + + return ' '; + } - return $ownIndent . ' '; + /** + * Returns the arrays enclosing a node in the returned array or in the + * literal a variable is written as, outermost first. + * + * @return array + */ + private function findAncestorsAnywhere(SourceNode $target): array + { + foreach ($this->variables as $variable) { + if ($variable->value !== null && ($ancestors = $this->findAncestors($variable->value, $target)) !== null) { + return $ancestors; + } + } + + return $this->findAncestors($this->root, $target) ?? []; + } + + /** + * Returns the arrays enclosing a node, outermost first, or null when the + * node is not below the one searched. + * + * @return array|null + */ + private function findAncestors(SourceNode $node, SourceNode $target): ?array + { + foreach ($node->entries as $entry) { + if ($entry->value === $target) { + return [$node]; + } + + $below = $this->findAncestors($entry->value, $target); + + if ($below !== null) { + return [$node, ...$below]; + } + } + + return null; } /** @@ -341,9 +574,16 @@ public function blockSpan(SourceEntry $entry): array $indent = $this->lineIndentBefore($entry->start); if ($indent === null) { - // The entry shares its line with something before it: only its - // own bytes and separator are its block. - return [$entry->start, $entry->separatorEnd]; + // The entry shares its line with something before it: its own + // bytes and separator are its block, with the spaces that set it + // apart from what precedes it, so the survivors keep one space + // between them and no line is left with trailing spaces. + $before = $entry->start; + while ($before > 0 && ($this->source[$before - 1] === ' ' || $this->source[$before - 1] === "\t")) { + $before--; + } + + return [$before, $entry->separatorEnd]; } $start = $this->lineStartOf($entry->start); @@ -365,7 +605,28 @@ public function blockSpan(SourceEntry $entry): array $start = $previousStart; } - return [$start, $this->lineEndAfter(max($entry->end, $entry->separatorEnd) - 1)]; + $end = $this->lineEndAfter(max($entry->end, $entry->separatorEnd) - 1); + // An indented first entry may still share its line with siblings or + // closing syntax. Its heading belongs to it; those later bytes do not. + if (! $this->holdsOnlyTrivia($entry->separatorEnd, $end)) { + return [$start, $entry->separatorEnd]; + } + return [$start, $end]; + } + + /** + * Returns whether the bytes between two offsets are only whitespace and + * comments. + */ + private function holdsOnlyTrivia(int $start, int $end): bool + { + foreach (PhpToken::tokenize('source, $start, $end - $start)) as $token) { + if (! $token->is([T_OPEN_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + return false; + } + } + + return true; } // -- Planning edits ------------------------------------------------------ @@ -385,9 +646,132 @@ public function replaceValueEdit(array $path, string $literal): array throw new RuntimeException(sprintf('No entry at %s to replace.', self::describePath($path))); } + $value = $this->resolveNode($entry->value); + + if ($value !== $entry->value) { + // Data only this entry names is replaced where it is written. + return [$value->start, $value->end, self::reindent($literal, $this->getLineIndent($value->start))]; + } + return [$entry->value->start, $entry->value->end, self::reindent($literal, $this->lineIndentBefore($entry->start) ?? '')]; } + /** Renames only a literal key; its value, comments and position survive. */ + public function renameKeyEdit(array $path, string $newKey): array + { + $entry = $this->entryAt($path); + $parent = $this->nodeAt(array_slice($path, 0, -1)); + if ($entry === null || $entry->keyIsOpaque || $parent === null || $parent->hasOpaqueKey + || $parent->entryFor($newKey) !== null) { + throw new SourcePreservationRefusal('The requested key rename cannot be preserved at ' . self::describePath($path)); + } + $end = $entry->start; + foreach (PhpToken::tokenize($this->source) as $token) { + if ($token->pos < $entry->start) { + continue; + } + if ($token->is(T_DOUBLE_ARROW)) { + return [$entry->start, $end, var_export($newKey, true)]; + } + if (! $token->is([T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { + $end = $token->pos + strlen($token->text); + } + if ($end > $entry->value->start) { + break; + } + } + throw new SourcePreservationRefusal('No literal key at ' . self::describePath($path)); + } + + /** + * Plans inserting entries, in order, ahead of an entry the array holds: + * on lines of their own above its block, or ahead of it on its line when + * it shares one. + * + * @param list $entries + * @return array{0: int, 1: int, 2: string} The edit. + */ + public function insertEntriesBeforeEdit(SourceNode $array, SourceEntry $anchor, array $entries): array + { + if ($this->lineIndentBefore($anchor->start) === null) { + $inline = array_map(fn(array $entry): string => $this->renderInlineEntry($entry['key'], $entry['literal'], $anchor->start), $entries); + + return [$anchor->start, $anchor->start, implode(', ', $inline) . ', ']; + } + + [$blockStart] = $this->blockSpan($anchor); + $lines = array_map(fn(array $entry): string => $this->renderEntryLine($array, $entry['key'], $entry['literal']), $entries); + + return [$blockStart, $blockStart, implode('', $lines)]; + } + + /** + * Returns whether every entry of an array shares its line with what + * precedes it, as `['type' => 'text', 'name' => '']` does. + */ + public function sharesLines(SourceNode $array): bool + { + if ($array->entries === []) { + return false; + } + + foreach ($array->entries as $entry) { + if ($this->lineIndentBefore($entry->start) !== null) { + return false; + } + } + + return true; + } + + /** + * Plans removing entries of an array whose entries share their lines, + * each run of removed entries with the separators that joined it, so the + * survivors read as if written without them: a run cut ahead of the + * entry after it, a run at the end cut after the entry before it, and an + * array left empty written `[]`. + * + * @param list $removed The indexes of the entries removed, ascending. + * @return array{edits: list, separatorCut: bool} The edits, and + * whether the last surviving entry's comma went with a run after it. + */ + public function removeSharedLineEntriesEdits(SourceNode $array, array $removed): array + { + $entries = $array->entries; + $count = count($entries); + $isRemoved = array_fill_keys($removed, true); + + if (count($isRemoved) === $count && $array->bodyStart !== null && $array->bodyEnd !== null) { + return ['edits' => [[$array->bodyStart, $array->bodyEnd, '']], 'separatorCut' => false]; + } + + $edits = []; + $separatorCut = false; + + for ($i = 0; $i < $count; $i++) { + if (! isset($isRemoved[$i])) { + continue; + } + + $j = $i; + + while (isset($isRemoved[$j + 1])) { + $j++; + } + + if ($j + 1 < $count) { + $edits[] = [$entries[$i]->start, $entries[$j + 1]->start, '']; + } else { + $edits[] = [$entries[$i - 1]->end, $entries[$j]->end, '']; + $separatorCut = true; + } + + $i = $j; + } + + return ['edits' => $edits, 'separatorCut' => $separatorCut]; + } + /** * Plans inserting an entry into an array: ahead of the entry now at a * position, or after the last one. @@ -406,30 +790,39 @@ public function insertEntryEdit(array $parentPath, int $position, int|string|nul throw new RuntimeException(sprintf('No array at %s to insert into.', self::describePath($parentPath))); } - $indent = $this->entryIndent($array); - $keyText = $key === null ? '' : var_export($key, true) . ' => '; - $line = $indent . $keyText . self::reindent($literal, $indent) . ",\n"; + $line = $this->renderEntryLine($array, $key, $literal); $entries = $array->entries; if ($entries === []) { - // Empty array: `[]` becomes a multi-line array holding the entry, - // closing at the array's own indentation. - $closing = $this->lineIndentBefore($array->start) ?? ''; - $inner = substr($this->source, $array->bodyStart, $array->bodyEnd - $array->bodyStart); + $expansion = $this->planEmptyArrayExpansion($array, $line); + + if ($expansion !== null) { + return $expansion; + } - if (trim($inner) === '') { - return [$array->bodyStart, $array->bodyEnd, "\n" . $line . $closing]; + // Only comments inside: the entry follows them, on a line of its + // own when the closing bracket has one. + if ($this->lineIndentBefore($array->bodyEnd) !== null) { + $at = $this->lineStartOf($array->bodyEnd); + + return [$at, $at, $line]; } + + $gap = ctype_space($this->source[$array->bodyEnd - 1]) ? '' : ' '; + + return [$array->bodyEnd, $array->bodyEnd, $gap . $this->renderInlineEntry($key, $literal, $array->bodyEnd)]; } if ($position < count($entries)) { - [$blockStart] = $this->blockSpan($entries[$position]); + $neighbour = $entries[$position]; - if ($this->lineIndentBefore($entries[$position]->start) === null) { + if ($this->lineIndentBefore($neighbour->start) === null) { // The neighbour shares its line: go ahead of it on that line. - return [$entries[$position]->start, $entries[$position]->start, trim($line, "\n") . ' ']; + return [$neighbour->start, $neighbour->start, $this->renderInlineEntry($key, $literal, $neighbour->start) . ', ']; } + [$blockStart] = $this->blockSpan($neighbour); + return [$blockStart, $blockStart, $line]; } @@ -438,9 +831,13 @@ public function insertEntryEdit(array $parentPath, int $position, int|string|nul $needsComma = $last->separatorEnd === $last->end; $afterLast = $this->lineEndAfter(max($last->end, $last->separatorEnd) - 1); - if ($this->lineIndentBefore($last->start) === null || $afterLast <= $last->separatorEnd) { - // Entries share lines: append inline after the last one. - return [$last->separatorEnd, $last->separatorEnd, ($needsComma ? ',' : '') . ' ' . trim($line, "\n")]; + if ($this->lineIndentBefore($last->start) === null + || $afterLast <= $last->separatorEnd + || ! $this->holdsOnlyTrivia($last->separatorEnd, $afterLast) + ) { + // The last entry shares its line with a sibling or the closing + // bracket: append inline after it. + return $this->planInlineAppend($array, [$this->renderInlineEntry($key, $literal, $last->start)], $last); } // The rest of the last entry's line -- its comma, a comment, the @@ -457,9 +854,86 @@ public function insertEntryEdit(array $parentPath, int $position, int|string|nul public function renderEntryLine(SourceNode $array, int|string|null $key, string $literal): string { $indent = $this->entryIndent($array); - $keyText = $key === null ? '' : var_export($key, true) . ' => '; - return $indent . $keyText . self::reindent($literal, $indent) . ",\n"; + return $indent . self::renderEntry($key, self::reindent($literal, $indent)) . ",\n"; + } + + /** + * Renders one entry for a line it shares with others: no indentation of + * its own and no separator, continuation lines under the line it joins. + */ + public function renderInlineEntry(int|string|null $key, string $literal, int $at): string + { + return self::renderEntry($key, self::reindent($literal, $this->getLineIndent($at))); + } + + /** + * Renders an entry's key and value, `key => value` or the value alone. + */ + private static function renderEntry(int|string|null $key, string $value): string + { + return ($key === null ? '' : var_export($key, true) . ' => ') . $value; + } + + /** + * Plans appending entries to an array whose entries share their lines, + * joined the way such an array joins its own: after the entry they + * follow, one space after each comma, and a trailing comma only when the + * array's last entry is written with one. + * + * @param SourceNode $array The array node. + * @param array $entries The entries, each `key => value` or a value. + * @param SourceEntry|null $lastSurviving The entry that will precede the + * appended ones, or null when none survives and they start the body. + * @param bool $separatorCut Whether the entries after it are cut with its + * comma ({@see removeSharedLineEntriesEdits}): the appended ones then + * join right after its value, ahead of any trailing comma left behind. + * @return array{0: int, 1: int, 2: string} The edit. + */ + public function planInlineAppend(SourceNode $array, array $entries, ?SourceEntry $lastSurviving, bool $separatorCut = false): array + { + if ($array->bodyStart === null) { + throw new RuntimeException('Only an array node takes appended entries.'); + } + + if ($lastSurviving !== null && $separatorCut) { + return [$lastSurviving->end, $lastSurviving->end, ', ' . implode(', ', $entries)]; + } + + $last = $array->entries === [] ? null : $array->entries[count($array->entries) - 1]; + $trailingComma = $last !== null && $last->separatorEnd !== $last->end; + $text = implode(', ', $entries) . ($trailingComma ? ',' : ''); + + if ($lastSurviving === null) { + return [$array->bodyStart, $array->bodyStart, $text]; + } + + if ($lastSurviving->separatorEnd === $lastSurviving->end) { + // It had no comma because it was last; it needs one now. + return [$lastSurviving->end, $lastSurviving->end, ', ' . $text]; + } + + return [$lastSurviving->separatorEnd, $lastSurviving->separatorEnd, ' ' . $text]; + } + + /** + * Plans writing entry lines into an array whose body is only whitespace: + * `[]` opens onto lines of their own and closes at the indentation of + * the line the array starts on. Null when the body holds anything else. + * + * @return array{0: int, 1: int, 2: string}|null The edit. + */ + private function planEmptyArrayExpansion(SourceNode $array, string $lines): ?array + { + if ($array->entries !== [] || $array->bodyStart === null || $array->bodyEnd === null) { + return null; + } + + if (trim(substr($this->source, $array->bodyStart, $array->bodyEnd - $array->bodyStart)) !== '') { + return null; + } + + return [$array->bodyStart, $array->bodyEnd, "\n" . $lines . $this->getLineIndent($array->start)]; } /** @@ -485,16 +959,26 @@ public function appendEntriesEdit(SourceNode $array, string $lines, ?SourceEntry throw new RuntimeException('Only an array node takes appended entries.'); } - if ($array->entries === []) { - $inner = substr($this->source, $array->bodyStart, $array->bodyEnd - $array->bodyStart); + $expansion = $this->planEmptyArrayExpansion($array, $lines); - if (trim($inner) === '') { - // `[]` becomes a multi-line array holding the entries, - // closing at the array's own indentation. - $closing = $this->lineIndentBefore($array->start) ?? ''; + if ($expansion !== null) { + return [$expansion]; + } - return [[$array->bodyStart, $array->bodyEnd, "\n" . $lines . $closing]]; - } + $closingLine = $this->lineStartOf($array->bodyEnd); + $last = $array->entries === [] ? null : $array->entries[count($array->entries) - 1]; + + if ($last !== null && $lastSurviving === $last && $last->end > $closingLine) { + // The closing bracket shares the last entry's line, so the + // start of that line is ahead of the entry: the lines follow the + // entry instead, and the bracket stays where it is written, + // after a comma only when the entry had one. + $trailingComma = $last->separatorEnd !== $last->end; + $body = substr(rtrim($lines, "\n"), 0, -1) . ($trailingComma ? ',' : ''); + + return $trailingComma + ? [[$last->separatorEnd, $last->separatorEnd, "\n" . $body]] + : [[$last->end, $last->end, ",\n" . $body]]; } $edits = []; @@ -504,7 +988,7 @@ public function appendEntriesEdit(SourceNode $array, string $lines, ?SourceEntry $edits[] = [$lastSurviving->end, $lastSurviving->end, ',']; } - $edits[] = [$this->lineStartOf($array->bodyEnd), $this->lineStartOf($array->bodyEnd), $lines]; + $edits[] = [$closingLine, $closingLine, $lines]; return $edits; } @@ -530,14 +1014,24 @@ public function removeEntryEdit(array $path): array /** * Plans a nowdoc assignment of a variable placed before the `return`, - * after the last existing assignment when there is one. + * after the last existing assignment when there is one, or ahead of the + * variable whose literal will name it. * * @param string $name The variable name, without `$`. * @param string $content The exact content. + * @param string|null $before The variable written as data whose literal will name it. * @return array{0: int, 1: int, 2: string} The edit. */ - public function defineVariableEdit(string $name, string $content): array + public function defineVariableEdit(string $name, string $content, ?string $before = null): array { + $host = $before === null ? null : ($this->variables[$before] ?? null); + + if ($host !== null) { + $at = $this->lineStartOf($host->start); + + return [$at, $at, self::nowdocAssignment($name, $content) . "\n"]; + } + $at = $this->lineStartOf($this->returnStart); $lastVariable = null; @@ -584,9 +1078,11 @@ public function replaceVariableContentEdit(string $name, string $content): array /** * Applies planned edits together, from the end of the file forward, and - * reads the result again. + * reads the result again. Edits at one place apply in reverse of the + * order they were planned, so text inserted there reads in planned + * order: each insertion lands ahead of the ones applied before it. * - * @param array $edits The edits. + * @param array $edits The edits, in planned order. * @return self The rewritten document. */ public function withEdits(array $edits): self @@ -595,7 +1091,10 @@ public function withEdits(array $edits): self return $this; } - usort($edits, static fn(array $a, array $b): int => [$b[0], $b[1]] <=> [$a[0], $a[1]]); + $edits = array_values($edits); + $order = array_keys($edits); + usort($order, static fn(int $a, int $b): int => [$edits[$b][0], $edits[$b][1], $b] <=> [$edits[$a][0], $edits[$a][1], $a]); + $edits = array_map(static fn(int $index): array => $edits[$index], $order); $source = $this->source; $previousStart = null; @@ -786,14 +1285,22 @@ private static function readAssignment(array $tokens, int $index, string $source return null; } + $value = null; + if ($significant === 1 && $token->is(T_CONSTANT_ENCAPSED_STRING)) { $kind = SourceVariable::STRING; $contentStart = $token->pos; $contentEnd = $token->pos + strlen($token->text); + } elseif (($literal = self::readValue($tokens, $valueAt, $source, [';'])) !== null + && in_array($literal['node']->kind, [SourceNode::ARRAY, SourceNode::SCALAR], true) + && self::nextSignificant($tokens, $literal['next']) === $cursor + ) { + $kind = SourceVariable::LITERAL; + $value = $literal['node']; } return [ - new SourceVariable($name, $kind, $start, $tokens[$cursor]->pos + 1, $contentStart, $contentEnd), + new SourceVariable($name, $kind, $start, $tokens[$cursor]->pos + 1, $contentStart, $contentEnd, value: $value), $cursor + 1, ]; } @@ -951,6 +1458,13 @@ private static function readArray(array $tokens, int $index, string $source): ?a } } else { $sawImplicit = true; + + if ($tokens[$cursor]->is(T_ELLIPSIS)) { + // A spread adds entries only PHP can count, so no later + // position or key in this array is the file's to address. + $keyIsOpaque = true; + $hasOpaqueKey = true; + } } $value = self::readValue($tokens, $valueAt, $source, [',']); diff --git a/src/Cutscenes/Source/SourceNode.php b/src/Cutscenes/Source/SourceNode.php index f60f21ec..3d3d1576 100644 --- a/src/Cutscenes/Source/SourceNode.php +++ b/src/Cutscenes/Source/SourceNode.php @@ -27,7 +27,7 @@ * @param int $end The offset just past the node's last byte. * @param SourceEntry[] $entries The array's entries, in source order, for an array node. * @param bool $isList Whether an array node has no explicit keys at all. - * @param bool $hasOpaqueKey Whether an array node holds an entry whose key the parser could not read. + * @param bool $hasOpaqueKey Whether an array node holds an entry whose key the parser could not read, or a spread. * @param string|null $variable The variable name, without `$`, for a variable node. * @param int|null $bodyStart The offset just past `[` or `array(`, for an array node. * @param int|null $bodyEnd The offset of the closing `]` or `)`, for an array node. @@ -46,7 +46,8 @@ public function __construct( } /** - * Returns the entry holding a key, or the entry at a list position. + * Returns the entry holding a key, or the entry at a list position. A + * list with a spread has no position the file can address. * * @param int|string $key The key, or the position for a list. * @return SourceEntry|null The entry. @@ -54,7 +55,7 @@ public function __construct( public function entryFor(int|string $key): ?SourceEntry { if ($this->isList) { - return is_int($key) ? ($this->entries[$key] ?? null) : null; + return is_int($key) && ! $this->hasOpaqueKey ? ($this->entries[$key] ?? null) : null; } foreach ($this->entries as $entry) { diff --git a/src/Cutscenes/Source/SourceVariable.php b/src/Cutscenes/Source/SourceVariable.php index ac123c9b..fec6d328 100644 --- a/src/Cutscenes/Source/SourceVariable.php +++ b/src/Cutscenes/Source/SourceVariable.php @@ -9,6 +9,11 @@ * `return`: the ASCII block a keyframe shows, the shared value several * entries name. * + * Text and literal kinds are written as data: assigned once and named + * nowhere else in the file but by plain references. Anything else is + * `OTHER`, a value the file builds with its own code (a call, a `require`, + * a loop that fills it), which the editor reads but never rewrites. + * * @package Ichiloto\Editor\Cutscenes\Source */ final readonly class SourceVariable @@ -16,6 +21,8 @@ public const string NOWDOC = 'nowdoc'; public const string HEREDOC = 'heredoc'; public const string STRING = 'string'; + /** An array or non-text scalar literal, with its parsed value. */ + public const string LITERAL = 'literal'; public const string OTHER = 'other'; /** @@ -27,6 +34,7 @@ * @param int|null $contentEnd The offset just past the last content byte (before the newline that precedes the closing label). * @param string $closingIndent The whitespace before the closing label, which PHP strips from every content line. * @param string|null $label The nowdoc or heredoc label. + * @param SourceNode|null $value The assigned array or scalar literal, for a literal variable. */ public function __construct( public string $name, @@ -37,6 +45,7 @@ public function __construct( public ?int $contentEnd = null, public string $closingIndent = '', public ?string $label = null, + public ?SourceNode $value = null, ) { } } diff --git a/src/Database/BattleArenaFields.php b/src/Database/BattleArenaFields.php new file mode 100644 index 00000000..e786277c --- /dev/null +++ b/src/Database/BattleArenaFields.php @@ -0,0 +1,221 @@ +value); + + return new RecordSubList( + key: 'footing.' . $habitat->value, prefix: $habitat->value . 'Footing', singular: $habitat->value . ' footing point', + fields: [new RecordField('x', 'x', InputControlType::FLOAT), new RecordField('y', 'y', InputControlType::FLOAT)], + blank: ['x' => 0, 'y' => 0], heading: $name . ' Footing', removeWhenEmpty: true, + ); + } + + /** + * The arena drawn for each encounter terrain a random battle on this + * arena starts on, stored as the Engine reads it: terrain => arena. + */ + public static function getVariantList(): RecordSubList + { + return new RecordSubList( + key: 'variants', prefix: 'terrainVariant', singular: 'terrain variant', + fields: [ + RecordField::reference('terrain', 'Encounter Terrain', ReferenceCatalog::ENCOUNTER_TERRAINS), + RecordField::reference('arena', 'Drawn On', self::CATEGORY), + ], + blank: ['terrain' => '', 'arena' => ''], heading: 'Terrain Variants', removeWhenEmpty: true, + keyField: 'terrain', valueField: 'arena', + ); + } + + /** + * Replaces the land or water footing at once, as one step: what a + * graphical editor's drag of a corner, or a new outline, writes. No + * points removes it. The Engine's footing rules decide what is accepted. + * + * @param list $points + * @throws RecordRefusal When the arenas cannot be written, there is no such arena, or the polygon is refused. + */ + public static function setFooting(ProjectRecordDatabase $arenas, int $index, EnemyHabitat $habitat, array $points, string $label): RecordChange + { + if (! $arenas->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $arenas->getReadOnlyReason() ?? 'the arenas cannot be written')); + } + $record = $arenas->getRecordByIndex($index) ?? throw new RecordRefusal(sprintf('%s has no record %d.', self::CATEGORY, $index)); + if (! in_array($habitat, self::FOOTED_HABITATS, true)) { + throw new RecordRefusal(sprintf('An arena marks land and water footing; %s battlers stand over either.', $habitat->value)); + } + $list = self::getFootingList($habitat); + try { + $read = BattleFooting::readPolygon($points, $list->heading); + $polygon = $habitat === EnemyHabitat::WATER ? new BattleFooting(water: $read) : new BattleFooting($read); + } catch (InvalidArgumentException $refused) { + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + // Whole canvas units stay integers, as an author writes them; the rest keep two decimals. + $entries = array_map(static fn(array $point): array => array_map(static fn(float $value): int|float + => round($value, 2) === floor($value) ? (int) $value : round($value, 2), ['x' => $point[0], 'y' => $point[1]]), + $habitat === EnemyHabitat::WATER ? $polygon->water : $polygon->land); + if ($entries === $record->getSubList($list->key)) { + return new RecordChange(null, $index); + } + $before = $record->toArray(); + $record->setEntries($list, $entries); + $after = $record->toArray(); + try { + $arenas->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $record->restorePayload($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + $label, + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + ), $index); + } + + /** The arena a battle with none chosen is fought on. */ + public static function getDefaultSchema(): RecordSchema + { + return new RecordSchema( + key: self::DEFAULT_CATEGORY, + entryNoun: 'default battle arena', + storage: RecordStorage::LIST_FILE, + relativePath: self::PATH, + fields: [RecordField::reference('default', 'Default Arena', self::CATEGORY, allowsNone: true)], + labelKey: 'default', + identityKey: null, + projection: new WholeFileProjection(['default']), + saveCheck: self::checkArenas(...), + ); + } + + /** + * A new arena, which saves at once as the Engine admits it: it starts on + * the project's first image, for the author to replace, with the theme's + * ground until the author marks where its picture's ground begins. + */ + public static function makeBlank(string $name, string $projectRoot): array + { + $images = ReferenceCatalog::getPngAssets($projectRoot); + sort($images); + if ($images === []) { + throw new RecordRefusal('A new battle arena starts on a background image. Add a PNG under assets first.'); + } + $id = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + + return ['id' => 'arena.' . ($id === '' ? 'new' : $id), 'name' => $name, 'background' => ['image' => $images[0]]]; + } + + /** + * Why the battle could not read the arenas as they would be saved, or + * null when it can: the Engine reads them, and binds them beside any the + * presentation code declares, refusing an arena or default both own. + * + * @param array $whole The arenas file as it would be written. + */ + public static function checkArenas(array $whole, string $projectRoot): ?string + { + $assets = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'assets'; + try { + $arenas = BattleArenaBindings::getFromArray($whole); + $catalog = BattlePresentationCatalog::loadCode($assets)?->bindArenas($arenas); + $layout = $catalog?->ui; + foreach ($catalog?->arenas ?? [] as $arena) { + $layout?->getGround($arena); + } + } catch (Throwable $error) { + return sprintf('The battle could not read %s as it would be saved: %s', BattleArenaBindings::FILE, $error->getMessage()); + } + + return null; + } +} diff --git a/src/Database/BattleEntryActorResolver.php b/src/Database/BattleEntryActorResolver.php index 64033b51..ba88a921 100644 --- a/src/Database/BattleEntryActorResolver.php +++ b/src/Database/BattleEntryActorResolver.php @@ -9,10 +9,8 @@ /** * The editor's mirror of the engine `ActorStore` reference resolution. * - * The engine registers every actor definition under three references — its - * durable id, its display name, and its file stem — normalized by lowercased - * trim, and refuses to build the store at all when two definitions share an - * identity or a reference points two ways. Battle-entry rules resolve actors + * Only durable IDs resolve, normalized by lowercased trim. Display names and + * filenames do not create aliases or collisions. Battle-entry rules resolve actors * through that store, so the editor resolves them the same way: a missing, * ambiguous, or contested identity stays visible and fails validation rather * than being substituted with whichever record happened to load first. @@ -49,6 +47,7 @@ public static function fromActors(array $actors): self $normalizedId = self::normalize($id); if ($normalizedId === '') { + $problems[] = sprintf('%s must declare an explicit non-empty actor id. Display names are not identities.', $actor->path); continue; } @@ -60,25 +59,7 @@ public static function fromActors(array $actors): self $definitions[$normalizedId] = $id; - $fileStem = $actor->path === '' ? '' : pathinfo($actor->path, PATHINFO_FILENAME); - - foreach ([$id, $actor->getName(), $fileStem] as $reference) { - $reference = self::normalize($reference); - - if ($reference === '') { - continue; - } - - $existing = $references[$reference] ?? null; - - if ($existing !== null && $existing !== $normalizedId) { - $problems[] = sprintf('Actor reference "%s" is ambiguous.', $reference); - - continue; - } - - $references[$reference] = $normalizedId; - } + $references[$normalizedId] = $normalizedId; } $canonical = []; diff --git a/src/Database/ConditionCodec.php b/src/Database/ConditionCodec.php index 252f879b..72730a9a 100644 --- a/src/Database/ConditionCodec.php +++ b/src/Database/ConditionCodec.php @@ -5,6 +5,7 @@ namespace Ichiloto\Editor\Database; use Ichiloto\Engine\Core\WorldConditionType; +use InvalidArgumentException; /** * Encodes and decodes the engine's world-condition arrays as a single @@ -16,12 +17,67 @@ * representation of it, so every surface that edits conditions behaves * identically. * - * Wire form: entries separated by `;`, each `[!]type:name[:extras]`, where - * extras depend on the type (quest status, switch `false`, item quantity, or - * variable `op:value`). A leading `!` negates. + * Wire form ({@see WorldStateLine}): entries separated by `;`, each + * `[!]type:name[:extras]`, where extras depend on the type (quest status, + * switch `false`, item quantity, variable `op:value`, or a map event's marker + * after its map id). A leading `!` negates. A name or value escapes its `\`, + * `:` and `;` with a backslash, so any name the Engine accepts round-trips. + * A line is read only as written: a part the type does not take, or a value + * the Engine would refuse, makes the entry unreadable, never rewritten. */ final class ConditionCodec { + /** The keys the line carries per type, beyond type, name and negate. */ + private const array CARRIED_KEYS = [ + 'quest' => ['status'], + 'switch' => ['value'], + 'event' => [], + 'map_event' => ['marker'], + 'variable' => ['op', 'value'], + 'item' => ['quantity'], + 'key_item' => ['quantity'], + ]; + + /** + * Why an authored condition list cannot go through the condition line, + * or null when the line carries it faithfully. + * + * Committing the line writes only what it carries, so a list that is not + * a list, a condition type the line cannot express, a quest status it + * would rewrite, or an authored key outside its vocabulary would be lost + * on commit. Such a list is refused by name instead. + * + * @param mixed $conditions The list as authored. + */ + public static function findCarryIssue(mixed $conditions): ?string + { + if (! is_array($conditions) || ! array_is_list($conditions)) { + return sprintf('the conditions are %s, not a list', is_array($conditions) ? 'a keyed array' : get_debug_type($conditions)); + } + + foreach ($conditions as $index => $condition) { + if (! is_array($condition)) { + return sprintf('condition %d is %s, not an array', $index + 1, get_debug_type($condition)); + } + + $type = strval($condition['type'] ?? ''); + if ($type === 'quest' && ! in_array($condition['status'] ?? 'completed', ['completed', 'active'], true)) { + return sprintf('condition %d has a quest status the condition editor would rewrite', $index + 1); + } + + if (self::decode(self::encode($condition)) === null) { + return sprintf('condition %d holds a shape the condition editor cannot carry', $index + 1); + } + + $extra = array_diff(array_keys($condition), ['type', 'name', 'negate', ...(self::CARRIED_KEYS[$type] ?? [])]); + if ($extra !== []) { + return sprintf('condition %d carries "%s", which the condition editor would drop', $index + 1, implode('", "', array_map(strval(...), $extra))); + } + } + + return null; + } + /** * Returns the condition types the engine's evaluator understands. * @@ -46,6 +102,9 @@ public static function encodeAll(array $conditions): string )); } + /** The operators a variable condition compares with, as the Engine's evaluator accepts them. */ + private const array VARIABLE_OPERATORS = ['==', '!=', '>', '>=', '<', '<=']; + /** * Encodes one condition as `[!]type:name[:extras]`. * @@ -55,8 +114,7 @@ public static function encodeAll(array $conditions): string public static function encode(array $condition): string { $type = strval($condition['type'] ?? ''); - $name = strval($condition['name'] ?? ''); - $parts = [$type, $name]; + $parts = [$type, strval($condition['name'] ?? '')]; switch ($type) { case 'quest': @@ -79,18 +137,20 @@ public static function encode(array $condition): string $parts[] = strval($condition['op'] ?? '=='); $parts[] = strval($condition['value'] ?? 0); break; + case 'map_event': + $parts[] = strval($condition['marker'] ?? ''); + break; } $negate = ($condition['negate'] ?? false) === true ? '!' : ''; - return $negate . implode(':', $parts); + return $negate . WorldStateLine::joinParts($parts); } /** - * Decodes the one-line form back into condition arrays. - * - * Unparseable segments are dropped rather than written back as garbage: - * a typo costs the author the condition, never the file. + * Decodes the one-line form back into condition arrays, leaving out an + * entry it cannot read: for showing a line this codec wrote. A line an + * author or a client submits is read by {@see decodeAllStrictly()}. * * @param string $value The encoded conditions. * @return array> @@ -99,7 +159,7 @@ public static function decodeAll(string $value): array { $conditions = []; - foreach (explode(';', $value) as $segment) { + foreach (WorldStateLine::splitEntries($value) as $segment) { $condition = self::decode($segment); if ($condition !== null) { @@ -111,53 +171,143 @@ public static function decodeAll(string $value): array } /** - * Decodes one `[!]type:name[:extras]` segment. + * Decodes the one-line form, refusing it whole when any entry cannot be + * read as written: for an interface that submits the line as typed, where + * a dropped or reinterpreted part would be a condition lost without a word. * - * @param string $segment The encoded segment. - * @return array|null The condition, or null when unparseable. + * @param string $value The encoded conditions. + * @return array> + * @throws InvalidArgumentException Naming the first unreadable entry and why. + */ + public static function decodeAllStrictly(string $value): array + { + $conditions = []; + + foreach (WorldStateLine::splitEntries($value) as $segment) { + if (trim($segment) === '') { + continue; + } + + $condition = self::parse($segment); + if (is_string($condition)) { + throw new InvalidArgumentException(sprintf('Condition "%s" cannot be read: %s Nothing was changed.', trim($segment), $condition)); + } + $conditions[] = $condition; + } + + return $conditions; + } + + /** + * Decodes one `[!]type:name[:extras]` entry. + * + * @param string $segment The encoded entry. + * @return array|null The condition, or null when it cannot be read as written. */ public static function decode(string $segment): ?array { - $segment = trim($segment); + $condition = self::parse($segment); + return is_array($condition) ? $condition : null; + } + + /** + * Reads one entry exactly as written. + * + * @return array|string The condition, or why it cannot be read. + */ + private static function parse(string $segment): array|string + { + $segment = trim($segment); if ($segment === '') { - return null; + return 'it is empty.'; } $negate = str_starts_with($segment, '!'); - $segment = ltrim($segment, '!'); - $parts = array_map(trim(...), explode(':', $segment)); - $type = strtolower($parts[0] ?? ''); + if ($negate) { + $segment = ltrim(substr($segment, 1)); + } + try { + $parts = WorldStateLine::splitParts($segment); + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + $type = strtolower($parts[0]); $name = $parts[1] ?? ''; - if ($name === '' || ! in_array($type, self::types(), true)) { - return null; + if (! in_array($type, self::types(), true)) { + return sprintf('write [!]type:name[:extras] with a type of %s.', implode(', ', self::types())); + } + if ($name === '') { + return sprintf('the %s condition needs a name.', $type); + } + + [$extras, $allowed] = [array_slice($parts, 2), match ($type) { + 'quest', 'switch', 'item', 'key_item' => 1, + 'map_event' => 1, + 'variable' => 2, + default => 0, + }]; + if (count($extras) > $allowed) { + return sprintf('the %s condition takes %s after its name, not "%s"; write a colon or semicolon inside a name as \\: or \\;.', + $type, match ($type) { + 'quest' => 'at most a status (completed or active)', + 'switch' => 'at most true or false', + 'item', 'key_item' => 'at most a quantity', + 'map_event' => 'only its event marker', + 'variable' => 'at most an operator and a value', + default => 'nothing', + }, implode(':', array_slice($extras, $allowed))); } $condition = ['type' => $type, 'name' => $name]; switch ($type) { case 'quest': - $status = strtolower($parts[2] ?? 'completed'); - $condition['status'] = in_array($status, ['completed', 'active'], true) ? $status : 'completed'; + $status = strtolower($extras[0] ?? 'completed'); + if (! in_array($status, ['completed', 'active'], true)) { + return sprintf('a quest status is completed or active, not "%s".', $extras[0]); + } + $condition['status'] = $status; break; case 'switch': - if (strtolower($parts[2] ?? 'true') === 'false') { + $state = strtolower($extras[0] ?? 'true'); + if (! in_array($state, ['true', 'false'], true)) { + return sprintf('a switch is true or false, not "%s".', $extras[0]); + } + if ($state === 'false') { $condition['value'] = false; } break; case 'item': case 'key_item': - if (intval($parts[2] ?? 1) > 1) { - $condition['quantity'] = intval($parts[2]); + if (isset($extras[0])) { + if (filter_var($extras[0], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) { + return sprintf('a quantity is a whole number above zero, not "%s".', $extras[0]); + } + if ((int) $extras[0] > 1) { + $condition['quantity'] = (int) $extras[0]; + } } break; case 'variable': - $condition['op'] = $parts[2] ?? '=='; - $rawValue = $parts[3] ?? '0'; - $condition['value'] = is_numeric($rawValue) ? intval($rawValue) : $rawValue; + $operator = $extras[0] ?? '=='; + if (! in_array($operator, self::VARIABLE_OPERATORS, true)) { + return sprintf('a variable compares with %s, not "%s".', implode(' ', self::VARIABLE_OPERATORS), $operator); + } + $condition['op'] = $operator; + $rawValue = $extras[1] ?? '0'; + $condition['value'] = is_numeric($rawValue) ? $rawValue + 0 : $rawValue; + break; + case 'map_event': + // A map event is named by its map and its one-shot event marker. + $marker = $extras[0] ?? ''; + if ($marker === '') { + return 'a map event condition needs its map and its event marker: map_event:map:marker.'; + } + $condition['marker'] = $marker; break; } diff --git a/src/Database/ConditionEditor.php b/src/Database/ConditionEditor.php index fd450452..19557cc0 100644 --- a/src/Database/ConditionEditor.php +++ b/src/Database/ConditionEditor.php @@ -32,12 +32,58 @@ final class ConditionEditor 'variable' => ['Variable', null], 'item' => ['Item', 'inventory'], 'key_item' => ['Key Item', 'inventory'], + 'map_event' => ['Map Event', 'maps'], ]; + /** The quest statuses a condition can require; the first is the default. */ + public const array QUEST_STATUSES = ['completed', 'active']; + + /** How a variable condition compares; the first is the default. */ + public const array VARIABLE_OPERATIONS = ['==', '!=', '>', '>=', '<', '<=']; + /** * @var array> The conditions being edited. */ private array $conditions = []; + + /** + * The condition vocabulary for an interface that builds conditions a + * part at a time without this editor's cursor: each type, what its name + * is called and which reference category lists names (null for a name + * the author invents), and the extras it carries, each with its choices + * or kind and its default. Every condition can be negated. + * + * @return list>}> + */ + public static function getGrammar(): array + { + $choices = static fn(array $values, array $labels): array => array_map( + static fn(mixed $value, string $label): array => ['value' => $value, 'label' => $label], $values, $labels); + $extras = [ + 'quest' => [['key' => 'status', 'label' => 'Status', 'kind' => 'options', 'default' => self::QUEST_STATUSES[0], + 'options' => $choices(self::QUEST_STATUSES, ['is completed', 'is active'])]], + 'switch' => [['key' => 'value', 'label' => 'Is', 'kind' => 'options', 'default' => true, + 'options' => $choices([true, false], ['on', 'off'])]], + 'variable' => [ + ['key' => 'op', 'label' => 'Compare', 'kind' => 'options', 'default' => self::VARIABLE_OPERATIONS[0], + 'options' => $choices(self::VARIABLE_OPERATIONS, self::VARIABLE_OPERATIONS)], + ['key' => 'value', 'label' => 'Value', 'kind' => 'text', 'default' => 0], + ], + 'item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], + 'key_item' => [['key' => 'quantity', 'label' => 'Quantity', 'kind' => 'integer', 'default' => 1]], + 'map_event' => [['key' => 'marker', 'label' => 'Event', 'kind' => 'text', 'default' => 'A', + // Chosen from the events of the map the condition names. + 'category' => 'map_event_markers', 'mapFrom' => 'name']], + ]; + + return array_map(static fn(string $type): array => [ + 'type' => $type, + 'label' => self::NAMES[$type][0] ?? ucfirst($type), + 'nameCategory' => self::NAMES[$type][1] ?? null, + 'negatable' => true, + 'extras' => $extras[$type] ?? [], + ], ConditionCodec::types()); + } private bool $isOpen = false; private string $fieldId = ''; private string $label = ''; @@ -243,9 +289,9 @@ public function cycleExtra(int $step): void switch (strval($condition['type'] ?? '')) { case 'quest': - $statuses = ['completed', 'active']; - $index = array_search(strval($condition['status'] ?? 'completed'), $statuses, true); - $condition['status'] = $statuses[(((is_int($index) ? $index : 0) + $step) % 2 + 2) % 2]; + $statuses = self::QUEST_STATUSES; + $index = array_search(strval($condition['status'] ?? $statuses[0]), $statuses, true); + $condition['status'] = $statuses[(((is_int($index) ? $index : 0) + $step) % count($statuses) + count($statuses)) % count($statuses)]; break; case 'switch': $condition['value'] = ($condition['value'] ?? true) === false; @@ -267,7 +313,7 @@ public function cycleExtra(int $step): void break; case 'variable': - $operations = ['==', '!=', '>', '>=', '<', '<=']; + $operations = self::VARIABLE_OPERATIONS; $index = array_search(strval($condition['op'] ?? '=='), $operations, true); $index = is_int($index) ? $index : 0; $condition['op'] = $operations[(($index + $step) % count($operations) + count($operations)) % count($operations)]; @@ -297,6 +343,25 @@ public function setName(string $name): void $this->conditions[$this->selectedIndex()] = $condition; } + /** + * Sets which event a map event condition waits on, chosen from the events + * of the map it names. + * + * @param string $marker The event's marker. + * @return void + */ + public function setMarker(string $marker): void + { + $condition = $this->selected(); + + if ($condition === null || strval($condition['type'] ?? '') !== 'map_event' || trim($marker) === '') { + return; + } + + $condition['marker'] = trim($marker); + $this->conditions[$this->selectedIndex()] = $condition; + } + /** * Sets a variable condition's value. * @@ -365,6 +430,7 @@ public static function describe(array $condition): string : 'is held', 'variable' => sprintf('%s %s', strval($condition['op'] ?? '=='), strval($condition['value'] ?? 0)), 'event' => 'happened', + 'map_event' => sprintf('event %s is done', strval($condition['marker'] ?? '')), default => '', }; diff --git a/src/Database/CutsceneSchemas.php b/src/Database/CutsceneSchemas.php index 4c4f9566..806680e8 100644 --- a/src/Database/CutsceneSchemas.php +++ b/src/Database/CutsceneSchemas.php @@ -6,7 +6,15 @@ use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; +use Ichiloto\Engine\Animations\Timelines\EffectCadence; +use Ichiloto\Engine\Animations\Timelines\EffectFacingTarget; +use Ichiloto\Engine\Animations\Timelines\EffectImageAttachment; +use Ichiloto\Engine\Rendering\Presentation\Canvas\CanvasImageFit; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; +use Ichiloto\Engine\Cutscenes\Presentation\CinematicStage; +use Ichiloto\Engine\Cutscenes\Summons\SummonEffectTiming; /** * How a cinematic and a summon are edited: the fields, sub-lists and @@ -33,6 +41,9 @@ final class CutsceneSchemas */ public const string SUMMONS_KEY = 'summons'; + /** The effect timeline category the Cutscenes workspace edits. */ + public const string EFFECTS_KEY = 'effects'; + /** * The payload key a cinematic's finalizer commands are edited under. */ @@ -48,17 +59,24 @@ final class CutsceneSchemas */ public const string CUES_KEY = 'cues'; - /** - * Cardinal facings a staged actor and a movement route step use. - */ - public const array FACINGS = ['north', 'south', 'east', 'west']; + /** The payload key a summon stage's subjects are edited under. */ + public const string STAGE_SUBJECTS_KEY = 'stage.subjects'; + + /** The payload key a summon stage's camera keys are edited under. */ + public const string STAGE_CAMERA_KEY = 'stage.camera'; + + /** The payload key a summon stage's cover keys are edited under. */ + public const string STAGE_COVERS_KEY = 'stage.covers'; + + /** Where a summon image track is placed: on a battler, the screen, or the graphical stage. */ + public const array SUMMON_IMAGE_ANCHORS = ['target', 'caster', 'screen', 'stage']; /** * Returns the cinematic category: one record per asset, its metadata as * fields, its cast as a sub-list, and its script and finalizer as * command lists opened as frames. */ - public static function cinematics(): RecordSchema + public static function cinematics(bool $graphical = false): RecordSchema { return new RecordSchema( key: self::CINEMATICS_KEY, @@ -89,9 +107,9 @@ public static function cinematics(): RecordSchema 'cast' => [], CutsceneAsset::COMMANDS_KEY => [], ], - subList: self::castList(), + subList: self::castList($graphical), commandLists: [ - CutsceneAsset::COMMANDS_KEY => self::cinematicCommandList(CutsceneAsset::COMMANDS_KEY), + CutsceneAsset::COMMANDS_KEY => self::cinematicCommandList(CutsceneAsset::COMMANDS_KEY, $graphical), self::FINALIZER_KEY => self::finalizerCommandList(), ], ); @@ -103,55 +121,65 @@ public static function cinematics(): RecordSchema */ public static function summons(): RecordSchema { + $fields = [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('name', 'Name'), + new RecordField('description', 'Description', removeWhenEmpty: true), + new RecordField('moveName', 'Move Name', removeWhenEmpty: true), + new RecordField('version', 'Version', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('linkedSummonId', 'Linked Summon Id', removeWhenEmpty: true), + RecordField::reference('linkedActionId', 'Linked Action', 'skills', allowsNone: true, noneLabel: '(none)'), + new RecordField('tags', 'Tags', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + // Lore and metadata. + new RecordField('lore', 'Lore', removeWhenEmpty: true), + RecordField::reference('element', 'Element', 'elements', allowsNone: true, noneLabel: '(none)'), + // Elemental affinities share the enemy contract: element to multiplier, read as Assess-style tiers. + // Omitted means not recorded, which the Summons menu shows as such rather than as "none". + new RecordField('elementAffinities', 'Element Affinities', removeWhenEmpty: true, codec: RecordFieldCodec::AFFINITIES, + displayDefault: '(not recorded)'), + new RecordField('effectiveAgainst', 'Effective Against', codec: RecordFieldCodec::CSV_LIST, reference: 'elements', + removeWhenEmpty: true, displayDefault: '(not recorded)'), + // Free-text matchups from before the affinity contract; the Summons menu still shows them. + new RecordField('strengths', 'Legacy Strengths', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + new RecordField('weaknesses', 'Legacy Weaknesses', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + new RecordField('attributes', 'Attributes', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + new RecordField('authoring', 'Authoring Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + // Availability: omitted is open; declared conditions must hold. + new RecordField('availability.conditions', 'Availability', codec: RecordFieldCodec::CONDITIONS, removeWhenEmpty: true, displayDefault: '(omitted: open)'), + // Wielder policy. + new RecordField('wielders.mode', 'Wielders', options: ['all', 'roles', 'characters'], removeWhenEmpty: true, displayDefault: '(omitted: open)'), + new RecordField('wielders.roles', 'Wielder Roles', codec: RecordFieldCodec::CSV_LIST, reference: 'classes', removeWhenEmpty: true), + new RecordField('wielders.characters', 'Wielder Characters', codec: RecordFieldCodec::CSV_LIST, reference: 'actor_ids', removeWhenEmpty: true), + new RecordField('wielders.tenancy', 'Tenancy', options: ['shared', 'exclusive'], removeWhenEmpty: true, displayDefault: 'shared'), + // Playback and presentation. + new RecordField('playback.defaultSpeed', 'Default Speed', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1'), + RecordField::boolean('playback.allowSkip', 'Allow Skip'), + RecordField::boolean('playback.loopPreview', 'Loop Preview'), + new RecordField('transitionIn.type', 'Transition In', options: ['fadeToBlack', 'fadeFromBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeToBlack'), + new RecordField('transitionIn.durationMs', 'Transition In Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('transitionIn.color', 'Transition In Color', removeWhenEmpty: true), + new RecordField('transitionOut.type', 'Transition Out', options: ['fadeFromBlack', 'fadeToBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeFromBlack'), + new RecordField('transitionOut.durationMs', 'Transition Out Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('transitionOut.color', 'Transition Out Color', removeWhenEmpty: true), + new RecordField('effectTiming.mode', 'Effect Timing', options: SummonEffectTiming::AUTHORING_MODES, removeWhenEmpty: true, displayDefault: SummonEffectTiming::DEFAULT_MODE), + RecordField::reference('effectTiming.cueId', 'Effect Cue', 'summon_cues', allowsNone: true, noneLabel: '(none)'), + new RecordField('effectTiming.frame', 'Effect Frame', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('targetPresentation.mode', 'Target Presentation', options: ['full_screen', 'inline'], removeWhenEmpty: true, displayDefault: 'full_screen'), + RecordField::boolean('targetPresentation.showCasterNameBanner', 'Caster Name Banner'), + // Timeline. + new RecordField('formatVersion', 'Timeline Format', InputControlType::INTEGER), + new RecordField('fps', 'FPS', InputControlType::INTEGER), + new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: 'Last frame'), + new RecordField('editor', 'Editor Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + ]; + return new RecordSchema( key: self::SUMMONS_KEY, entryNoun: 'summon', storage: RecordStorage::MAP_OWNED, relativePath: 'assets/Cutscenes/Summons', - fields: [ - new RecordField('id', 'Id', isReadOnly: true), - new RecordField('name', 'Name'), - new RecordField('description', 'Description', removeWhenEmpty: true), - new RecordField('moveName', 'Move Name', removeWhenEmpty: true), - new RecordField('version', 'Version', InputControlType::INTEGER, removeWhenEmpty: true), - new RecordField('linkedSummonId', 'Linked Summon Id', removeWhenEmpty: true), - RecordField::reference('linkedActionId', 'Linked Action', 'skills', allowsNone: true, noneLabel: '(none)'), - new RecordField('tags', 'Tags', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), - // Lore and metadata. - new RecordField('lore', 'Lore', removeWhenEmpty: true), - RecordField::reference('element', 'Element', 'elements', allowsNone: true, noneLabel: '(none)'), - new RecordField('strengths', 'Strengths', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), - new RecordField('weaknesses', 'Weaknesses', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), - new RecordField('attributes', 'Attributes', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), - new RecordField('authoring', 'Authoring Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), - // Availability: omitted is open; declared conditions must hold. - new RecordField('availability.conditions', 'Availability', codec: RecordFieldCodec::CONDITIONS, removeWhenEmpty: true, displayDefault: '(omitted: open)'), - // Wielder policy. - new RecordField('wielders.mode', 'Wielders', options: ['all', 'roles', 'characters'], removeWhenEmpty: true, displayDefault: '(omitted: open)'), - new RecordField('wielders.roles', 'Wielder Roles', codec: RecordFieldCodec::CSV_LIST, reference: 'classes', removeWhenEmpty: true), - new RecordField('wielders.characters', 'Wielder Characters', codec: RecordFieldCodec::CSV_LIST, reference: 'actors', removeWhenEmpty: true), - new RecordField('wielders.tenancy', 'Tenancy', options: ['shared', 'exclusive'], removeWhenEmpty: true, displayDefault: 'shared'), - // Playback and presentation. - new RecordField('playback.defaultSpeed', 'Default Speed', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1'), - RecordField::boolean('playback.allowSkip', 'Allow Skip'), - RecordField::boolean('playback.loopPreview', 'Loop Preview'), - new RecordField('transitionIn.type', 'Transition In', options: ['fadeToBlack', 'fadeFromBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeToBlack'), - new RecordField('transitionIn.durationMs', 'Transition In Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), - new RecordField('transitionIn.color', 'Transition In Color', removeWhenEmpty: true), - new RecordField('transitionOut.type', 'Transition Out', options: ['fadeFromBlack', 'fadeToBlack', 'none'], removeWhenEmpty: true, displayDefault: 'fadeFromBlack'), - new RecordField('transitionOut.durationMs', 'Transition Out Ms', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), - new RecordField('transitionOut.color', 'Transition Out Color', removeWhenEmpty: true), - new RecordField('effectTiming.mode', 'Effect Timing', options: ['end', 'cue', 'frame'], removeWhenEmpty: true, displayDefault: 'end'), - RecordField::reference('effectTiming.cueId', 'Effect Cue', 'summon_cues', allowsNone: true, noneLabel: '(none)'), - new RecordField('effectTiming.frame', 'Effect Frame', InputControlType::INTEGER, removeWhenEmpty: true), - new RecordField('targetPresentation.mode', 'Target Presentation', options: ['full_screen', 'inline'], removeWhenEmpty: true, displayDefault: 'full_screen'), - RecordField::boolean('targetPresentation.showCasterNameBanner', 'Caster Name Banner'), - // Timeline. - new RecordField('formatVersion', 'Timeline Format', InputControlType::INTEGER), - new RecordField('fps', 'FPS', InputControlType::INTEGER), - new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), - new RecordField('editor', 'Editor Metadata', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), - ], + fields: $fields, labelKey: 'name', identityKey: 'id', blank: [ @@ -167,30 +195,153 @@ public static function summons(): RecordSchema commandLists: [ self::TRACKS_KEY => self::trackList(), self::CUES_KEY => self::cueList(), + self::STAGE_SUBJECTS_KEY => self::stageSubjectList(), + self::STAGE_CAMERA_KEY => self::stageCameraList(), + self::STAGE_COVERS_KEY => self::stageCoverList(), + ], + // A sequence with a stage edits it as rows and lists of its own. + fieldsFor: static fn(array $payload): array => is_array($payload['stage'] ?? null) ? [...$fields, ...self::stageFields()] : $fields, + commandListsFor: static fn(array $payload): array => is_array($payload['stage'] ?? null) + ? [self::TRACKS_KEY, self::CUES_KEY, self::STAGE_SUBJECTS_KEY, self::STAGE_CAMERA_KEY, self::STAGE_COVERS_KEY] + : [self::TRACKS_KEY, self::CUES_KEY], + ); + } + + /** + * A summon's graphical stage: its own canvas in stage units, the frames + * it is shown from and until the arena returns, and what it is drawn on. + * Its subjects, camera keys and cover keys are lists opened as frames. + * + * @return list + */ + public static function stageFields(): array + { + return [ + new RecordField('stage.canvas', 'Stage Canvas', codec: RecordFieldCodec::SIZE), + new RecordField('stage.startFrame', 'Stage Start Frame', InputControlType::INTEGER), + new RecordField('stage.restoreFrame', 'Stage Restore Frame', InputControlType::INTEGER), + // black, white or #RRGGBB, as cover colours are written. + new RecordField('stage.background', 'Stage Background', removeWhenEmpty: true, displayDefault: 'black'), + ]; + } + + /** + * The subjects a stage places art on: each a stable id, where it stands, + * its registered box and the point of that box that stands there, and + * named points within the box (a chest, the ground) that art and effects + * attach to. + */ + public static function stageSubjectList(): RecordSubList + { + return new RecordSubList( + key: self::STAGE_SUBJECTS_KEY, + prefix: 'subject', + singular: 'subject', + fields: [ + new RecordField('id', 'Id'), + new RecordField('position', 'Position', codec: RecordFieldCodec::COORDINATES), + new RecordField('size', 'Size', codec: RecordFieldCodec::SIZE), + new RecordField('pivot', 'Pivot', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 1 (bottom centre)'), + ], + blank: ['id' => 'subject', 'position' => ['x' => 0, 'y' => 0], 'size' => ['width' => 100, 'height' => 100]], + nestedLists: ['*' => self::stageAttachmentList()], + heading: 'Stage Subjects', + removeWhenEmpty: true, + ); + } + + /** + * A stage subject's named points: each from 0 to 1 across and down its + * registered box. + */ + public static function stageAttachmentList(): RecordSubList + { + return new RecordSubList( + key: 'attachments', + prefix: 'attachment', + singular: 'point', + fields: [ + new RecordField('id', 'Id'), + new RecordField('x', 'X', InputControlType::FLOAT), + new RecordField('y', 'Y', InputControlType::FLOAT), + ], + blank: ['id' => 'point', 'x' => 0.5, 'y' => 0.5], + removeWhenEmpty: true, + ); + } + + /** + * The stage camera's keys, in frame order from frame zero: the stage + * point at the centre of the view and how near it is. + */ + public static function stageCameraList(): RecordSubList + { + return new RecordSubList( + key: self::STAGE_CAMERA_KEY, + prefix: 'camera', + singular: 'camera key', + fields: [ + new RecordField('id', 'Id'), + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('focus', 'Focus', codec: RecordFieldCodec::COORDINATES), + new RecordField('zoom', 'Zoom', InputControlType::FLOAT), + new RecordField('easing', 'Easing', options: ['', ...CinematicStage::EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), ], + blank: ['id' => 'key', 'frame' => 0, 'focus' => ['x' => 0, 'y' => 0], 'zoom' => 1], + heading: 'Stage Camera', + ); + } + + /** + * The stage's full-screen covers, in frame order: a colour and how + * opaque it is, from frame zero to a clear last frame. + */ + public static function stageCoverList(): RecordSubList + { + return new RecordSubList( + key: self::STAGE_COVERS_KEY, + prefix: 'cover', + singular: 'cover key', + fields: [ + new RecordField('id', 'Id'), + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('color', 'Color'), + new RecordField('opacity', 'Opacity', InputControlType::FLOAT), + new RecordField('easing', 'Easing', options: ['', ...CinematicStage::EASINGS], removeWhenEmpty: true, displayDefault: '(linear)'), + ], + blank: ['id' => 'cover', 'frame' => 0, 'color' => 'black', 'opacity' => 1], + heading: 'Stage Covers', + removeWhenEmpty: true, ); } /** * Returns the cast sub-list: who a cinematic addresses. */ - public static function castList(): RecordSubList + public static function castList(bool $graphical = false): RecordSubList { return new RecordSubList( key: 'cast', prefix: 'cast', singular: 'cast member', fields: [ - new RecordField('kind', 'Kind', options: ['player', 'party_actor', 'npc', 'staged_actor']), + new RecordField('kind', 'Kind', options: ['player', 'party_actor', 'npc', 'staged_actor', ...($graphical ? ['world_object'] : [])], + displayDefault: 'staged_actor'), ], blank: ['kind' => 'staged_actor', 'id' => 'actor', 'sprite' => ['@'], 'x' => 0, 'y' => 0], variants: [ 'player' => [new RecordField('id', 'Id')], 'party_actor' => [RecordField::reference('id', 'Actor', 'actors')], 'npc' => [new RecordField('id', 'NPC Id', reference: 'map_npcs')], - 'staged_actor' => self::stagedActorFields(), + 'world_object' => [RecordField::reference('id', 'World Object', 'map_world_objects')], + 'staged_actor' => static fn(array $entry): array => self::stagedActorFields($graphical, $entry), ], variantKey: 'kind', + nestedLists: $graphical ? ['staged_actor' => StagedActorFields::getSuppressionList(...)] : [], + // The Engine stages a cast member that names no kind. + variantDefault: 'staged_actor', + prepareEdit: static fn(array $entry, string $field): array => StagedActorFields::preparePositionEdit( + StagedActorFields::prepareAppearanceEdit($graphical ? StagedActorFields::prepareEdit($entry, $field) : $entry, $field), $field), ); } @@ -200,22 +351,57 @@ public static function castList(): RecordSubList * * @return RecordField[] */ - public static function stagedActorFields(): array + public static function stagedActorFields(bool $graphical = false, array $entry = []): array { - return [ + $axes = [new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER)]; + // A visual bound to a real subject inherits its position: it is never placed relative to another subject, + // and the graphical editor that binds it offers no coordinates either. + $position = match (true) { + ! isset($entry['subject']) => RelativePositionFields::getFields($entry, $axes, cinematic: true, graphical: $graphical, attachable: true), + $graphical => [], + default => $axes, + }; + $fields = [ new RecordField('id', 'Id'), new RecordField('sprite', 'Sprite', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('asset', 'Asset', removeWhenEmpty: true), - new RecordField('x', 'X', InputControlType::INTEGER), - new RecordField('y', 'Y', InputControlType::INTEGER), - new RecordField('facing', 'Facing', options: self::FACINGS, removeWhenEmpty: true, displayDefault: 'south'), - RecordField::boolean('visible', 'Visible'), + ...$position, + // An independent replacement that names no facing keeps the visual's current heading in the Engine. + ...EndFacingFields::getFields($entry, cinematic: true, graphical: $graphical, + facingDefault: ($entry['replace'] ?? false) === true && ! isset($entry['subject']) ? '(keep current facing)' : 'south'), + RecordField::boolean('visible', 'Visible', removeWhenEmpty: false, displayDefault: 'true'), RecordField::boolean('collision', 'Collision'), new RecordField('sprites.north', 'Sprite North', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('sprites.south', 'Sprite South', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('sprites.east', 'Sprite East', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), new RecordField('sprites.west', 'Sprite West', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES, removeWhenEmpty: true), ]; + + // A map NPC's look replaces the actor's own glyphs and sheet; a bound visual already wears its subject's. + if (! isset($entry['subject'])) { + $look = [new RecordField('appearance.kind', 'Look Of', options: ['', ...CinematicCommandSchema::STAGED_ACTOR_APPEARANCE_KINDS], + removeWhenEmpty: true, displayDefault: '(its own look)')]; + if (($entry['appearance']['kind'] ?? null) === 'npc') { + $look[] = RecordField::reference('appearance.id', 'Look Of NPC', 'map_npcs'); + } + array_splice($fields, 1, 0, $look); + } + if (isset($entry['appearance'])) { + $fields = array_values(array_filter($fields, static fn(RecordField $field): bool + => $field->key !== 'sprite' && $field->key !== 'asset' && ! str_starts_with($field->key, 'sprites.'))); + } + + if (! $graphical) { + return $fields; + } + + if (isset($entry['subject'])) { + $fields = array_values(array_filter($fields, static fn(RecordField $field): bool + => ! in_array($field->key, [EndFacingFields::FACING_FIELD, EndFacingFields::KIND_FIELD, EndFacingFields::ID_FIELD, + 'collision'], true))); + } + + return [...$fields, ...StagedActorFields::getFields($entry)]; } /** @@ -223,27 +409,37 @@ public static function stagedActorFields(): array * the interpreter understands, each with its fields, nested blocks as * frames, and routes, lanes and camera points as nested lists. */ - public static function cinematicCommandList(string $key): RecordSubList + public static function cinematicCommandList(string $key, bool $graphical = false, array $blank = ['type' => 'wait', 'seconds' => 0.5]): RecordSubList { return new RecordSubList( key: $key, prefix: 'command', singular: 'command', fields: [ - new RecordField('type', 'Type', options: CinematicCommandSchema::COMMAND_TYPES), + new RecordField('type', 'Type', options: [...CinematicCommandSchema::COMMAND_TYPES, ...RecordSchemaCatalog::getRegisteredCommandTypes()]), ], - blank: ['type' => 'wait', 'seconds' => 0.5], - variants: self::cinematicCommandVariants(), + blank: $blank, + variants: self::cinematicCommandVariants($graphical), variantKey: 'type', nestedLists: [ - 'move_route' => RecordSchemaCatalog::routeStepList(), + 'move_route' => static fn(array $entry): ?RecordSubList => MovementRouteFields::getPointList($entry, cinematic: true, graphical: $graphical), 'parallel' => self::laneList(), - 'camera' => static fn(array $entry): ?RecordSubList => strtolower(strval($entry['operation'] ?? '')) === 'route' ? self::cameraPointList() : null, + 'camera' => static fn(array $entry): ?RecordSubList => strtolower(strval($entry['operation'] ?? '')) === 'route' ? self::cameraPointList($graphical) : null, + ...RecordSchemaCatalog::getRegisteredCommandLists($graphical), + ...($graphical ? ['stage_actor' => StagedActorFields::getSuppressionList(...)] : []), ], variantArms: [ 'sequence' => ['commands' => 'Sequence'], 'choice' => ['cancel' => 'Cancel'], ], + exclusiveFields: [['effect', 'animation'], ['effect', 'id'], ['effect', 'secondsPerFrame']], + prepareEdit: static function (array $entry, string $field) use ($graphical): array { + $entry = InnPresentationFields::prepareEdit($entry, $field); + $entry = $graphical ? StagedActorFields::prepareEdit($entry, $field) : $entry; + $entry = StagedActorFields::prepareAppearanceEdit($entry, $field); + $entry = StagedActorFields::preparePositionEdit($entry, $field); + return MovementRouteFields::prepareEdit($entry, $field); + }, ); } @@ -295,32 +491,25 @@ public static function finalizerCommandList(): RecordSubList * * @return array): RecordField[]> */ - public static function cinematicCommandVariants(): array + public static function cinematicCommandVariants(bool $graphical = false): array { - $base = RecordSchemaCatalog::eventCommandVariants(); - $subject = static fn(string $prefix, string $label, bool $screen = false): array => [ + $base = RecordSchemaCatalog::eventCommandVariants($graphical); + $subject = static fn(string $prefix, string $label, bool $screen = false, array $entry = []): array => [ new RecordField( $prefix . '.kind', $label . ' Kind', options: $screen - ? CinematicCommandSchema::SUBJECT_KINDS - : array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, ['screen_position'])), + ? array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, $graphical ? [] : ['world_object'])) + : array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, $graphical ? ['screen_position'] : ['screen_position', 'world_object'])), ), - new RecordField($prefix . '.id', $label . ' Id', reference: 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), + new RecordField($prefix . '.id', $label . ' Id', reference: ($entry[$prefix]['kind'] ?? null) === 'world_object' ? 'map_world_objects' : 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), new RecordField($prefix . '.x', $label . ' X', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField($prefix . '.y', $label . ' Y', InputControlType::INTEGER, removeWhenEmpty: true), ]; return [ ...$base, - 'move_route' => [ - new RecordField('subject', 'Subject', options: ['player', 'npc', 'staged_actor']), - new RecordField('npcId', 'NPC Id', reference: 'map_npcs', removeWhenEmpty: true, allowsNone: true), - new RecordField('actorId', 'Staged Actor', reference: 'cinematic_cast', removeWhenEmpty: true, allowsNone: true), - new RecordField('secondsPerStep', 'Seconds Per Step', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField('speed', 'Steps Per Second', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField('wait', 'Wait For Completion', InputControlType::BOOLEAN, ['true'], removeWhenEmpty: false), - ], + 'move_route' => static fn(array $entry): array => MovementRouteFields::getFields($entry, cinematic: true), 'transfer' => [ RecordField::reference('map', 'Map', 'maps'), new RecordField('x', 'X', InputControlType::INTEGER), @@ -333,7 +522,7 @@ public static function cinematicCommandVariants(): array 'checkpoint' => [new RecordField('name', 'Checkpoint', reference: 'cinematic_checkpoints')], 'camera' => static fn(array $entry): array => [ new RecordField('operation', 'Operation', options: CinematicCommandSchema::CAMERA_OPERATIONS), - ...(in_array($entry['operation'] ?? '', ['focus', 'pan', 'track'], true) ? $subject('target', 'Target') : []), + ...(in_array($entry['operation'] ?? '', ['focus', 'pan', 'track'], true) ? $subject('target', 'Target', entry: $entry) : []), ...(in_array($entry['operation'] ?? '', ['pan', 'track', 'shake'], true) ? [new RecordField('seconds', 'Seconds', InputControlType::FLOAT)] : []), @@ -341,15 +530,26 @@ public static function cinematicCommandVariants(): array ? [new RecordField('magnitude', 'Magnitude', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1')] : []), ], - 'stage_actor' => self::stagedActorFields(), + 'stage_actor' => static fn(array $entry): array => $graphical + ? StagedActorFields::getCommandFields($entry) + : self::stagedActorFields(entry: $entry), 'show_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'hide_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], 'remove_actor' => [new RecordField('actorId', 'Actor', reference: 'cinematic_cast')], - 'field_animation' => [ - RecordField::reference('animation', 'Animation', 'animations'), - ...$subject('target', 'Target', true), - new RecordField('secondsPerFrame', 'Seconds Per Frame', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '0.12'), - ], + // An effect timeline owns its frame rate, so a command names + // either an effect or a legacy animation and its pace, never + // both; choosing one removes the other (see exclusiveFields). + 'field_animation' => static fn(array $entry): array => array_key_exists('effect', $entry) + ? [ + new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(legacy animation)'), + ...$subject('target', 'Target', true, $entry), + ] + : [ + new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(legacy animation)'), + RecordField::reference('animation', 'Animation', 'animations'), + ...$subject('target', 'Target', true, $entry), + new RecordField('secondsPerFrame', 'Seconds Per Frame', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '0.12'), + ], 'title_card' => [ new RecordField('title', 'Title', removeWhenEmpty: true), new RecordField('text', 'Text', InputControlType::MULTILINE, removeWhenEmpty: true), @@ -395,15 +595,16 @@ public static function laneList(): RecordSubList /** * Returns the points of a camera route: a target each, with a duration. */ - public static function cameraPointList(): RecordSubList + public static function cameraPointList(bool $graphical = false): RecordSubList { return new RecordSubList( key: 'points', prefix: 'point', singular: 'route point', - fields: [ - new RecordField('kind', 'Kind', options: array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, ['screen_position']))), - new RecordField('id', 'Id', reference: 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), + fields: [], + getFields: static fn(array $entry): array => [ + new RecordField('kind', 'Kind', options: array_values(array_diff(CinematicCommandSchema::SUBJECT_KINDS, $graphical ? ['screen_position'] : ['screen_position', 'world_object']))), + new RecordField('id', 'Id', reference: ($entry['kind'] ?? null) === 'world_object' ? 'map_world_objects' : 'cinematic_subjects', removeWhenEmpty: true, allowsNone: true), new RecordField('x', 'X', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('y', 'Y', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('seconds', 'Seconds', InputControlType::FLOAT), @@ -412,10 +613,239 @@ public static function cameraPointList(): RecordSubList ); } + /** + * Returns the effect timeline category: one record per effect, the + * sequence being edited as fields, its tracks and cues as lists opened + * as frames. Every row is a key the Engine's effect library reads; an + * effect with separate terminal and graphical sequences is edited one + * sequence at a time. Battle-only keys (impact timing, facing, image + * attachment, flips, flash and shake) are offered for every + * effect; validation says where + * an effect is used that refuses them. + */ + public static function effects(bool $graphical = false): RecordSchema + { + $fields = [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('fps', 'FPS', InputControlType::INTEGER), + new RecordField('lengthFrames', 'Length (frames)', InputControlType::INTEGER), + new RecordField('playback', 'Playback', options: ['once', 'loop'], removeWhenEmpty: true, displayDefault: 'once'), + new RecordField('loopFrom', 'Loop From', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + // Battle only: fixed plays at its FPS; battle_phase spreads its frames + // over the battle phase it plays in, as the battle's pace sets it. + new RecordField( + 'cadence', + 'Cadence', + options: array_map(static fn(EffectCadence $cadence): string => $cadence->value, EffectCadence::cases()), + removeWhenEmpty: true, + displayDefault: EffectCadence::FIXED->value, + ), + // Battle only: when the command's result lands. + new RecordField('effectTiming.mode', 'Impact Timing', options: ['end', 'frame', 'cue'], removeWhenEmpty: true, displayDefault: '(omitted)'), + new RecordField('effectTiming.frame', 'Impact Frame', InputControlType::INTEGER, removeWhenEmpty: true), + RecordField::reference('effectTiming.cueId', 'Impact Cue', 'effect_cues', allowsNone: true, noneLabel: '(none)'), + ]; + + return new RecordSchema( + key: self::EFFECTS_KEY, + entryNoun: 'effect', + storage: RecordStorage::MAP_OWNED, + relativePath: 'assets/' . EffectTimelineLibrary::DIRECTORY, + fields: $fields, + identityKey: 'id', + blank: [ + 'id' => 'new-effect', + 'fps' => 12, + 'lengthFrames' => 12, + self::TRACKS_KEY => [[ + 'id' => 'glyph', + 'type' => 'glyph', + 'keyframes' => [['frame' => 0, 'duration' => 12, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]]], + ]], + self::CUES_KEY => [], + ], + commandLists: [ + self::TRACKS_KEY => self::effectTrackList($graphical), + self::CUES_KEY => self::effectCueList(), + ...($graphical ? [ + self::STAGE_SUBJECTS_KEY => self::stageSubjectList(), + self::STAGE_CAMERA_KEY => self::stageCameraList(), + self::STAGE_COVERS_KEY => self::stageCoverList(), + ] : []), + ], + fieldsFor: static fn(array $payload): array => $graphical && is_array($payload['stage'] ?? null) + ? [...$fields, ...self::stageFields()] : $fields, + commandListsFor: static fn(array $payload): array => $graphical && is_array($payload['stage'] ?? null) + ? [self::TRACKS_KEY, self::CUES_KEY, self::STAGE_SUBJECTS_KEY, self::STAGE_CAMERA_KEY, self::STAGE_COVERS_KEY] + : [self::TRACKS_KEY, self::CUES_KEY], + ); + } + + /** + * Returns the track list of an effect timeline: the keys every track + * reads, an image track's sheet and cells as its own rows, and each + * track's keyframes as a nested list of the keys its type reads. + */ + public static function effectTrackList(bool $graphical = false): RecordSubList + { + $keyframes = self::effectKeyframeList(); + $anchors = [ + new RecordField('anchor', 'Anchor', options: ['target', 'caster', 'screen'], removeWhenEmpty: true, displayDefault: 'target'), + new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), + // Battle only, with a facing: a stroke turns toward its recipient; a ward raised in + // front of a battler turns toward that battler's opponents, wherever its caster stands. + new RecordField('toward', 'Faces Toward', + options: ['', ...array_map(static fn(EffectFacingTarget $target): string => $target->value, EffectFacingTarget::cases())], + removeWhenEmpty: true, displayDefault: EffectFacingTarget::RECIPIENT->value), + ]; + + return new RecordSubList( + key: self::TRACKS_KEY, + prefix: 'track', + singular: 'track', + fields: [ + new RecordField('id', 'Id'), + new RecordField('type', 'Type', options: ['glyph', 'text', 'image', 'flash', 'shake']), + new RecordField('presentation', 'Presentation', + options: ['all', ...array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases())], + removeWhenEmpty: true, displayDefault: 'all'), + ], + blank: ['id' => 'track', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 1, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]]]], + variants: [ + 'image' => static fn(array $entry): array => $graphical && self::isOnStage($entry) + ? self::stageImageTrackFields() + : [new RecordField('anchor', 'Anchor', options: $graphical ? self::SUMMON_IMAGE_ANCHORS : ['target', 'caster', 'screen'], + removeWhenEmpty: true, displayDefault: 'target'), $anchors[1], $anchors[2], ...self::imageTrackFields(sharedWithField: true)], + 'glyph' => $anchors, + 'text' => $anchors, + 'flash' => $anchors, + 'shake' => $anchors, + ], + variantKey: 'type', + nestedLists: [ + 'image' => static fn(array $entry): RecordSubList => $graphical && self::isOnStage($entry) + ? self::stageImageKeyframeList() : self::effectImageKeyframeList(), + 'glyph' => $keyframes, + 'text' => $keyframes, + 'flash' => $keyframes, + 'shake' => $keyframes, + ], + ); + } + + /** + * Returns the keyframes of an effect's glyph, text, flash or shake + * track. A position is `x` and `y`, as the Engine reads it. + */ + public static function effectKeyframeList(): RecordSubList + { + return new RecordSubList( + key: 'keyframes', + prefix: 'keyframe', + singular: 'keyframe', + fields: [ + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('duration', 'Duration', InputControlType::INTEGER), + new RecordField('position.x', 'Position X', InputControlType::INTEGER), + new RecordField('position.y', 'Position Y', InputControlType::INTEGER), + new RecordField('content', 'Content', InputControlType::MULTILINE, removeWhenEmpty: true), + new RecordField('assetId', 'Asset Id', removeWhenEmpty: true), + new RecordField('color', 'Color', removeWhenEmpty: true), + RecordField::boolean('visible', 'Visible'), + new RecordField('zIndex', 'Z Index', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('payload', 'Payload', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + ], + blank: ['frame' => 0, 'duration' => 1, 'content' => '*', 'position' => ['x' => 0, 'y' => 0]], + ); + } + + /** + * Returns the keyframes of an effect's image track: which sheet frame + * shows, where, and (battle only) whether it is flipped. + */ + public static function effectImageKeyframeList(): RecordSubList + { + return new RecordSubList( + key: 'keyframes', + prefix: 'keyframe', + singular: 'keyframe', + fields: [ + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('duration', 'Duration', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('sourceFrame', 'Sheet Frame', InputControlType::INTEGER), + new RecordField('position.x', 'Position X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('position.y', 'Position Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + RecordField::boolean('flipX', 'Flip Horizontally'), + RecordField::boolean('flipY', 'Flip Vertically'), + ], + blank: ['frame' => 0, 'sourceFrame' => 0], + ); + } + + /** + * Returns the cue list of an effect timeline. The field plays only + * `playSound`; battle plays every type. + */ + public static function effectCueList(): RecordSubList + { + return new RecordSubList( + key: self::CUES_KEY, + prefix: 'cue', + singular: 'cue', + fields: [ + new RecordField('id', 'Id'), + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('type', 'Type', options: ['playSound', 'applyEffect', 'showMessage', 'flash', 'shake']), + new RecordField('payload', 'Payload', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), + ], + blank: ['id' => 'cue', 'frame' => 0, 'type' => 'playSound', 'payload' => []], + ); + } + /** * Returns the track list of a summon timeline, each track owning its * keyframes as a nested list. */ + /** + * An image track's own fields, the same on an effect and a summon: the + * sheet it draws from, its depth, its normalized pivot and (in battle) + * the battler point it is placed on. An effect may be used in both field + * and battle: omission keeps the consumer's default, not an authored pivot. + * + * @return list + */ + public static function imageTrackFields(bool $sharedWithField = false): array + { + return [ + RecordField::reference('asset', 'Image', 'png_assets'), + new RecordField('sheet.columns', 'Sheet Columns', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('sheet.rows', 'Sheet Rows', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('cells.width', 'Cell Width', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('cells.height', 'Cell Height', InputControlType::INTEGER, displayDefault: '1'), + // How a sheet cell fills its cells: stretched to them, or kept to its + // own proportions inside them (the empty choice removes it: stretched). + new RecordField('fit', 'Fit', options: ['', ...array_map(static fn(CanvasImageFit $fit): string => $fit->value, CanvasImageFit::cases())], + removeWhenEmpty: true, displayDefault: '(' . CanvasImageFit::STRETCH->value . ')'), + new RecordField('depth', 'Depth', options: ['front', 'behind'], removeWhenEmpty: true, displayDefault: 'front'), + // Attachments are battle-only; a field image has only its pivot. + new RecordField('attachment', $sharedWithField ? 'Attachment (battle only)' : 'Attachment', + options: ['', ...array_map(static fn(EffectImageAttachment $attachment): string => $attachment->value, EffectImageAttachment::cases())], + removeWhenEmpty: true, displayDefault: '(center)'), + // Keep context-dependent defaults descriptive, never write one into + // a shared effect just because it was opened in a particular preview. + new RecordField('pivot', $sharedWithField ? 'Pivot (empty: field 0.5, 1; battle 0.5, 0.5)' : 'Pivot', + codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, + displayDefault: $sharedWithField ? '(field 0.5, 1; battle 0.5, 0.5)' : '0.5, 0.5'), + ]; + } + + /** + * A summon's tracks. Glyph and text tracks keep the summon's own screen + * positions; an image track is an effect image track, placed on its + * anchor (the target, by default) with the effect schema's fields and + * keyframes, so a summon's art is authored as any effect's is. + */ public static function trackList(): RecordSubList { return new RecordSubList( @@ -424,10 +854,91 @@ public static function trackList(): RecordSubList singular: 'track', fields: [ new RecordField('id', 'Id'), - new RecordField('type', 'Type', options: ['glyph', 'text', 'flash', 'shake'], removeWhenEmpty: true, displayDefault: 'glyph'), + new RecordField('type', 'Type', options: ['glyph', 'text', 'image', 'flash', 'shake'], removeWhenEmpty: true, displayDefault: 'glyph'), + // Which renderers draw the track: every one, or only the terminal or the graphical. + new RecordField('presentation', 'Presentation', + options: ['all', ...array_map(static fn(EffectPresentation $presentation): string => $presentation->value, EffectPresentation::cases())], + removeWhenEmpty: true, displayDefault: 'all'), ], blank: ['type' => 'glyph', 'id' => 'track', 'keyframes' => []], - nestedLists: ['*' => self::keyframeList()], + // On a graphical stage, art is placed in stage units on a subject, + // not on a battler; elsewhere it sits on its battle anchor. + variants: ['image' => static fn(array $entry): array => self::isOnStage($entry) ? self::stageImageTrackFields() : [ + new RecordField('anchor', 'Anchor', options: self::SUMMON_IMAGE_ANCHORS, removeWhenEmpty: true, displayDefault: 'target'), + // Battle only: the way the art is drawn; the battle mirrors it for a summon cast the other way. + new RecordField('facing', 'Facing', options: ['', 'west', 'east'], removeWhenEmpty: true, displayDefault: '(undirected)'), + ...self::imageTrackFields(), + ]], + variantKey: 'type', + nestedLists: [ + 'image' => static fn(array $entry): RecordSubList => self::isOnStage($entry) ? self::stageImageKeyframeList() : self::effectImageKeyframeList(), + // A track whose type is left out is a glyph track. + '' => self::keyframeList(), + 'glyph' => self::keyframeList(), + 'text' => self::keyframeList(), + 'flash' => self::keyframeList(), + 'shake' => self::keyframeList(), + ], + ); + } + + /** Whether a summon image track is placed on its sequence's graphical stage. */ + private static function isOnStage(array $track): bool + { + return ($track['anchor'] ?? null) === 'stage'; + } + + /** + * A summon image track on the stage: its sheet and how a cell fills its + * box, the subject and named point it stands on (else the stage origin), + * an offset and box in stage units, and its depth among the stage's art. + * Battler geometry (cells, facing, battler attachment) does not apply. + * + * @return list + */ + public static function stageImageTrackFields(): array + { + $byKey = []; + + foreach (self::imageTrackFields() as $field) { + $byKey[$field->key] = $field; + } + + return [ + new RecordField('anchor', 'Anchor', options: self::SUMMON_IMAGE_ANCHORS, removeWhenEmpty: true, displayDefault: 'target'), + $byKey['asset'], + $byKey['sheet.columns'], + $byKey['sheet.rows'], + $byKey['fit'], + $byKey['pivot'], + RecordField::reference('placement.subject', 'Subject', 'stage_subjects', allowsNone: true, noneLabel: '(stage origin)'), + RecordField::reference('placement.attachment', 'Subject Point', 'stage_attachments', allowsNone: true, noneLabel: "(the subject's pivot)"), + new RecordField('placement.position', 'Offset', codec: RecordFieldCodec::COORDINATES, removeWhenEmpty: true, displayDefault: '0, 0'), + new RecordField('placement.size', 'Size', codec: RecordFieldCodec::SIZE, removeWhenEmpty: true, displayDefault: "(the subject's size)"), + new RecordField('zIndex', 'Z Index', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + ]; + } + + /** + * The keyframes of an image on the stage: which sheet cell shows, its + * offset in stage units, how opaque it is, and whether it is flipped. + */ + public static function stageImageKeyframeList(): RecordSubList + { + return new RecordSubList( + key: 'keyframes', + prefix: 'keyframe', + singular: 'keyframe', + fields: [ + new RecordField('frame', 'Frame', InputControlType::INTEGER), + new RecordField('duration', 'Duration', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('sourceFrame', 'Sheet Frame', InputControlType::INTEGER), + new RecordField('position', 'Offset', codec: RecordFieldCodec::COORDINATES, removeWhenEmpty: true, displayDefault: '0, 0'), + new RecordField('opacity', 'Opacity', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1'), + RecordField::boolean('flipX', 'Flip Horizontally'), + RecordField::boolean('flipY', 'Flip Vertically'), + ], + blank: ['frame' => 0, 'sourceFrame' => 0], ); } @@ -469,7 +980,7 @@ public static function cueList(): RecordSubList fields: [ new RecordField('id', 'Id'), new RecordField('frame', 'Frame', InputControlType::INTEGER), - new RecordField('type', 'Type', options: ['applyEffect', 'showMessage', 'playSound', 'shake', 'flash'], removeWhenEmpty: true, displayDefault: 'showMessage'), + new RecordField('type', 'Type', options: ['applyEffect', 'showMessage', 'playSound', 'shake', 'flash', 'restoreBattlefield'], removeWhenEmpty: true, displayDefault: 'showMessage'), new RecordField('payload', 'Payload', codec: RecordFieldCodec::KEY_VALUES, removeWhenEmpty: true), ], blank: ['id' => 'cue', 'frame' => 0, 'type' => 'showMessage', 'payload' => []], diff --git a/src/Database/DatabaseCatalog.php b/src/Database/DatabaseCatalog.php index abb2e31a..d58fb827 100644 --- a/src/Database/DatabaseCatalog.php +++ b/src/Database/DatabaseCatalog.php @@ -4,6 +4,8 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Field\PlayerPresentationFields; + /** * Provides the editor's Database category registry. */ @@ -20,6 +22,9 @@ public static function all(): array new DatabaseCategoryDefinition('actors', 'Actors', 'Manage playable character records.', true), new DatabaseCategoryDefinition('classes', 'Classes', 'Define growth, roles, and class data.', true), new DatabaseCategoryDefinition('skills', 'Skills', 'Author active and passive skill entries.', true), + new DatabaseCategoryDefinition(MasteryFields::MAPS, 'Mastery Maps', 'Set a character\'s own terms for tree nodes: what each costs in SP, when it opens and what it builds on.', true), + new DatabaseCategoryDefinition(MasteryFields::REGIONS, 'Mastery Regions', 'Name the regions of the mastery tree every character shares, and the nodes on each trunk.', true), + new DatabaseCategoryDefinition(MasteryFields::BRANCHES, 'Mastery Branches', 'Grow each region\'s branches: an element, aspect or non-elemental practice and its nodes in the order they progress.', true), new DatabaseCategoryDefinition('items', 'Items', 'Manage consumables, key items, and resources.', true), new DatabaseCategoryDefinition('weapons', 'Weapons', 'Configure weapon stats and restrictions.', true), new DatabaseCategoryDefinition('armors', 'Armors', 'Configure armor stats and resistances.', true), @@ -29,9 +34,17 @@ public static function all(): array new DatabaseCategoryDefinition('states', 'States', 'Define status effects and conditions.', true), new DatabaseCategoryDefinition('animations', 'Animations', 'Create reusable keyframed effects.', true), new DatabaseCategoryDefinition('tilesets', 'Tilesets', 'Assign tiles and terrain behavior.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\MapCategoryFields::CATEGORY, 'Place Categories', 'Name the kinds of place a game marks on its maps beyond inn, shop, home and organization.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\MapRoleFields::CATEGORY, 'Map Roles', 'Name what maps are to the world, such as overworld, outside or interior, and the roles each one\'s doors may lead to.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\PlaceTypeFields::CATEGORY, 'Place Types', 'Name the kinds of place maps lead into, such as town, outpost or shrine, which world maps show by their icons.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\RegionFields::CATEGORY, 'Regions', 'Choose what a whole region shows, such as the backdrop skits show on its maps that choose none.', true), new DatabaseCategoryDefinition('common_events', 'Common Events', 'Author cutscene and event command lists.', true), new DatabaseCategoryDefinition('quests', 'Quests', 'Author quests, objectives, and rewards.', true), + new DatabaseCategoryDefinition('income_schedules', 'Income', 'Pay the party on a schedule: salaries, stipends, rents.', true), + new DatabaseCategoryDefinition(FieldCueFields::CATEGORY, 'Field Cues', 'Choose how cue marks and objective waymarks read on any ground: plate, bob, pointers, fade under the player and Terminal cell.', true), new DatabaseCategoryDefinition('skits', 'Skits', 'Write optional party banter scenes.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Dialogue\DialoguePortraitFields::CATEGORY, 'Dialogue Portraits', 'Choose the portraits, skit busts and emotions dialogue shows for each speaker.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Dialogue\DialogueSpeakerFields::CATEGORY, 'Speaker Names', 'Bind the names lines are spoken under to the portraits they show.', true), new DatabaseCategoryDefinition('knowledge_subjects', 'Knowledge', 'Author what the party can come to know.', true), new DatabaseCategoryDefinition('knowledge_reports', 'Knowledge Reports', 'Author claims about knowledge subjects.', true), new DatabaseCategoryDefinition('knowledge_record_types', 'Knowledge Types', 'Declare the kinds of record a subject can be.', true), @@ -41,13 +54,68 @@ public static function all(): array new DatabaseCategoryDefinition('optimize_outcomes', 'Optimize Outcomes', 'Weight elemental outcomes and special properties.', true), new DatabaseCategoryDefinition('optimize_exclusions', 'Optimize Exclusions', 'Keep gear out of automatic selection.', true), new DatabaseCategoryDefinition('system', 'System', 'Configure system-wide project settings.', true), + new DatabaseCategoryDefinition('configuration', 'Configuration', 'Adjust saving, accessibility, interface, field, graphics, audio and inn settings.', true), new DatabaseCategoryDefinition('types', 'Types', 'Manage element and weapon-type tables.', true), + new DatabaseCategoryDefinition(WordFormFields::CATEGORY, 'Word Forms', 'Give names their own singular and plural forms where the English rules would get them wrong.', true), new DatabaseCategoryDefinition('terms', 'Terms', 'Customize UI labels and message terms.', true), ]; } + /** The GUI's categories; the Terminal registry and its list positions stay unchanged. */ + public static function getGraphicalCategories(): array + { + return [...self::all(), new DatabaseCategoryDefinition(PlayerPresentationFields::CATEGORY, + 'Player Field Appearance', 'Choose fixed player art or the selected party leader\'s field role.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\FieldResourceFields::CATEGORY, + 'Field Resources', 'Reusable whole images, such as trees, that maps place by reference.', true), + new DatabaseCategoryDefinition(\Ichiloto\Editor\Field\TextRoleFields::CATEGORY, + 'Text Roles', 'How text such as a sign\'s label is set: the project\'s fonts, size and colour.', true), + new DatabaseCategoryDefinition(MenuThemeFields::CATEGORY, + 'Menu Theme', 'The shared menu theme: the minimap atlas\'s corner sizes, margin, markers and frame, and icon images.', true), + new DatabaseCategoryDefinition(BattleArenaFields::CATEGORY, + 'Battle Arenas', 'Battle backgrounds and the ground battlers stand on in each.', true), + new DatabaseCategoryDefinition(BattleArenaFields::DEFAULT_CATEGORY, + 'Default Battle Arena', 'The arena a battle that names none is fought on.', true)]; + } + + /** + * Returns the categories edited from another category's records rather + * than listed on their own: battle art is set on the actor's or enemy's + * own page, in the graphical editor, and never shown in the terminal's. + * + * @return list + */ + public static function getEmbedded(): array + { + return [ + new DatabaseCategoryDefinition('battler_actors', 'Actor Battle Art', 'The art an actor fights with in a graphical battle.', true, ['actors']), + new DatabaseCategoryDefinition('battler_enemies', 'Enemy Battle Art', 'The art an enemy fights with in a graphical battle.', true, ['enemies']), + new DatabaseCategoryDefinition('battle_scale', 'Battle Scale', 'The actor every battler\'s size is measured against.', true, ['actors', 'enemies']), + ]; + } + + /** + * Returns whether a key names a category, listed or embedded. + */ + public static function knows(string $key): bool + { + return self::findByKey($key) !== null; + } + + /** + * Returns the category a key names, listed or embedded; null when none + * does. Unlike {@see indexOf()}, which places a listed category in the + * Database list, this never answers with another category. + */ + public static function findByKey(string $key): ?DatabaseCategoryDefinition + { + return array_find([...self::getGraphicalCategories(), ...self::getEmbedded()], static fn(DatabaseCategoryDefinition $category): bool => $category->key === $key); + } + /** - * Returns the category index for the requested key. + * Returns the Database list position of a listed category; the first + * category for any other key, embedded ones included. To name a + * category by its key, use {@see findByKey()}. * * @param string $key Stable category key. * @return int diff --git a/src/Database/DatabaseCategory.php b/src/Database/DatabaseCategory.php new file mode 100644 index 00000000..6f856f92 --- /dev/null +++ b/src/Database/DatabaseCategory.php @@ -0,0 +1,119 @@ + The records' labels, as the list shows them. */ + public function getRecordLabels(): array; + + /** + * A record's rows in a frame of its commands ([] for the record itself), + * as the terminal editor's record pane builds them. + * + * @param list $frame + * @return array> + * @throws RecordRefusal When the record or frame is gone. + */ + public function getRecordRows(int $index, array $frame): array; + + /** + * How a frame reads, or null at the record itself. + * + * @param list $frame + */ + public function describeFrame(array $frame): ?string; + + /** + * What an entry of the list an add with no row goes to is called, or + * null when there is none. + * + * @param list $frame + */ + public function getListNoun(array $frame): ?string; + + /** + * The item a row belongs to, or null when it belongs to none. + * + * @param list $frame + */ + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem; + + /** + * @param list $frame + * @throws RecordRefusal When the record is gone or read-only, or the value is refused. + * @throws \Ichiloto\Editor\History\SourceSetRequired When the edit is only whole as files written at once. + */ + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange; + + /** + * @param list $frame + * @throws RecordRefusal When the record is gone or read-only, or the row's item cannot take it. + */ + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange; + + /** + * @param list $frame + * @throws RecordRefusal When the record is gone or read-only. + */ + public function removeItem(int $index, array $frame, string $fieldId): RecordChange; + + /** + * @param string|null $identity The identity a record is made for, when it belongs to something that already has one. + * @throws RecordRefusal When the category takes no new records, or none for that identity. + */ + public function createRecord(?string $identity = null): RecordChange; + + /** + * Copies a record below itself under a fresh identity: the one given + * (made unique) when the copy is meant to be something of its own, so + * its identity and file are named for it from the start. + * + * @throws RecordRefusal When the record cannot be duplicated. + */ + public function duplicateRecord(int $index, ?string $identity = null): RecordChange; + + /** @throws RecordRefusal When the record cannot be deleted. */ + public function deleteRecord(int $index): RecordChange; + + /** + * @param int $step -1 for up, 1 for down. + * @throws RecordRefusal When the record is gone or the file would not keep the order. + */ + public function moveRecord(int $index, int $step): RecordChange; + + /** @return list The files a save would overwrite. */ + public function getBackupPaths(): array; + + /** @throws RecordRefusal When the source cannot take the save, with why. */ + public function save(): void; +} diff --git a/src/Database/DatabaseCategoryDefinition.php b/src/Database/DatabaseCategoryDefinition.php index e5f2e82d..2234f000 100644 --- a/src/Database/DatabaseCategoryDefinition.php +++ b/src/Database/DatabaseCategoryDefinition.php @@ -14,12 +14,15 @@ * @param string $label User-facing category label. * @param string $description Short category purpose summary. * @param bool $isImplemented Whether the editor is currently implemented. + * @param list $hosts For a category edited from other categories' records + * rather than listed on its own: those categories' keys. */ public function __construct( public string $key, public string $label, public string $description, public bool $isImplemented = false, + public array $hosts = [], ) { } } diff --git a/src/Database/EndFacingFields.php b/src/Database/EndFacingFields.php new file mode 100644 index 00000000..27228d37 --- /dev/null +++ b/src/Database/EndFacingFields.php @@ -0,0 +1,88 @@ + + */ + public static function getFields(array $entry, bool $cinematic, bool $graphical, string $facingDefault): array + { + $contract = CinematicCommandSchema::export()['endFacing']; + $kinds = array_values(array_intersect(RelativePositionFields::getSubjectKinds($cinematic, $graphical), + $contract['subjectKinds'])); + $fields = [ + new RecordField(self::FACING_FIELD, 'Facing', options: ['', ...$contract['cardinals']], removeWhenEmpty: true, + displayDefault: $facingDefault), + new RecordField(self::KIND_FIELD, 'Face Toward', options: ['', ...$kinds], removeWhenEmpty: true, + displayDefault: '(no subject)'), + ]; + $kind = is_array($entry['faceToward'] ?? null) ? strval($entry['faceToward']['kind'] ?? '') : ''; + $id = RelativePositionFields::getSubjectIdField(self::ID_FIELD, $kind, 'Face Toward '); + if ($id !== null) { + $fields[] = $id; + } + return $fields; + } + + /** + * Keeps at most one end facing after one field edit, then has the Engine judge it. A subject still waiting for + * its id is a draft the owner's save boundary completes. + * + * @return array + * @throws InvalidArgumentException When the edit leaves an end facing the Engine refuses. + */ + public static function prepareEdit(array $entry, string $field): array + { + if (! in_array($field, [self::FACING_FIELD, self::KIND_FIELD, self::ID_FIELD], true)) { + return $entry; + } + if ($field === self::FACING_FIELD && array_key_exists('facing', $entry)) { + unset($entry['faceToward']); + } + if ($field === self::KIND_FIELD) { + $kind = is_array($entry['faceToward'] ?? null) ? ($entry['faceToward']['kind'] ?? null) : null; + if ($kind === null) { + unset($entry['faceToward']); + } else { + unset($entry['facing']); + if (! RelativePositionFields::namesSubjectId(strval($kind))) { + unset($entry['faceToward']['id']); + } + } + } + if (($entry['faceToward'] ?? null) === []) { + unset($entry['faceToward']); + } + try { + CinematicFacing::validate($entry); + } catch (InvalidArgumentException $error) { + $reference = $entry['faceToward'] ?? null; + $awaitsId = is_array($reference) && ! array_key_exists('id', $reference) + && RelativePositionFields::namesSubjectId(strval($reference['kind'] ?? '')); + if (! $awaitsId) { + throw $error; + } + } + return $entry; + } +} diff --git a/src/Database/EnemySprites.php b/src/Database/EnemySprites.php new file mode 100644 index 00000000..a6014bb5 --- /dev/null +++ b/src/Database/EnemySprites.php @@ -0,0 +1,58 @@ +.txt`), never from + * outside that folder. Shared by every editor surface that shows or places + * an enemy on the terminal field. + */ +final readonly class EnemySprites +{ + public function __construct(private string $projectRoot) + { + } + + /** + * One sprite's rows, or why there are none. + * + * @return array{lines?: list, issue?: string} + */ + public function readSprite(mixed $imagePath): array + { + if (! is_string($imagePath) || trim($imagePath) === '') { + return ['issue' => 'No sprite is chosen.']; + } + $folder = realpath($this->projectRoot . '/assets/Graphics/Enemies'); + $file = realpath($this->projectRoot . '/assets/Graphics/Enemies/' . $imagePath . '.txt'); + if ($folder === false || $file === false || ! str_starts_with($file, $folder . DIRECTORY_SEPARATOR)) { + return ['issue' => sprintf('Graphics/Enemies/%s.txt is not a sprite in this project.', $imagePath)]; + } + + return ['lines' => array_values(array_map(strval(...), (array) ProjectDirectoryContext::run( + $this->projectRoot, + static fn(): array => (array) graphics('Enemies/' . $imagePath), + )))]; + } + + /** + * Every enemy's sprite rows by the name a troop member names it by; an + * enemy whose sprite cannot be read has none. + * + * @return array> + */ + public function readByEnemy(?ProjectRecordDatabase $enemies): array + { + $sprites = []; + foreach ($enemies?->getRecords() ?? [] as $record) { + $sprites[trim(strval($record->get('name')))] = $this->readSprite($record->get('imagePath'))['lines'] ?? []; + } + + return $sprites; + } +} diff --git a/src/Database/FieldCueFields.php b/src/Database/FieldCueFields.php new file mode 100644 index 00000000..fd204618 --- /dev/null +++ b/src/Database/FieldCueFields.php @@ -0,0 +1,90 @@ + A setting's rows, showing the Engine's default where the catalog is silent. */ + public static function getFields(array $row): array + { + $default = array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null; + $path = (string) ($row['path'] ?? ''); + $style = FieldPresentationSettings::CUE_STYLE; + + return [ + new RecordField('path', 'Setting', isReadOnly: true), + match (true) { + $path === FieldPresentationSettings::WAYMARKS . '.color', $path === "{$style}.terminal.background" + => new RecordField('value', 'Colour', options: self::COLORS, displayDefault: $default), + $path === FieldPresentationSettings::WAYMARKS . '.inset' => new RecordField('value', + sprintf('Edge inset (0..%d cells)', FieldWaymarkStyle::MAX_INSET), InputControlType::INTEGER, displayDefault: $default), + $path === FieldPresentationSettings::WAYMARKS . '.symbol' => new RecordField('value', 'Symbol (one cell)', displayDefault: $default), + str_starts_with($path, FieldPresentationSettings::CUES . '.') && str_ends_with($path, '.plate') + => RecordField::reference('value', 'Plate image', 'png_assets', allowsNone: true, noneLabel: '(no plate)'), + $path === "{$style}.bob.height" => new RecordField('value', + sprintf('Bob height (0..%d px)', FieldCueStyle::MAX_BOB_HEIGHT), InputControlType::INTEGER, displayDefault: $default), + $path === "{$style}.bob.seconds" => new RecordField('value', 'Bob seconds (0.4..4)', InputControlType::FLOAT, displayDefault: $default), + $path === "{$style}.pointerSpacing.x", $path === "{$style}.pointerSpacing.y" => new RecordField('value', + sprintf('Pointer spacing %s (0..%d px)', str_ends_with($path, '.x') ? 'across' : 'up or down', + FieldCueStyle::MAX_POINTER_SPACING), InputControlType::INTEGER, displayDefault: $default), + $path === "{$style}.overlap.opacity" => new RecordField('value', 'Opacity under the player (0..1)', + InputControlType::FLOAT, displayDefault: $default), + $path === "{$style}.overlap.seconds" => new RecordField('value', + sprintf('Fade seconds under the player (0..%s)', FieldCueStyle::MAX_OVERLAP_SECONDS), InputControlType::FLOAT, + displayDefault: $default), + $path === "{$style}.terminal.bold" => RecordField::boolean('value', 'Bold'), + str_ends_with($path, '.effect') => RecordField::reference('value', 'Mark effect', 'effects'), + str_contains($path, '.pointer.') && str_ends_with($path, '.asset') + => RecordField::reference('value', 'Pointer image', 'png_assets', allowsNone: true), + str_contains($path, '.pointer.') && str_ends_with($path, '.quarterTurns') + => new RecordField('value', 'Quarter turns (0..3 clockwise)', InputControlType::INTEGER, displayDefault: $default), + // Images that baked the mark into each edge are read-only until split into pointers. + str_contains($path, '.edges.') => new RecordField('value', 'Old edge image (split into pointers)', isReadOnly: true), + default => new RecordField('value', 'Value', displayDefault: $default), + }, + ]; + } +} diff --git a/src/Database/InnPresentationFields.php b/src/Database/InnPresentationFields.php new file mode 100644 index 00000000..8d53f5b8 --- /dev/null +++ b/src/Database/InnPresentationFields.php @@ -0,0 +1,166 @@ + */ + public static function getFields(mixed $value, string $key, string $label, bool $graphical): array + { + if (! $graphical) { + return [is_array($value) || is_object($value) + ? new RecordField($key, $label, isReadOnly: true) + : RecordField::reference($key, $label, 'stage_timelines', allowsNone: true, noneLabel: 'None')]; + } + + return [ + ...(! is_array($value) && ! is_object($value) + ? [RecordField::reference($key, $label, 'stage_timelines', allowsNone: true, noneLabel: 'None')] + : []), + new RecordField($key . '.treatment', $label . ' Treatment', options: [PartyStageSelection::LEADER, PartyStageSelection::PARTY]), + ]; + } + + /** @return list */ + public static function getBindingLists(string $key): array + { + return [ + new RecordSubList( + key: $key . '.leaders', prefix: 'innLeader', singular: 'leader binding', + fields: [RecordField::reference('actor', 'Leader', 'actor_ids'), RecordField::reference('timeline', 'Stage', 'stage_timelines')], + blank: ['actor' => '', 'timeline' => ''], heading: 'Leader Bindings', keyField: 'actor', valueField: 'timeline', + ), + new RecordSubList( + key: $key . '.parties', prefix: 'innParty', singular: 'party binding', + fields: [new RecordField('actors', 'Guests', codec: RecordFieldCodec::CSV_LIST, reference: 'actor_ids'), + RecordField::reference('timeline', 'Stage', 'stage_timelines')], + blank: ['actors' => [], 'timeline' => ''], heading: 'Exact Party Bindings', + ), + ]; + } + + public static function getBindingList(mixed $value, string $key): ?RecordSubList + { + if (! is_array($value)) { return null; } + return match ($value['treatment'] ?? null) { + PartyStageSelection::LEADER => self::getBindingLists($key)[0], + PartyStageSelection::PARTY => self::getBindingLists($key)[1], + default => null, + }; + } + + /** + * Blank picker rows are authoring drafts, never invented resource ids. + * Only complete rows participate in draft DTO validation; saving always + * validates the actual, unfiltered value with the authoritative DTO. + */ + public static function assertValid(mixed $value, ?ReferenceCatalog $references = null, bool $complete = true): void + { + if ($value === null) { return; } + if (is_string($value)) { + EffectTimelineLibrary::assertId($value); + self::assertReference($value, 'stage_timelines', $references); + return; + } + if ($value instanceof PartyStageSelection) { $value = $value->toArray(); } + if (! is_array($value)) { throw new InvalidArgumentException('A rest presentation must be a stage id or party stage selection.'); } + + $checked = $value; + if (! $complete && ($value['treatment'] ?? null) === PartyStageSelection::LEADER && is_array($value['leaders'] ?? null)) { + $checked['leaders'] = array_filter($value['leaders'], static fn(mixed $timeline, mixed $actor): bool => $actor !== '' && $timeline !== '', ARRAY_FILTER_USE_BOTH); + } + if (! $complete && ($value['treatment'] ?? null) === PartyStageSelection::PARTY && is_array($value['parties'] ?? null)) { + $checked['parties'] = array_values(array_filter($value['parties'], static fn(mixed $binding): bool => + ! is_array($binding) || array_diff(array_keys($binding), ['actors', 'timeline']) !== [] + || (($binding['actors'] ?? null) !== [] && ($binding['timeline'] ?? null) !== ''))); + } + // Mixed bindings, unknown keys, duplicate compositions and limits remain Engine-owned. + PartyStageSelection::fromArray($checked); + if (count((array) ($value['leaders'] ?? [])) > 1024 || count((array) ($value['parties'] ?? [])) > 1024) { + throw new InvalidArgumentException('A rest presentation has too many bindings.'); + } + foreach ((array) ($value['leaders'] ?? []) as $actor => $timeline) { + self::assertReference((string) $actor, 'actor_ids', $references, ! $complete); + self::assertReference($timeline, 'stage_timelines', $references, ! $complete); + } + foreach ((array) ($value['parties'] ?? []) as $binding) { + if (! is_array($binding) || ! is_array($binding['actors'] ?? null) || ! array_is_list($binding['actors']) + || array_diff(array_keys($binding), ['actors', 'timeline']) !== [] || ! array_key_exists('timeline', $binding)) { + throw new InvalidArgumentException('A party binding needs an actor list and stage timeline.'); + } + foreach ($binding['actors'] as $actor) { self::assertReference($actor, 'actor_ids', $references); } + if (count(array_unique($binding['actors'], SORT_REGULAR)) !== count($binding['actors'])) { + throw new InvalidArgumentException('A party binding cannot repeat a guest.'); + } + self::assertReference($binding['timeline'], 'stage_timelines', $references, ! $complete); + } + } + + private static function assertReference(mixed $value, string $category, ?ReferenceCatalog $references, bool $blankAllowed = false): void + { + if ($value === '' && $blankAllowed) { return; } + if (! is_string($value) || trim($value) === '' || $value !== trim($value)) { + throw new InvalidArgumentException('Choose a nonempty ' . ($category === 'actor_ids' ? 'actor identity' : 'stage timeline') . '.'); + } + if ($category === 'stage_timelines') { EffectTimelineLibrary::assertId($value); } + if ($references !== null && ! in_array($value, $references->valuesFor($category), true)) { + throw new InvalidArgumentException(sprintf('Unknown %s "%s"; choose a project resource.', $category, $value)); + } + } + + /** Validates registered stage references at any command depth, not just inn commands. */ + public static function assertCommandsValid(array $payload, ?ReferenceCatalog $references = null, bool $complete = true): void + { + $definition = is_string($payload['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$payload['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $value = $payload; + foreach (explode('.', $field->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + self::assertValid($value, $references, $complete); + } + foreach ($payload as $nested) { + if (is_array($nested)) { self::assertCommandsValid($nested, $references, $complete); } + } + } + + public static function prepareEdit(array $entry, string $field): array + { + $definition = is_string($entry['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$entry['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $declared) { + if ($declared->reference === ScriptCommandReference::STAGE_TIMELINE + && ($field === $declared->key || str_starts_with($field, $declared->key . '.'))) { + self::assertCommandsValid($entry, complete: false); + } + } + return $entry; + } + + /** Only changed presentation values are authored; unrelated Terminal edits preserve graphical data. */ + public static function assertChangedCommandsValid(array $old, array $new, ?ReferenceCatalog $references = null, bool $complete = true): void + { + if ($old === $new) { return; } + $definition = is_string($new['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$new['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $before = $old; + $after = $new; + foreach (explode('.', $field->key) as $segment) { + $before = is_array($before) ? ($before[$segment] ?? null) : null; + $after = is_array($after) ? ($after[$segment] ?? null) : null; + } + if ($before !== $after) { self::assertValid($after, $references, $complete); } + } + foreach ($new as $key => $nested) { + if (is_array($nested)) { self::assertChangedCommandsValid(is_array($old[$key] ?? null) ? $old[$key] : [], $nested, $references, $complete); } + } + } +} diff --git a/src/Database/InventoryCatalog.php b/src/Database/InventoryCatalog.php index 15b3f053..6b840909 100644 --- a/src/Database/InventoryCatalog.php +++ b/src/Database/InventoryCatalog.php @@ -5,6 +5,9 @@ namespace Ichiloto\Editor\Database; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Entities\Inventory\InventoryItem; +use Ichiloto\Engine\Entities\Inventory\ItemRecord; +use InvalidArgumentException; use RuntimeException; /** @@ -34,9 +37,9 @@ final class InventoryCatalog /** * The record categories whose entries are inventory definitions. * - * The engine reads one `assets/Data/items.php` catalogue; the editor - * presents it as consumables, weapons and armors, so identity spans all - * three rather than living in any one of them. + * The engine reads one catalogue from the `Items`, `Weapons` and `Armors` + * record folders; the editor presents it as those three categories, so + * identity spans all three rather than living in any one of them. */ public const array CATEGORIES = ['items', 'weapons', 'armors']; @@ -420,6 +423,24 @@ public static function definitionId(mixed $authoredId, string $name): ?string return 'legacy.' . self::slug($name); } + /** + * Returns a definition as the game builds it from its record, unsaved + * edits included and the defaults the record leaves out filled in. A + * record the game cannot build is the validator's to report, so it reads + * as null here. + * + * @param ProjectRecord $record The inventory record. + * @return InventoryItem|null The definition. + */ + public static function readDefinition(ProjectRecord $record): ?InventoryItem + { + try { + return ItemRecord::readItem((array) $record->toArray()); + } catch (InvalidArgumentException) { + return null; + } + } + /** * Determines whether an authored id is one the engine will accept. * diff --git a/src/Database/MasteryAdoption.php b/src/Database/MasteryAdoption.php new file mode 100644 index 00000000..91a4c989 --- /dev/null +++ b/src/Database/MasteryAdoption.php @@ -0,0 +1,155 @@ +}|null The plan and the skills it adds, or null when the + * actor file holds no skills of its own. + * @throws RuntimeException When the actor is unknown, the project declares no regions, or a file cannot be rewritten. + */ + public static function plan(string $projectRoot, string $actorId): ?array + { + return ProjectDirectoryContext::run($projectRoot, static function (string $root) use ($actorId): ?array { + $catalog = MasteryCatalog::load($root . '/assets'); + $regions = $catalog->regions; + if ($regions === []) { + throw new RuntimeException('Declare the Mastery regions first: a node needs a region to sit in.'); + } + $actorPaths = glob($root . '/assets/Data/Actors/*.php') ?: []; + $mapPaths = glob($root . '/assets/' . MasteryCatalog::DIRECTORY . '/*.php') ?: []; + $classesPath = $root . '/assets/Data/classes.php'; + $settingsPath = $root . '/assets/' . MasteryCatalog::SETTINGS; + $payloads = array_map(PhpDataFile::getComparableValue(...), + PhpDataFile::evaluateIsolatedFiles([...$actorPaths, ...$mapPaths, $settingsPath, ...(is_file($classesPath) ? [$classesPath] : [])], $root)); + $actorPayloads = array_slice($payloads, 0, count($actorPaths)); + $mapPayloads = array_slice($payloads, count($actorPaths), count($mapPaths)); + $settings = (array) $payloads[count($actorPaths) + count($mapPaths)]; + $classes = is_file($classesPath) ? (array) end($payloads) : []; + + $actorIndex = array_find_key($actorPayloads, static fn(mixed $payload): bool => is_array($payload) + && (($payload['data']['id'] ?? $payload['data']['name'] ?? null) === $actorId)); + if ($actorIndex === null) { + throw new RuntimeException(sprintf('No actor file has the id %s.', $actorId)); + } + $actorPath = $actorPaths[$actorIndex]; + $actor = $actorPayloads[$actorIndex]; + $role = array_find($classes, static fn(mixed $class): bool => is_array($class) && ($class['name'] ?? null) === ($actor['data']['class'] ?? null)); + $taught = array_map(static fn(array $grant): string => strval($grant['skill'] ?? ''), (array) ($role['skillsToLearn'] ?? [])); + + $mapIndex = array_find_key($mapPayloads, static fn(mixed $payload): bool => is_array($payload) && ($payload['actor'] ?? null) === $actorId); + $map = $mapIndex === null ? ['actor' => $actorId, 'nodes' => []] : $mapPayloads[$mapIndex]; + $onMap = array_map(static fn(array $node): string => strval($node['id'] ?? ''), (array) ($map['nodes'] ?? [])); + $ids = array_keys($catalog->tree->nodes); + $upgradedSettings = $settings; + $added = []; + $emptied = $actor; + foreach (['abilities', 'magic'] as $book) { + foreach ((array) ($actor['data'][$book]['learned'] ?? []) as $skill) { + $added[] = ['skill' => strval($skill), 'cost' => 0]; + } + foreach ((array) ($actor['data'][$book]['learnables'] ?? []) as $learnable) { + $added[] = ['skill' => strval($learnable['skill'] ?? ''), 'cost' => max(0, intval($learnable['requirement']['experienceRequired'] ?? 0))]; + } + if (isset($actor['data'][$book])) { + $emptied['data'][$book]['learned'] = []; + $emptied['data'][$book]['learnables'] = []; + } + } + if ($emptied === $actor) { + return null; + } + $nodes = []; + $placed = []; + foreach ($added as ['skill' => $skill, 'cost' => $cost]) { + $id = $catalog->tree->findSkillNode($skill)?->id ?? $placed[$skill] ?? null; + if ($skill === '' || in_array($skill, $taught, true) || ($id !== null && in_array($id, $onMap, true))) { + continue; + } + if ($id === null) { + $id = $placed[$skill] = Slug::unique($skill, $ids, 'node'); + $ids[] = $id; + $upgradedSettings['regions'][0]['nodes'][] = ['id' => $id, 'kind' => MasteryNodeKind::APPLICATION->value, 'skill' => $skill]; + } + $onMap[] = $id; + $map['nodes'][] = ['id' => $id, 'cost' => $cost, 'opensAtLevel' => 1]; + $nodes[] = $skill; + } + + $actorSource = (string) file_get_contents($actorPath); + $watched = [$actorPath => $actorSource, $settingsPath => (string) file_get_contents($settingsPath)]; + foreach ($mapPaths as $path) { + $watched[$path] = (string) file_get_contents($path); + } + $originals = [$actorPath => $actorSource]; + $proposals = [$actorPath => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($actorSource), $actor, $emptied)->source]; + $before = [$actorPath => $actor]; + $after = [$actorPath => $emptied]; + $created = null; + if ($upgradedSettings !== $settings) { + $originals[$settingsPath] = $watched[$settingsPath]; + $proposals[$settingsPath] = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($watched[$settingsPath]), $settings, $upgradedSettings)->source; + $before[$settingsPath] = $settings; + $after[$settingsPath] = $upgradedSettings; + } + if ($nodes !== []) { + $mapPath = $mapIndex === null + ? $root . '/assets/' . MasteryCatalog::DIRECTORY . '/' . Slug::of($actorId) . '.php' + : $mapPaths[$mapIndex]; + if ($mapIndex === null && is_file($mapPath)) { + throw new RuntimeException(sprintf('%s already exists for another map.', basename($mapPath))); + } + $created = $mapIndex === null ? $mapPath : null; + $originals[$mapPath] = $mapIndex === null ? null : $watched[$mapPath]; + $proposals[$mapPath] = $mapIndex === null + ? "source; + $before[$mapPath] = $mapIndex === null ? null : $mapPayloads[$mapIndex]; + $after[$mapPath] = $map; + } + + return ['plan' => new SourceSetPlan( + $root, + $watched, + $originals, + $proposals, + $before, + $after, + // A map the plan creates is its own change, not part of the family it read. + static fn(): array => array_values(array_diff([$actorPath, $settingsPath, ...(glob($root . '/assets/' . MasteryCatalog::DIRECTORY . '/*.php') ?: [])], [$created])), + static fn(string $path, string $staged): mixed => PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + sprintf('move of %s\'s skills onto their Mastery Map', $actorId), + 'The actor, mastery tree and Mastery Map files', + ), 'nodes' => $nodes]; + }); + } +} diff --git a/src/Database/MasteryAuthoring.php b/src/Database/MasteryAuthoring.php new file mode 100644 index 00000000..d675eb65 --- /dev/null +++ b/src/Database/MasteryAuthoring.php @@ -0,0 +1,192 @@ + array_map(static fn(ProjectRecord $record): array => (array) $record->toArray(), $regions?->getRecords() ?? []), + 'branches' => array_map(static fn(ProjectRecord $record): array => (array) $record->toArray(), $branches?->getRecords() ?? []), + ]); + } + + /** + * The map as the page draws it: the regions, and each node the + * character's map gives terms for, with the tree's name, skill, region, + * branch and kind for it, its place (authored, or where the Engine would + * put it) and what it builds on. + * + * @return array{regions: list, nodes: list>, issue: ?string} + */ + public function readPage(ProjectRecordDatabase $maps, ?ProjectRecordDatabase $regions, ?ProjectRecordDatabase $branches, int $index): array + { + $record = $maps->getRecordByIndex($index) ?? throw new RecordRefusal(sprintf('%s has no record %d.', MasteryFields::MAPS, $index)); + $regionRows = array_map(static fn(ProjectRecord $region): array => ['id' => strval($region->get('id')), 'name' => strval($region->get('name'))], + $regions?->getRecords() ?? []); + $entries = $record->getSubList('nodes'); + $places = []; + $tree = null; + $issue = null; + try { + $tree = self::readTree($regions, $branches); + $places = MasteryLayout::place(MasteryMap::fromArray((array) $record->toArray(), $tree), array_column($regionRows, 'id'), + array_values(array_map(static fn(array $node): string => strval($node['id'] ?? ''), $entries))); + } catch (Throwable $error) { + // A map still being authored draws its placed nodes and says what keeps the rest from a place. + $issue = $error->getMessage(); + } + $nodes = []; + foreach ($entries as $entry => $node) { + $id = strval($node['id'] ?? ''); + $definition = $tree?->findNode($id); + $place = $places[$id] ?? (is_array($node['position'] ?? null) + ? ['x' => floatval($node['position']['x'] ?? 0.5), 'y' => floatval($node['position']['y'] ?? 0.5), 'placed' => true] + : ['x' => 0.5, 'y' => 0.5, 'placed' => false]); + $nodes[] = [ + 'entry' => $entry, + 'id' => $id, + 'name' => $definition?->label ?? $id, + 'skill' => strval($definition?->skill ?? ''), + 'region' => strval($definition?->region ?? ''), + 'branch' => $definition?->branch, + 'kind' => $definition?->kind->value, + 'cost' => intval($node['cost'] ?? 0), + 'opensAtLevel' => intval($node['opensAtLevel'] ?? 1), + 'requires' => array_values(array_map('strval', (array) ($node['requires'] ?? []))), + 'x' => $place['x'], + 'y' => $place['y'], + 'placed' => $place['placed'], + ]; + } + + return ['regions' => $regionRows, 'nodes' => $nodes, 'issue' => $issue]; + } + + /** + * Places a node on the map, as fractions (0 to 1) of its width and height. + * + * @throws RecordRefusal When the map or node is unknown, or the place is off the map. + */ + public function placeNode(ProjectRecordDatabase $maps, int $index, string $nodeId, float $x, float $y): RecordChange + { + if ($x < 0 || $x > 1 || $y < 0 || $y > 1) { + throw new RecordRefusal('A node is placed between 0 and 1 across and down the map.'); + } + + return $this->changeNodes($maps, $index, 'Move node', static function (array $nodes) use ($nodeId, $x, $y): array { + $entry = self::requireEntry($nodes, $nodeId); + $nodes[$entry]['position'] = ['x' => round($x, 3), 'y' => round($y, 3)]; + + return $nodes; + }); + } + + /** + * Makes a node build on another, or stop building on it if it already did. + * + * @throws RecordRefusal When a node is unknown, the two are one node, or the link would close a loop. + */ + public function toggleRequirement(ProjectRecordDatabase $maps, ?ProjectRecordDatabase $regions, ?ProjectRecordDatabase $branches, int $index, + string $nodeId, string $requiredId): RecordChange + { + if ($nodeId === $requiredId) { + throw new RecordRefusal('A node cannot build on itself.'); + } + try { + $tree = self::readTree($regions, $branches); + } catch (\InvalidArgumentException $error) { + throw new RecordRefusal(sprintf('The mastery tree could not be read: %s', $error->getMessage()), previous: $error); + } + + return $this->changeNodes($maps, $index, 'Link nodes', static function (array $nodes, array $record) use ($nodeId, $requiredId, $tree): array { + $entry = self::requireEntry($nodes, $nodeId); + self::requireEntry($nodes, $requiredId); + $requires = array_values(array_map('strval', (array) ($nodes[$entry]['requires'] ?? []))); + $requires = in_array($requiredId, $requires, true) + ? array_values(array_diff($requires, [$requiredId])) + : [...$requires, $requiredId]; + if ($requires === []) { + unset($nodes[$entry]['requires']); + } else { + $nodes[$entry]['requires'] = $requires; + } + // The link must leave a map the Engine reads: no loop of nodes requiring each other. + try { + MasteryMap::fromArray([...$record, 'nodes' => $nodes], $tree); + } catch (\InvalidArgumentException $error) { + if (str_contains($error->getMessage(), 'loop')) { + throw new RecordRefusal($error->getMessage(), previous: $error); + } + } + + return $nodes; + }); + } + + /** + * @param callable(list>, array): list> $change + * @throws RecordRefusal + */ + private function changeNodes(ProjectRecordDatabase $maps, int $index, string $label, callable $change): RecordChange + { + $record = $maps->getRecordByIndex($index) ?? throw new RecordRefusal(sprintf('%s has no record %d.', MasteryFields::MAPS, $index)); + $list = $maps->schema->subList ?? throw new RecordRefusal('A mastery map has no node list.'); + $before = $record->toArray(); + $nodes = $change(array_values($record->getSubList('nodes')), (array) $before); + if ($nodes === array_values($record->getSubList('nodes'))) { + return new RecordChange(null, $index); + } + $record->setEntries($list, $nodes); + $after = $record->toArray(); + try { + $maps->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $record->restorePayload($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + $label, + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + ), $index); + } + + /** @param list> $nodes */ + private static function requireEntry(array $nodes, string $nodeId): int + { + foreach ($nodes as $entry => $node) { + if (($node['id'] ?? null) === $nodeId) { + return $entry; + } + } + + throw new RecordRefusal(sprintf('This map has no node %s.', $nodeId)); + } +} diff --git a/src/Database/MasteryFields.php b/src/Database/MasteryFields.php new file mode 100644 index 00000000..63439239 --- /dev/null +++ b/src/Database/MasteryFields.php @@ -0,0 +1,231 @@ + 'new-region', 'name' => 'New Region'], + // Nodes on the region's trunk, outside any branch: ungraded applications, until an author places them on a branch. + subList: new RecordSubList( + key: 'nodes', + prefix: 'node', + singular: 'trunk node', + fields: self::getTreeNodeFields(trunk: true), + blank: ['id' => 'new-node', 'kind' => MasteryNodeKind::APPLICATION->value], + heading: 'Trunk Nodes', + removeWhenEmpty: true, + ), + projection: new KeyedListProjection('regions'), + saveCheck: self::checkTree(...), + ); + } + + public static function getBranchSchema(): RecordSchema + { + return new RecordSchema( + key: self::BRANCHES, + entryNoun: 'mastery branch', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/' . MasteryCatalog::SETTINGS, + fields: [ + new RecordField('id', 'Stable Id', isReadOnly: true), + RecordField::reference('region', 'Region', self::REGIONS), + // The branch of the same region it grows within, as a sub-family of that branch's family; none grows from the region. + new RecordField('within', 'Within Branch', reference: self::BRANCHES, removeWhenEmpty: true, allowsNone: true, displayDefault: '(the region)'), + new RecordField('name', 'Name'), + new RecordField('kind', 'Kind', options: array_column(MasteryBranchKind::cases(), 'value')), + // An element branch practises one of the project's elements. + new RecordField('element', 'Element', reference: 'elements', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + // What the branch covers; the game's Info shows it on the family and its foundation. + new RecordField('description', 'Summary', removeWhenEmpty: true), + ], + labelKey: 'name', + identityKey: 'id', + blank: ['id' => 'new-branch', 'region' => '', 'name' => 'New Branch', 'kind' => MasteryBranchKind::ASPECT->value, 'nodes' => []], + // A branch grows from a region, so a new one starts on the project's first; without one there is nothing to grow from. + makeBlank: static function (string $name, string $root): array { + $region = MasteryCatalog::load($root . '/assets')->regions[0]->id + ?? throw new RecordRefusal('Declare a Mastery region first: a branch grows from one.'); + + return ['id' => 'new-branch', 'region' => $region, 'name' => $name, 'kind' => MasteryBranchKind::ASPECT->value, 'nodes' => []]; + }, + // The branch's nodes in the order they progress: foundation, graded applications, advanced applications. + subList: new RecordSubList( + key: 'nodes', + prefix: 'node', + singular: 'node', + fields: self::getTreeNodeFields(trunk: false), + blank: ['id' => 'new-node', 'kind' => MasteryNodeKind::APPLICATION->value], + heading: 'Nodes', + ), + projection: new KeyedListProjection('branches'), + saveCheck: self::checkTree(...), + ); + } + + public static function getMapSchema(bool $graphical = false): RecordSchema + { + $nodeFields = [ + // The tree node these terms are for; the tree owns its skill, region and branch. + RecordField::reference('id', 'Node', self::NODES), + // The Mastery points (SP) this character spends to learn it; 0 is learned as soon as it opens. + new RecordField('cost', 'Cost (SP)', InputControlType::INTEGER), + new RecordField('opensAtLevel', 'Opens At Level', InputControlType::INTEGER), + // The ids of the nodes it builds on for this character, in place of its chain step on the tree. + new RecordField('requires', 'Requires', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + // Menus show ??? for it while it is locked. + RecordField::boolean('hidden', 'Secret Until Unlocked'), + ...($graphical ? [ + new RecordField('position.x', 'Map X (0 to 1)', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('position.y', 'Map Y (0 to 1)', InputControlType::FLOAT, removeWhenEmpty: true), + ] : []), + ]; + + return new RecordSchema( + key: self::MAPS, + entryNoun: 'mastery map', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/' . MasteryCatalog::DIRECTORY, + fields: [RecordField::reference('actor', 'Actor', 'actor_ids')], + labelKey: 'actor', + identityKey: 'actor', + // A map created for a character takes their id; one created without starts under a placeholder to rename. + blank: ['actor' => 'new-mastery-map', 'nodes' => []], + subList: new RecordSubList( + key: 'nodes', + prefix: 'node', + singular: 'node', + fields: $nodeFields, + blank: ['id' => '', 'cost' => 0, 'opensAtLevel' => 1], + heading: 'Own Terms', + ), + saveCheck: self::checkMap(...), + ); + } + + /** + * A tree node's fields. A trunk holds only ungraded applications, so it + * shows no grade, phase or extension. + * + * @return list + */ + private static function getTreeNodeFields(bool $trunk): array + { + return [ + new RecordField('id', 'Id'), + new RecordField('kind', 'Kind', options: $trunk ? [MasteryNodeKind::APPLICATION->value] : array_column(MasteryNodeKind::cases(), 'value')), + // A foundation may grant no skill yet; every other node teaches one. + new RecordField('skill', 'Skill', reference: 'skills', removeWhenEmpty: true, allowsNone: true, displayDefault: '(grants none yet)'), + // The name shown where no skill gives one, as for a foundation. + new RecordField('name', 'Name', removeWhenEmpty: true, displayDefault: '(the skill\'s)'), + ...($trunk ? [] : [ + // Its rung on the branch's ladder of basic applications: 1 for I. + new RecordField('grade', 'Grade', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(ungraded)'), + new RecordField('phase', 'Special Phase', options: ['', ...array_column(MasteryPhase::cases(), 'value')], removeWhenEmpty: true, displayDefault: '(none)'), + // A full phase extends its demi phase, and an evolution the advanced application it carries further, by node id. + new RecordField('extends', 'Extends', removeWhenEmpty: true, displayDefault: '(none)'), + ]), + // Its price on the tree's terms, for characters whose own map does not price it; without one, only they can learn it. + new RecordField('cost', 'Cost (SP)', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(not priced)'), + new RecordField('opensAtLevel', 'Opens At Level', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(any)'), + // Nodes it builds on besides its chain step. + new RecordField('requires', 'Also Requires', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + RecordField::boolean('hidden', 'Secret Until Unlocked'), + ]; + } + + /** + * Why the Engine could not read the mastery tree as it would be saved: + * a repeated or unknown id, a broken ladder or chain, a skill taught + * twice, or a skill the project has neither as a skill nor as a draft. + * + * @param array $whole The settings file, as it would be saved. + */ + public static function checkTree(array $whole, string $projectRoot): ?string + { + try { + $tree = MasteryTree::fromArray($whole); + } catch (InvalidArgumentException $error) { + return sprintf('The mastery tree could not be read: %s', $error->getMessage()); + } + $skills = SkillCatalog::load($projectRoot . '/assets'); + foreach ($tree->nodes as $node) { + if ($node->skill !== null && $skills->findSkill($node->skill) === null && $skills->findDraft($node->skill) === null) { + return sprintf('Mastery node %s teaches unknown skill "%s".', $node->id, $node->skill); + } + } + + return null; + } + + /** + * Why the Engine could not read a map as it would be saved: terms for a + * node the tree lacks, an unknown required node, a loop, or terms it + * cannot read. + * + * @param array $whole The map, as it would be saved. + */ + public static function checkMap(array $whole, string $projectRoot): ?string + { + try { + MasteryMap::fromArray($whole, MasteryCatalog::load($projectRoot . '/assets')->tree); + } catch (Throwable $error) { + return sprintf('The mastery map could not be read: %s', $error->getMessage()); + } + + return null; + } +} diff --git a/src/Database/MenuThemeFields.php b/src/Database/MenuThemeFields.php new file mode 100644 index 00000000..e3030980 --- /dev/null +++ b/src/Database/MenuThemeFields.php @@ -0,0 +1,90 @@ + + */ + public static function getFields(array $row): array + { + $default = array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null; + $kind = array_key_exists('default', $row) ? $row['default'] : ($row['value'] ?? null); + + $path = (string) ($row['path'] ?? ''); + return [ + new RecordField('path', 'Setting', isReadOnly: true), + match (true) { + // An icon is a project image chosen with the shared picker; none leaves the Engine's own treatment. + MenuPresentationSettings::isIcon($path) => RecordField::reference('value', 'Image', 'png_assets', allowsNone: true, + noneLabel: array_key_exists('default', $row) ? '(Engine pictogram)' : '(none)'), + ($row['path'] ?? null) === MenuPresentationSettings::ATLAS . '.frameRole' + => new RecordField('value', 'Value', options: MapAtlasTheme::FRAME_ROLES, displayDefault: $default), + is_int($kind) => new RecordField('value', self::describeRange((string) ($row['path'] ?? '')), + InputControlType::INTEGER, displayDefault: $default), + // A fraction such as the corner map's opacity: a number, checked by the Engine with its neighbours. + is_float($kind) => new RecordField('value', self::describeRange($path), InputControlType::FLOAT, displayDefault: $default), + default => new RecordField('value', 'Value', displayDefault: $default), + }, + ]; + } + + /** A number's label with the logical-pixel range the Engine accepts for it, so an author sees it before a refusal. */ + private static function describeRange(string $path): string + { + $range = match ($path) { + 'atlas.compactWidth', 'atlas.compactHeight', 'atlas.largeWidth', 'atlas.largeHeight' + => [MapAtlasTheme::MIN_EXTENT, MapAtlasTheme::MAX_EXTENT], + 'atlas.margin' => [0, MapAtlasTheme::MAX_MARGIN], + 'atlas.markerSize' => [MapAtlasTheme::MIN_MARKER_SIZE, MapAtlasTheme::MAX_MARKER_SIZE], + default => null, + }; + + return match (true) { + $range !== null => sprintf('Pixels (%d..%d)', ...$range), + // Opacity's own meaning: how much of what lies beneath shows through. + $path === 'atlas.cornerOpacity' => 'Opacity (0 clear, 1 solid)', + default => 'Value', + }; + } +} diff --git a/src/Database/MovementRouteFields.php b/src/Database/MovementRouteFields.php new file mode 100644 index 00000000..612cfcb3 --- /dev/null +++ b/src/Database/MovementRouteFields.php @@ -0,0 +1,208 @@ +commandLists[$frame[0]]) + ? $frame : [$schema->subList?->key, ...$frame]; + } + + /** Resolve the invocation's outer command list, not sibling scripts or a nested branch alone. */ + public static function getOwnerCommands(array $payload, array $path = []): array + { + if ($path === []) { + return (array) ($payload['commands'] ?? $payload['script'] ?? []); + } + $current = $payload; + foreach ($path as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { return []; } + $current = $current[$segment]; + if (in_array($segment, ['commands', 'script'], true)) { + return is_array($current) ? $current : []; + } + } + return []; + } + + /** Only executable arms may declare a route; preserved annotations are not scripts. */ + public static function getChildCommandLists(array $command): array + { + return match ($command['type'] ?? '') { + 'sequence' => [(array) ($command['commands'] ?? [])], + 'branch' => [(array) ($command['then'] ?? []), (array) ($command['else'] ?? [])], + 'choice' => [...array_map(static fn(array $option): array => (array) ($option['then'] ?? []), + array_filter((array) ($command['options'] ?? []), is_array(...))), (array) ($command['cancel'] ?? [])], + 'parallel' => array_map(static fn(array $lane): array => array_is_list($lane) ? $lane : (array) ($lane['commands'] ?? []), + array_filter((array) ($command['lanes'] ?? []), is_array(...))), + default => [], + }; + } + + public static function getMode(array $entry): ?string + { + $modes = array_values(array_intersect(self::MODES, array_keys($entry))); + // New commands retain the established Add Step workflow; no mode is stored until authored. + if ($modes === []) { return 'steps'; } + return count($modes) === 1 ? $modes[0] : null; + } + + /** @return list */ + public static function getFields(array $entry, bool $cinematic = false): array + { + $mode = self::getMode($entry); + return [ + new RecordField('subject', 'Subject', options: $cinematic ? ['player', 'npc', 'staged_actor'] : ['player', 'npc']), + new RecordField('npcId', 'NPC Id', reference: 'map_npcs', removeWhenEmpty: true, allowsNone: true), + ...($cinematic ? [new RecordField('actorId', 'Staged Actor', reference: 'cinematic_cast', removeWhenEmpty: true, allowsNone: true)] : []), + new RecordField(self::MODE_FIELD, 'Route Mode', options: self::MODES, displayDefault: $mode ?? '(Choose one route mode)'), + new RecordField('secondsPerStep', 'Seconds Per Step', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('speed', 'Steps Per Second', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('wait', 'Wait For Completion', InputControlType::BOOLEAN, ['true'], removeWhenEmpty: false), + ...($mode === 'retrace' + ? [RecordField::reference('retrace', 'Recorded Route', 'cinematic_movement_routes')] + : [new RecordField('remember', 'Remember As', removeWhenEmpty: true)]), + ]; + } + + /** A waypoint is a map cell (either axis may keep the current one) or a cell relative to a map subject. */ + public static function getPointList(array $entry, bool $cinematic = false, bool $graphical = false): ?RecordSubList + { + $axes = [ + new RecordField('x', 'X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current X)'), + new RecordField('y', 'Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(Keep current Y)'), + ]; + return match (self::getMode($entry)) { + 'steps' => RecordSchemaCatalog::routeStepList(), + 'waypoints' => new RecordSubList( + key: 'waypoints', prefix: 'waypoint', singular: 'waypoint', + fields: $axes, + blank: [], + prepareEdit: static function (array $point, string $field): array { + $point = EndFacingFields::prepareEdit($point, $field); + $validate = static fn(array $point) => self::assertPathOptions(['waypoints' => [$point]]); + if (RelativePositionFields::isRelative($point) || $field === RelativePositionFields::KIND_FIELD) { + $point = RelativePositionFields::prepareEdit($point, $field, $validate); + if (RelativePositionFields::isRelative($point)) { return $point; } + } + $validate($point); + return $point; + }, + // Arriving, the subject keeps its last step's heading unless the waypoint names an end facing. + getFields: static fn(array $point): array => [ + ...RelativePositionFields::getFields($point, $axes, $cinematic, $graphical), + ...EndFacingFields::getFields($point, $cinematic, $graphical, '(last step)'), + ], + ), + default => null, + }; + } + + public static function prepareEdit(array $entry, string $field): array + { + if (($entry['type'] ?? '') !== 'move_route') { return $entry; } + if ($field === self::MODE_FIELD) { + $mode = $entry[self::MODE_FIELD] ?? null; + if (!in_array($mode, self::MODES, true)) { + throw new InvalidArgumentException('Choose steps, waypoints or retrace.'); + } + unset($entry[self::MODE_FIELD]); + foreach (self::MODES as $other) { + if ($other !== $mode) { unset($entry[$other]); } + } + // Empty drafts ask the author for points/a reference, never invent coordinates or ids. + $entry[$mode] ??= $mode === 'retrace' ? '' : []; + if ($mode === 'retrace') { unset($entry['remember']); } + } + if (in_array($field, ['remember', 'retrace'], true) && array_key_exists($field, $entry)) { + $binding = [$field === 'retrace' ? 'retrace' : 'steps' => $field === 'retrace' ? $entry[$field] : [], + 'subject' => $entry['subject'] ?? 'player']; + if (isset($entry['remember'])) { $binding['remember'] = $entry['remember']; } + self::assertPathOptions($binding); + } + if (in_array($field, [self::MODE_FIELD, 'subject', 'remember', 'retrace'], true) + && strtolower(trim(strval($entry['subject'] ?? 'player'))) === 'staged_actor' + && (isset($entry['waypoints']) || isset($entry['remember']) || isset($entry['retrace']))) { + throw new InvalidArgumentException('Waypoints and recorded routes require a real player or NPC subject.'); + } + return $entry; + } + + /** Complete changed path options at the real owner's save boundary, not while authoring drafts. */ + public static function assertChangedCommandsValid(array $old, array $new): void + { + if ($old === $new) { return; } + $loadedPaths = array_map(self::getPathOptions(...), self::getRouteCommands($old)); + foreach (self::getRouteCommands($new) as $command) { + $unchanged = array_search(self::getPathOptions($command), $loadedPaths, true); + // Reordering unrelated commands preserves legacy paths, but adding another does not. + if ($unchanged !== false) { unset($loadedPaths[$unchanged]); continue; } + self::assertPathOptions($command); + if (array_key_exists('steps', $command)) { self::assertStepsComplete($command['steps']); } + } + } + + /** @return list */ + private static function getRouteCommands(array $payload): array + { + $commands = ($payload['type'] ?? '') === 'move_route' ? [$payload] : []; + foreach ($payload as $child) { + if (is_array($child)) { array_push($commands, ...self::getRouteCommands($child)); } + } + return $commands; + } + + private static function getPathOptions(array $command): array + { + $path = []; + foreach (['steps', 'waypoints', 'retrace', 'remember', 'subject'] as $key) { + if (array_key_exists($key, $command)) { $path[$key] = $command[$key]; } + } + return $path; + } + + /** The Engine extension validator handles waypoints/retrace, not cardinal draft completeness. */ + private static function assertStepsComplete(mixed $steps): void + { + if (!is_array($steps) || $steps === []) { + throw new InvalidArgumentException('A movement route requires at least one step.'); + } + foreach ($steps as $step) { + if (!is_array($step) || !is_string($step['direction'] ?? null) + || !in_array(strtolower(trim($step['direction'])), MovementRouteRunner::DIRECTIONS, true)) { + throw new InvalidArgumentException('Each movement-route step requires a supported direction.'); + } + $count = $step['count'] ?? 1; + if (!is_numeric($count) || floatval($count) !== floatval(intval($count)) || intval($count) < 0) { + throw new InvalidArgumentException('Movement-route step count must be a non-negative integer.'); + } + if (array_key_exists('faceOnly', $step) && !is_bool($step['faceOnly'])) { + throw new InvalidArgumentException('Movement-route step faceOnly must be a boolean.'); + } + } + } + + private static function assertPathOptions(array $entry): void + { + try { + MovementRouteRunner::validatePathOptions($entry); + } catch (MovementRouteException $error) { + throw new InvalidArgumentException($error->getMessage(), previous: $error); + } + } +} diff --git a/src/Database/OwnsFileKeys.php b/src/Database/OwnsFileKeys.php new file mode 100644 index 00000000..bdc1a45a --- /dev/null +++ b/src/Database/OwnsFileKeys.php @@ -0,0 +1,20 @@ + + */ + public function listOwnedKeys(): array; +} diff --git a/src/Database/ParameterMapCodec.php b/src/Database/ParameterMapCodec.php index d03a4d38..b94f9500 100644 --- a/src/Database/ParameterMapCodec.php +++ b/src/Database/ParameterMapCodec.php @@ -133,7 +133,9 @@ public static function decode(string $line): array /** * Returns a map with authored scalars replaced and everything the line - * cannot carry kept exactly as it was. + * cannot carry kept exactly as it was, where it was: a hidden value stays + * after the parameter it followed, so a line read back unchanged leaves + * the map as stored. * * @param array $existing The parameters as stored. * @param array $authored The parameters read off the line. @@ -141,14 +143,29 @@ public static function decode(string $line): array */ public static function merge(array $existing, array $authored): array { - $merged = $authored; + $leading = []; + $hiddenAfter = []; + $anchor = null; foreach ($existing as $name => $value) { - if (! is_scalar($value) && ! array_key_exists($name, $authored)) { - $merged[$name] = $value; + if (array_key_exists($name, $authored)) { + $anchor = $name; + } elseif (is_scalar($value)) { + continue; + } elseif ($anchor === null) { + $leading[$name] = $value; + } else { + $hiddenAfter[$anchor][$name] = $value; } } + $merged = $leading; + + foreach ($authored as $name => $value) { + $merged[$name] = $value; + $merged += $hiddenAfter[$name] ?? []; + } + return $merged; } diff --git a/src/Database/PhpDataFile.php b/src/Database/PhpDataFile.php index dfd1f36e..a5c819d3 100644 --- a/src/Database/PhpDataFile.php +++ b/src/Database/PhpDataFile.php @@ -4,26 +4,36 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; +use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\IO\AtomicFile; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Editor\Storage\FileSetTransaction; use RuntimeException; use Throwable; /** * One authored PHP data file, loaded with its source header preserved. * - * The editor's contract with an author's file is deliberately narrow: it - * regenerates **only the returned expression**. Everything from ` $class, ...array_map(self::getComparableValue(...), $fields)]; + } + return is_array($value) ? array_map(self::getComparableValue(...), $value) : $value; + } + /** * A stable comparison value that includes an object's class and state. * @@ -155,6 +185,13 @@ private static function evaluateIsolatedPaths(array $paths, ?string $workingDire $autoload = $localAutoload; } + // Child evaluation must use the same source roots as this process, + // including a development Engine selected by the test bootstrap. + $prefixes = []; + foreach (\Composer\Autoload\ClassLoader::getRegisteredLoaders() as $loader) { + $prefixes = array_replace($prefixes, $loader->getPrefixesPsr4()); + } + $runner = <<<'PHP' $directories) { + $loader->setPsr4($prefix, $directories); + } } if ($workingDirectory !== '' && is_dir($workingDirectory)) { @@ -247,7 +288,7 @@ private static function evaluateIsolatedPaths(array $paths, ?string $workingDire $runner = str_replace('/*__ICHILOTO_AUTHORED_REQUIRES__*/', $authoredRequires, $runner); $pipes = []; $resultMarker = 'ICHILOTO_EVAL_RESULT:' . bin2hex(random_bytes(16)) . ':'; - $arguments = [PHP_BINARY, '-r', substr($runner, strlen("entryClasses(), static fn(string $class): bool => trim($class) !== '') !== []; + } + + /** Enum cases use the same source-preserving literal path as other data. */ + private static function holdsNonEnumObject(mixed $value): bool + { + if (is_object($value)) { + return ! $value instanceof \UnitEnum; + } + + return is_array($value) && array_any($value, self::holdsNonEnumObject(...)); + } + + /** The file as an editable array literal, or null when it is not one. */ + private static function parseArraySource(string $source): ?PhpArraySourceDocument + { + try { + return PhpArraySourceDocument::parse($source); + } catch (SourceUnreadable) { + return null; + } } /** @@ -485,6 +559,52 @@ public function isEditable(): bool * @return void */ public function save(mixed $payload): void + { + $contents = $this->composeContents($payload, checkDisk: true); + $directory = dirname($this->path); + + if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new RuntimeException("Unable to create {$directory}."); + } + + AtomicFile::write($this->path, $contents); + $this->adoptWritten($payload, $contents); + } + + /** + * Adopts what a save wrote: the file now reads as this payload, so the + * next save edits from here. + */ + public function adoptWritten(mixed $payload, string $contents): void + { + $this->payload = $payload; + $this->source = $contents; + } + + /** + * Requires the file to hold what it was read as when a transaction + * installs a save of it, or to be absent when it was. + */ + public function expectUnchanged(FileSetTransaction $transaction): void + { + if ($this->source !== null) { + $transaction->expectSource($this->path, $this->source); + } elseif (! $this->exists) { + $transaction->expectSource($this->path, null); + } + } + + /** + * Returns what saving a value would write, refusing what the file's own + * source cannot take, without writing anything: an edit can be refused + * when it is made rather than when it is saved. + * + * @param mixed $payload The value to return from the file. + * @param bool $checkDisk Whether a file changed outside the editor is refused too, as a save does. + * @return string The file's contents. + * @throws RuntimeException When the file or the value cannot be written. + */ + public function composeContents(mixed $payload, bool $checkDisk = false): string { if (! $this->isEditable()) { throw new RuntimeException(sprintf('Refusing to overwrite %s: %s.', $this->path, $this->readOnlyReason)); @@ -496,16 +616,29 @@ public function save(mixed $payload): void throw new RuntimeException(sprintf('Refusing to write %s: %s.', $this->path, $reason)); } - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); + $document = $this->source === null ? null : self::parseArraySource($this->source); + + // Data, including enum cases, is edited in its own source; objects keep the regeneration + // (or the constructor-argument edits) their files were written for. + if ($document !== null && is_array($this->payload) && is_array($payload) + && ! self::holdsNonEnumObject($this->payload) && ! self::holdsNonEnumObject($payload)) { + if ($checkDisk && @file_get_contents($this->path) !== $this->source) { + throw new RuntimeException(sprintf( + 'Refusing to overwrite %s: it changed outside the editor since it was read. Reload it first.', + $this->path, + )); + } + $contents = ArraySourceWriter::rewrite($document, $this->payload, $payload)->source; + } elseif ($this->hasInteriorComment) { + throw new RuntimeException(sprintf( + 'Refusing to rewrite %s: it has comments inside its data, which rewriting it would drop. Edit the entries one value at a time, or edit the file directly.', + $this->path, + )); + } else { + $contents = $this->header . 'return ' . PhpValueExporter::export($payload) . ";\n"; } - AtomicFile::write( - $this->path, - $this->header . 'return ' . PhpValueExporter::export($payload) . ";\n", - ); + return $contents; } /** diff --git a/src/Database/PhpSourceDocument.php b/src/Database/PhpSourceDocument.php index 471fed31..87eedbb9 100644 --- a/src/Database/PhpSourceDocument.php +++ b/src/Database/PhpSourceDocument.php @@ -187,6 +187,72 @@ public function withArgument(int $entryIndex, string $path, string $literal): se return self::parse($this->insertedArgument($entryIndex, $path, $literal)); } + /** + * Edits a constructor with an explicitly known signature, including positional arguments. + * Unknown calls and argument unpacking are refused rather than assigned guessed positions. + * + * @param list $parameters Constructor parameter names in declaration order. + */ + public function getWithConstructorArgument(int $entryIndex, string $name, string $literal, array $parameters): self + { + return $this->getMappedConstructor($entryIndex, $name, $parameters)->withArgument($entryIndex, $name, $literal); + } + + /** Reads a named or positional argument using a known constructor signature. @param list $parameters */ + public function getConstructorArgumentSource(int $entryIndex, string $name, array $parameters): ?string + { + return $this->getMappedConstructor($entryIndex, $name, $parameters)->argumentSource($entryIndex, $name); + } + + /** Removes an optional argument without shifting any later positional arguments. @param list $parameters */ + public function getWithoutConstructorArgument(int $entryIndex, string $name, array $parameters): self + { + $document = $this->getMappedConstructor($entryIndex, $name, $parameters); + $entry = $document->entries[$entryIndex]; + $removed = $entry['arguments'][$name] ?? null; + if ($removed === null) { return $this; } + $source = $this->source; + foreach (array_reverse($entry['arguments'], true) as $parameter => $span) { + if ($span['start'] <= $removed['start'] || ($span['nameStart'] ?? null) !== $span['start']) { continue; } + $source = substr($source, 0, $span['start']) . $parameter . ': ' . substr($source, $span['start']); + } + return self::parse($source)->getMappedConstructor($entryIndex, $name, $parameters)->withoutArgument($entryIndex, $name); + } + + /** @param list $parameters */ + private function getMappedConstructor(int $entryIndex, string $name, array $parameters): self + { + if (! $this->isConstructorEntry($entryIndex) || ! in_array($name, $parameters, true)) { + throw new RuntimeException('Cannot edit an unknown constructor or parameter.'); + } + + $entries = $this->entries; + $entry = $entries[$entryIndex]; + $tokens = self::tokenize($this->source); + $open = $close = null; + foreach ($tokens as $index => $token) { + if ($token['offset'] === $entry['open']) { $open = $index; } + if ($token['offset'] === $entry['close']) { $close = $index; } + } + if ($open === null || $close === null) { + throw new RuntimeException('Cannot locate the authored constructor.'); + } + + $arguments = self::readArguments($tokens, $open, $close); + foreach ($arguments['positions'] as $position => $span) { + $parameter = $parameters[$position] ?? null; + if ($parameter === null || isset($arguments['named'][$parameter]) || $span['unpacked']) { + throw new RuntimeException('Cannot safely map positional constructor arguments.'); + } + unset($span['unpacked']); + $entry['arguments'][$parameter] = $span; + } + $entry['positional'] = false; + $entries[$entryIndex] = $entry; + + return new self($this->source, $entries, $this->arrayClose); + } + /** * @var string The literal an insertion is writing, while it is writing it. */ @@ -212,6 +278,21 @@ public function withoutArgument(int $entryIndex, string $path): self $end = $span['end']; $source = $this->source; + // A comment may separate the value from its comma. Keep the comment, + // but remove the separator as well as the argument it belonged to. + $hasComment = false; + foreach (self::tokenize($source) as $token) { + if ($token['offset'] < $end) { continue; } + if (in_array($token['id'], [T_COMMENT, T_DOC_COMMENT], true)) { $hasComment = true; } + if (self::isSkippable($token)) { continue; } + if ($hasComment && $token['text'] === ',') { + return self::parse(substr($source, 0, $start) + . substr($source, $end, $token['offset'] - $end) + . substr($source, $token['offset'] + 1)); + } + break; + } + // Take the separator and the blank line the argument occupied with // it, so removing one never leaves a stray comma or an empty line. while ($end < strlen($source) && ($source[$end] === ' ' || $source[$end] === "\t")) { @@ -268,18 +349,49 @@ public function withNewEntry(string $class, array $arguments, ?int $before = nul $lines[] = sprintf('%s%s: %s,', $inner, $name, $literal); } - $lines[] = $indent . '),'; - $block = implode("\n", $lines) . "\n"; + $lines[] = $indent . ')'; + + return $this->getWithEntrySource(ltrim(implode("\n", $lines)), $before); + } + + /** Inserts a previously preserved or newly authored constructor without re-exporting its values. */ + public function getWithEntrySource(string $source, ?int $before = null): self + { + return $this->getWithEntryBlockSource($this->entryIndent() . $source . ",\n", $before); + } + + /** Inserts a preserved entry together with its leading and same-line trailing comments. */ + public function getWithEntryBlockSource(string $block, ?int $before = null): self + { + if ($this->arrayClose === null) { + throw new RuntimeException('This file does not return an array to add an entry to.'); + } + $candidate = self::parse("entryCount() !== 1 || ! $candidate->isConstructorEntry(0)) { + throw new RuntimeException('Only a complete constructor entry can be inserted.'); + } + $entry = $candidate->entries[0]; + $hasComma = false; + foreach (self::tokenize('source, $entry['close'] + 1)) as $token) { + if (self::isSkippable($token) || $token['id'] === T_OPEN_TAG) { continue; } + $hasComma = $token['text'] === ','; + break; + } + if (! $hasComma) { + $offset = $entry['close'] + 1 - strlen("isConstructorEntry($before)) { + if (! isset($this->entries[$before])) { throw new RuntimeException(sprintf( 'Cannot place an entry before %s: it is not a constructor call this editor can position against.', $this->describeEntry($before), )); } - $start = $this->entries[$before]['start']; + $start = $this->getEntryBounds($before)['start']; $lineBreak = strrpos(substr($this->source, 0, $start), "\n"); $lineStart = $lineBreak === false ? 0 : $lineBreak + 1; @@ -292,17 +404,26 @@ public function withNewEntry(string $class, array $arguments, ?int $before = nul // The neighbour shares a line with something before it. The new // entry still goes ahead of it, and the neighbour continues on a // fresh line at the entries' indentation. - return self::parse(substr($this->source, 0, $start) . ltrim($block) . $indent . substr($this->source, $start)); + return self::parse(substr($this->source, 0, $start) . ltrim($block) . $this->entryIndent() . substr($this->source, $start)); } $beforeClose = substr($this->source, 0, $this->arrayClose); + if ($this->entries !== []) { + foreach (array_reverse(self::tokenize($beforeClose)) as $token) { + if (self::isSkippable($token)) { continue; } + if ($token['text'] !== ',') { + $offset = $token['offset'] + strlen($token['text']); + $beforeClose = substr($beforeClose, 0, $offset) . ',' . substr($beforeClose, $offset); + } + break; + } + } $after = substr($this->source, $this->arrayClose); $trimmed = rtrim($beforeClose); - $needsComma = ! str_ends_with($trimmed, ',') && ! str_ends_with($trimmed, '['); $closingIndent = substr($beforeClose, strrpos($beforeClose, "\n") === false ? 0 : strrpos($beforeClose, "\n") + 1); return self::parse( - $trimmed . ($needsComma ? ',' : '') . "\n" . $block . (trim($closingIndent) === '' ? $closingIndent : '') . $after, + $trimmed . "\n" . $block . (trim($closingIndent) === '' ? $closingIndent : '') . $after, ); } @@ -328,28 +449,48 @@ public function withoutEntry(int $entryIndex): self )); } - $entry = $this->entries[$entryIndex]; - $start = $entry['start']; - $end = $entry['close'] + 1; - $source = $this->source; + $bounds = $this->getEntryBounds($entryIndex); + return self::parse(substr($this->source, 0, $bounds['start']) . substr($this->source, $bounds['end'])); + } - while ($end < strlen($source) && ($source[$end] === ' ' || $source[$end] === "\t")) { - $end++; - } + public function getEntryBlockSource(int $entryIndex): ?string + { + if (! $this->isConstructorEntry($entryIndex)) { return null; } + $bounds = $this->getEntryBounds($entryIndex); + return substr($this->source, $bounds['start'], $bounds['end'] - $bounds['start']); + } - if ($end < strlen($source) && $source[$end] === ',') { - $end++; + /** @return array{start: int, end: int} */ + private function getEntryBounds(int $entryIndex): array + { + $entry = $this->entries[$entryIndex]; + $tokens = self::tokenize($this->source); + $start = $entry['start']; + foreach (array_reverse($tokens) as $token) { + if ($token['offset'] >= $entry['start']) { continue; } + if ($token['id'] === T_WHITESPACE) { continue; } + if (! in_array($token['id'], [T_COMMENT, T_DOC_COMMENT], true)) { break; } + $lineStart = strrpos(substr($this->source, 0, $token['offset']), "\n"); + $lineStart = $lineStart === false ? 0 : $lineStart + 1; + if (trim(substr($this->source, $lineStart, $token['offset'] - $lineStart)) !== '') { break; } + $start = $token['offset']; } + $lineStart = strrpos(substr($this->source, 0, $start), "\n"); + $lineStart = $lineStart === false ? 0 : $lineStart + 1; + if (trim(substr($this->source, $lineStart, $start - $lineStart)) === '') { $start = $lineStart; } - if ($end < strlen($source) && $source[$end] === "\n") { - $end++; - - while ($start > 0 && ($source[$start - 1] === ' ' || $source[$start - 1] === "\t")) { - $start--; - } + $end = $entry['close'] + 1; + foreach ($tokens as $token) { + if ($token['offset'] < $end) { continue; } + if ($token['id'] === T_WHITESPACE) { + $newline = strpos($token['text'], "\n"); + $end = $token['offset'] + ($newline === false ? strlen($token['text']) : $newline + 1); + if ($newline !== false) { break; } + } elseif ($token['text'] === ',' || in_array($token['id'], [T_COMMENT, T_DOC_COMMENT], true)) { + $end = $token['offset'] + strlen($token['text']); + } else { break; } } - - return self::parse(substr($source, 0, $start) . substr($source, $end)); + return ['start' => $start, 'end' => $end]; } /** @@ -424,6 +565,16 @@ private function insertedArgument(int $entryIndex, string $path, string $literal $indent = $this->argumentIndent($entry); $before = substr($this->source, 0, $close); $after = substr($this->source, $close); + if ($entry['arguments'] !== []) { + $lastEnd = max(array_column($entry['arguments'], 'end')); + $hasComma = false; + foreach (self::tokenize('source, $lastEnd, $close - $lastEnd)) as $token) { + if ($token['text'] === ',') { $hasComma = true; } + } + if (! $hasComma) { + $before = substr($before, 0, $lastEnd) . ',' . substr($before, $lastEnd); + } + } $trimmed = rtrim($before); $isMultiline = str_contains(substr($this->source, $entry['open'], $close - $entry['open']), "\n"); @@ -433,13 +584,10 @@ private function insertedArgument(int $entryIndex, string $path, string $literal } if (! $isMultiline) { - return $trimmed . sprintf(', %s: %s', $name, $literal) . $after; + return $trimmed . sprintf(' %s: %s', $name, $literal) . $after; } - $needsComma = ! str_ends_with($trimmed, ',') && ! str_ends_with($trimmed, '('); - return $trimmed - . ($needsComma ? ',' : '') . "\n" . $indent . sprintf('%s: %s,', $name, $literal) . "\n" . $this->closingIndent($entry) . $after; @@ -776,12 +924,13 @@ private static function readEntry(array $tokens, int $position, int $limit): ?ar * @param array $tokens The tokens. * @param int $open The opening parenthesis token index. * @param int $close The closing parenthesis token index. - * @return array{named: array, positional: bool} + * @return array{named: array, positional: bool, positions: list} */ private static function readArguments(array $tokens, int $open, int $close): array { $named = []; $positional = false; + $positions = []; $index = $open + 1; while ($index < $close) { @@ -826,12 +975,19 @@ private static function readArguments(array $tokens, int $open, int $close): arr 'end' => self::trimmedEnd($tokens, $index, $end, $valueEnd), 'nameStart' => $tokens[$nameIndex]['offset'], ]; + } else { + $positions[] = [ + 'start' => $valueStart, + 'end' => self::trimmedEnd($tokens, $index, $end, $valueEnd), + 'nameStart' => $tokens[$nameIndex]['offset'], + 'unpacked' => $tokens[$index]['id'] === T_ELLIPSIS, + ]; } $index = $end; } - return ['named' => $named, 'positional' => $positional]; + return ['named' => $named, 'positional' => $positional, 'positions' => $positions]; } /** diff --git a/src/Database/PhpValueExporter.php b/src/Database/PhpValueExporter.php index cfa9c83b..bcff08f1 100644 --- a/src/Database/PhpValueExporter.php +++ b/src/Database/PhpValueExporter.php @@ -191,6 +191,12 @@ public static function constructorArguments(object $value): ?array $reflection = new ReflectionClass($value); $constructor = $reflection->getConstructor(); + // Writing 'new' needs a constructor anyone may call: a class built only through its own factories, such as + // one with a private constructor, would be written back as a file that cannot load. + if (! $reflection->isInstantiable()) { + return null; + } + if ($constructor === null) { // Nothing to rebuild it from. An object carrying state with no // constructor to put it back would be exported as an empty shell, diff --git a/src/Database/ProjectCatalogs.php b/src/Database/ProjectCatalogs.php new file mode 100644 index 00000000..a7553860 --- /dev/null +++ b/src/Database/ProjectCatalogs.php @@ -0,0 +1,538 @@ +workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'assets'; + } + + /** + * A data file as the workspace would write it, or as it stands on disk + * when nothing unsaved holds it. A file that does not exist reads as + * absent. + * + * @param string $assetPath The data file, relative to the asset root. + * @return array{exists: bool, payload: mixed} + * @throws Throwable When the file on disk cannot be read. + */ + public function readData(string $assetPath): array + { + $current = $this->readCurrent($assetPath); + if ($current !== null) { + return $current; + } + $path = $this->getAssetRoot() . DIRECTORY_SEPARATOR . $assetPath; + if (! is_file($path)) { + return ['exists' => false, 'payload' => null]; + } + + return ['exists' => true, 'payload' => ProjectDirectoryContext::run($this->workspace->projectRoot, + static fn(): mixed => (static fn(string $file): mixed => require $file)($path))]; + } + + /** + * A tileset as the Tilesets page holds it. + * + * @throws InvalidArgumentException With the Engine's reason when it is missing or refused. + */ + public function loadTileset(string $id): Tileset + { + $current = $this->readCurrent(Tileset::DIRECTORY . '/' . $id . '.php'); + if ($current === null) { + return Tileset::load($this->getAssetRoot(), $id); + } + if (! $current['exists']) { + throw new InvalidArgumentException("Tileset {$id} was not found at " . Tileset::DIRECTORY . "/{$id}.php."); + } + + return Tileset::fromArray($id, $current['payload']); + } + + /** + * The id of every tileset the project holds: the Tilesets page's records, + * unsaved ones included and deleted ones left out, or the files when no + * page is loaded. + * + * @return list + */ + public function listTilesetIds(): array + { + $database = $this->workspace->getRecordDatabase('tilesets'); + if ($database !== null) { + return array_values(array_map(static fn(ProjectRecord $record): string => $record->recordId, $database->getRecords())); + } + + return array_map(static fn(string $file): string => basename($file, '.php'), + glob($this->getAssetRoot() . DIRECTORY_SEPARATOR . Tileset::DIRECTORY . DIRECTORY_SEPARATOR . '*.php') ?: []); + } + + /** + * The field presentation (`Data/Presentation/field.php`) as the Field + * Resources page holds it. + * + * @throws InvalidArgumentException With the Engine's reason when it is refused. + */ + public function loadFieldPresentation(): FieldPresentationCatalog + { + $assets = $this->getAssetRoot(); + $current = $this->readCurrent(FieldPresentationCatalog::FILE); + + return ProjectDirectoryContext::run($this->workspace->projectRoot, static fn(): FieldPresentationCatalog => $current === null + ? FieldPresentationCatalog::load($assets) + : FieldPresentationCatalog::fromArray(is_array($current['payload']) ? $current['payload'] : [], $assets)); + } + + /** + * The place categories as the Place Categories page holds them. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadMapCategories(): MapCategoryCatalog + { + $current = $this->readCurrent(MapCategoryCatalog::FILE); + + return $current === null ? MapCategoryCatalog::load($this->getAssetRoot()) : MapCategoryCatalog::fromArray($current['payload']); + } + + /** + * The map roles as the Map Roles page holds them. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadMapRoles(): \Ichiloto\Engine\Field\MapRoleCatalog + { + $current = $this->readCurrent(\Ichiloto\Engine\Field\MapRoleCatalog::FILE); + + return $current === null ? \Ichiloto\Engine\Field\MapRoleCatalog::load($this->getAssetRoot()) + : \Ichiloto\Engine\Field\MapRoleCatalog::fromArray($current['payload']); + } + + /** + * The place types as the Place Types page holds them. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadPlaceTypes(): \Ichiloto\Engine\Field\PlaceTypeCatalog + { + $current = $this->readCurrent(\Ichiloto\Engine\Field\PlaceTypeCatalog::FILE); + + return $current === null ? \Ichiloto\Engine\Field\PlaceTypeCatalog::load($this->getAssetRoot()) + : \Ichiloto\Engine\Field\PlaceTypeCatalog::fromArray($current['payload']); + } + + /** + * The regions as the Regions page holds them. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadRegions(): \Ichiloto\Engine\Field\RegionCatalog + { + $current = $this->readCurrent(\Ichiloto\Engine\Field\RegionCatalog::FILE); + + return match (true) { + $current === null => \Ichiloto\Engine\Field\RegionCatalog::load($this->getAssetRoot()), + ! $current['exists'] => new \Ichiloto\Engine\Field\RegionCatalog(), + default => \Ichiloto\Engine\Field\RegionCatalog::fromArray($current['payload']), + }; + } + + /** + * The text roles (`Data/Presentation/text.php`) as the Text Roles page + * holds them; null when the project declares none. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadTextCatalog(): ?ProjectTextCatalog + { + $current = $this->readCurrent(ProjectTextCatalog::FILE); + if ($current === null) { + return ProjectTextCatalog::load($this->getAssetRoot()); + } + + return $current['exists'] ? ProjectTextCatalog::fromArray($current['payload']) : null; + } + + /** + * The dialogue catalogue (`Data/Presentation/dialogue.php`) as the + * Dialogue Portraits and Speaker Names pages hold it, with the keys + * neither page owns (theme, skit stage) as the file has them. + * + * @throws InvalidArgumentException|\RuntimeException When the Engine would refuse the catalogue. + */ + public function loadDialoguePresentation(): DialoguePresentationCatalog + { + $current = $this->readCurrent(DialoguePresentationCatalog::FILE); + if ($current === null) { + return DialoguePresentationCatalog::load($this->getAssetRoot()); + } + + return DialogueCatalogFile::createCatalog(is_array($current['payload']) ? $current['payload'] : [], $this->getAssetRoot()); + } + + /** The permanent-growth definitions as the Permanent Growth page holds them. */ + public function loadPermanentGrowth(): PermanentGrowthCatalog + { + $current = $this->workspace->readCurrentData(rtrim($this->workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . PermanentGrowthCatalog::RELATIVE_PATH); + if ($current === null) { + return PermanentGrowthCatalog::fromProject($this->workspace->projectRoot); + } + + return PermanentGrowthCatalog::fromPayload(is_array($current['payload']) ? $current['payload'] : []); + } + + /** + * The knowledge catalogue's declarations as the Knowledge pages hold + * them; empty when the project declares none. + * + * @return array + * @throws Throwable When the file on disk cannot be read. + */ + public function readKnowledgeData(): array + { + $payload = $this->readData(self::KNOWLEDGE_FILE)['payload']; + + return is_array($payload) ? $payload : []; + } + + /** + * The knowledge catalogue the Engine makes of {@see readKnowledgeData()}. + * + * @throws Throwable When the Engine refuses it or the file cannot be read. + */ + public function loadKnowledgeCatalog(): KnowledgeCatalog + { + return new KnowledgeCatalog($this->readKnowledgeData()); + } + + /** + * The battler bindings (`Data/Presentation/battlers.php`) as the battle + * art pages hold them; null when the project declares none. + * + * @throws Throwable With the Engine's reason when they are refused. + */ + public function loadBattlerBindings(): ?BattlerBindings + { + $current = $this->readCurrent(BattlerBindings::FILE); + if ($current === null) { + return BattlerBindings::load($this->getAssetRoot()); + } + if (! $current['exists']) { + return null; + } + + return BattlerBindings::getFromArray(is_array($current['payload']) ? $current['payload'] : [], $this->getAssetRoot()); + } + + /** + * The battle arenas (`Data/Presentation/arenas.php`) as the Battle Arenas + * page holds them; null when the project declares none. + * + * @throws Throwable With the Engine's reason when they are refused. + */ + public function loadArenaBindings(): ?BattleArenaBindings + { + $current = $this->readCurrent(BattleArenaBindings::FILE); + if ($current === null) { + return BattleArenaBindings::load($this->getAssetRoot()); + } + if (! $current['exists']) { + return null; + } + + return BattleArenaBindings::getFromArray(is_array($current['payload']) ? $current['payload'] : []); + } + + /** + * The battle presentation: the code-owned catalogue + * (`Data/Presentation/battle.php`, which no page edits) with the current + * battler bindings and arenas bound to it. Null when the project has none. + * + * @throws Throwable With the Engine's reason when it is refused. + */ + public function loadBattlePresentation(): ?BattlePresentationCatalog + { + $catalog = BattlePresentationCatalog::loadCode($this->getAssetRoot()); + if ($catalog === null) { + return null; + } + $bindings = $this->loadBattlerBindings(); + $catalog = $bindings === null ? $catalog : $catalog->bindBattlers($bindings); + $arenas = $this->loadArenaBindings(); + + return $arenas === null ? $catalog : $catalog->bindArenas($arenas); + } + + /** The player's presentation (`Data/Entities/player.php`) as the Player page holds it. */ + public function loadPlayerPresentation(): PlayerPresentationConfig + { + $current = $this->readCurrent(self::PLAYER_PRESENTATION_FILE); + if ($current === null) { + return ProjectDirectoryContext::run($this->workspace->projectRoot, static fn(): PlayerPresentationConfig => PlayerPresentationConfig::load()); + } + + return PlayerPresentationConfig::fromArray(is_array($current['payload']) ? $current['payload'] : []); + } + + /** + * The skill catalogue as the Skills page and the summons hold it: when + * either holds an unsaved edit, built by the Engine from the skill + * records as they stand (in file order, a record that cannot be read + * reported against its file) and the current summons; otherwise read + * by the Engine from disk. + */ + public function loadSkillCatalog(): SkillCatalog + { + $database = $this->workspace->getRecordDatabase('skills'); + if (($database === null || ! $database->isDirty()) && ! $this->holdsUnsaved(CutsceneType::SUMMON)) { + return SkillCatalog::load($this->getAssetRoot()); + } + if ($database === null) { + $disk = SkillCatalog::load($this->getAssetRoot()); + $skills = []; + $drafts = []; + foreach ($disk->getSkills() as $name => $skill) { + $skills[$disk->getSourceFile((string) $name) ?? (string) $name] = $skill; + } + foreach ($disk->getDrafts() as $name => $draft) { + $drafts[$disk->getSourceFile((string) $name) ?? (string) $name] = $draft; + } + + return SkillCatalog::fromSkills($skills, $disk->getProblems(), $this->loadSummons(), $drafts); + } + + // A record still awaiting its author's values reads as a draft, as the Engine reads it. + [$records, $problems] = self::readRecordFiles($database, SkillCatalog::readRecordData(...)); + $drafts = array_filter($records, static fn(mixed $record): bool => $record instanceof SkillDraft); + + return SkillCatalog::fromSkills(array_diff_key($records, $drafts), $problems, $this->loadSummons(), $drafts); + } + + /** + * What the Engine's inventory catalogue would report against the item, + * weapon and armor record files as their pages hold them: each file it + * cannot read, by its file under `assets/Data`, in its order. + * + * @return list + */ + public function listItemRecordProblems(): array + { + $databases = array_map($this->workspace->getRecordDatabase(...), InventoryCatalog::CATEGORIES); + $disk = null; + $problems = []; + foreach ($databases as $index => $database) { + if ($database !== null && $database->isDirty()) { + $problems = [...$problems, ...self::readRecordFiles($database, ItemRecord::readItem(...))[1]]; + continue; + } + $disk ??= ItemCatalog::load($this->getAssetRoot())->getProblems(); + $directory = ItemCatalog::DIRECTORIES[$index] . '/'; + $problems = [...$problems, ...array_values(array_filter($disk, static fn(string $problem): bool => str_starts_with($problem, $directory)))]; + } + + return $problems; + } + + /** + * The summons as the Cutscenes workspace holds them, by name, a summon + * the Engine refuses left out as its loader leaves it out. + * + * @return list + */ + public function loadSummons(): array + { + $cutscenes = $this->workspace->cutscenes; + if ($cutscenes === null || ! $this->holdsUnsaved(CutsceneType::SUMMON)) { + return new SummonCutsceneLibrary($this->getAssetRoot() . DIRECTORY_SEPARATOR . 'Cutscenes' . DIRECTORY_SEPARATOR . 'Summons')->load(); + } + $definitions = []; + foreach ($cutscenes->assets(CutsceneType::SUMMON) as $asset) { + if ($asset->isDeleted()) { + continue; + } + try { + $definitions[] = $asset->summonDefinition(); + } catch (Throwable) { + continue; + } + } + usort($definitions, static fn(SummonCutsceneDefinition $left, SummonCutsceneDefinition $right): int => $left->name <=> $right->name); + + return $definitions; + } + + /** + * The data file of every summon as the Cutscenes workspace holds them, + * deleted ones left out and new ones included, in path order. + * + * @return list Absolute paths. + */ + public function listSummonDataPaths(): array + { + $paths = []; + foreach (glob($this->getAssetRoot() . '/Cutscenes/Summons/*/*.data.php') ?: [] as $path) { + $paths[ProjectRecordDatabase::canonicalizePath($path)] = $path; + } + foreach ($this->workspace->cutscenes?->assets(CutsceneType::SUMMON) ?? [] as $asset) { + $path = ProjectRecordDatabase::canonicalizePath($asset->dataPath()); + if ($asset->isDeleted()) { + unset($paths[$path]); + } else { + $paths[$path] ??= $asset->dataPath(); + } + } + $paths = array_values($paths); + sort($paths, SORT_STRING); + + return $paths; + } + + /** + * The id of every effect timeline as the Cutscenes workspace holds them, + * deleted ones left out. + * + * @return list + */ + public function listEffectTimelineIds(): array + { + $cutscenes = $this->workspace->cutscenes; + if ($cutscenes === null) { + return new EffectTimelineLibrary($this->getAssetRoot())->findTimelineIds(); + } + $ids = $cutscenes->ids(CutsceneType::EFFECT); + sort($ids, SORT_STRING); + + return array_values($ids); + } + + /** + * An effect timeline as the Cutscenes workspace holds it, compiled by the + * Engine as battle, the field or an owned stage plays it. + * + * @throws InvalidArgumentException With the Engine's reason when it is missing or refused. + */ + public function loadEffectTimeline(string $id, bool $forBattle = false, + EffectPresentation $presentation = EffectPresentation::GRAPHICAL, bool $forStage = false): CompiledEffectTimeline + { + $library = new EffectTimelineLibrary($this->getAssetRoot()); + EffectTimelineLibrary::assertId($id); + $current = $this->readCurrent(EffectTimelineLibrary::DIRECTORY . "/{$id}/{$id}.timeline.php"); + + return ProjectDirectoryContext::run($this->workspace->projectRoot, static function () use ($library, $id, $forBattle, $presentation, $forStage, $current): CompiledEffectTimeline { + if ($current === null) { + return $library->load($id, $forBattle, $presentation, $forStage); + } + if (! $current['exists']) { + throw new InvalidArgumentException("Effect timeline {$id} was not found inside the asset root."); + } + + return $library->compile($id, $current['payload'], $forBattle, $presentation, $forStage); + }); + } + + /** + * Reads a category's record files as the Engine reads a directory of + * them, from the records as they stand: in file order, each keyed by its + * file under `assets/Data`, a record that cannot be read reported + * against its file. + * + * @template T + * @param \Closure(array): T $read The Engine's reader of one record's data. + * @return array{0: array, 1: list} + */ + private static function readRecordFiles(ProjectRecordDatabase $database, \Closure $read): array + { + $directory = basename($database->backingFilePath()); + $records = $database->getRecords(); + usort($records, static fn(ProjectRecord $left, ProjectRecord $right): int => strcmp($left->recordId, $right->recordId)); + $found = []; + $problems = []; + foreach ($records as $record) { + $file = $directory . '/' . $record->recordId . '.php'; + if (($reason = $record->getReadOnlyReason()) !== null) { + $problems[] = sprintf('%s: %s', $file, $reason); + continue; + } + try { + $found[$file] = $read((array) $record->toArray()); + } catch (Throwable $failure) { + $problems[] = sprintf('%s: %s', $file, $failure->getMessage()); + } + } + + return [$found, $problems]; + } + + /** Whether the Cutscenes workspace holds an unsaved edit of one form. */ + private function holdsUnsaved(CutsceneType $type): bool + { + foreach ($this->workspace->cutscenes?->dirtyAssets() ?? [] as $asset) { + if ($asset->type === $type) { + return true; + } + } + + return false; + } + + /** + * @param string $assetPath The data file, relative to the asset root. + * @return array{exists: bool, payload: mixed}|null + */ + private function readCurrent(string $assetPath): ?array + { + return $this->workspace->readCurrentData($this->getAssetRoot() . DIRECTORY_SEPARATOR . $assetPath); + } +} diff --git a/src/Database/ProjectRecord.php b/src/Database/ProjectRecord.php index 24a2f43b..e5898b1e 100644 --- a/src/Database/ProjectRecord.php +++ b/src/Database/ProjectRecord.php @@ -28,6 +28,7 @@ final class ProjectRecord * @param string|null $sourcePath The file backing this record, for one-file-per-record categories. * @param string $recordId The record identity, when it comes from the filename rather than the payload. * @param PhpDataFile|null $file The loaded source file, for one-file-per-record categories. + * @param string|null $loadProblem Why the file could not be read as a record, which leaves it listed but read-only. */ public function __construct( private array|object $payload, @@ -35,6 +36,7 @@ public function __construct( public readonly ?string $sourcePath = null, public readonly string $recordId = '', public readonly ?PhpDataFile $file = null, + private readonly ?string $loadProblem = null, ) { if (! $isDirty) { $this->captureBaseline(); @@ -67,7 +69,7 @@ public function getReadOnlyReason(): ?string ); } - return $this->file?->readOnlyReason; + return $this->loadProblem ?? $this->file?->readOnlyReason; } /** @@ -199,6 +201,8 @@ private static function withPathValue(array $target, array $segments, mixed $val return $target; } + // Removing an absent nested field never changes its scalar/null owner. + if ($value === null && !is_array($target[$key] ?? null)) { return $target; } $child = is_array($target[$key] ?? null) ? $target[$key] : []; $child = self::withPathValue($child, $segments, $value); @@ -385,17 +389,54 @@ private static function withArrayValue(array $array, array $path, mixed $value): return $array; } + /** + * A list's entries as the editor edits them ({@see RecordSubList::readEntries()}). + * + * @return list> + */ + public function getEntries(RecordSubList $list): array + { + return array_values(array_filter($list->readEntries($this->get($list->key)), is_array(...))); + } + + /** + * Replaces a list's entries, stored as the list stores them + * ({@see RecordSubList::writeEntries()}); an empty list the file leaves + * out is removed. + * + * @param list> $entries + */ + public function setEntries(RecordSubList $list, array $entries): void + { + $this->set($list->key, $entries === [] && $list->removeWhenEmpty ? null : $list->writeEntries($entries)); + } + /** * Returns a nested sub-list (objectives, beats, members, commands). * * @param string $key The payload key holding the list. * @return array> */ - public function getSubList(string $key): array + public function getSubList(string $key, ?string $scalarKey = null): array { $list = $this->get($key); - return is_array($list) ? array_values(array_filter($list, is_array(...))) : []; + if (! is_array($list)) { + return []; + } + + $entries = []; + + foreach ($list as $entry) { + if (is_array($entry)) { + $entries[] = $entry; + } elseif ($scalarKey !== null && is_scalar($entry)) { + // An entry authored as a bare value is that value's field. + $entries[] = [$scalarKey => $entry]; + } + } + + return $entries; } /** @@ -405,8 +446,25 @@ public function getSubList(string $key): array * @param array> $list The new list. * @return void */ - public function setSubList(string $key, array $list): void + public function setSubList(string $key, array $list, ?string $scalarKey = null, bool $removeWhenEmpty = false): void { + if ($list === [] && $removeWhenEmpty) { + // An empty list would read as "none, explicitly"; absence is how + // such a list is authored. + $this->set($key, null); + + return; + } + + if ($scalarKey !== null) { + // An entry holding nothing but that field stays a bare value, as + // it was authored; one with more is written as its fields. + $list = array_map( + static fn(array $entry): mixed => array_keys($entry) === [$scalarKey] ? $entry[$scalarKey] : $entry, + $list, + ); + } + $this->set($key, array_values($list)); } @@ -420,6 +478,13 @@ public function toArray(): array|object return $this->payload; } + /** Restores an editor-owned snapshot, including keys removed by a compound field edit. */ + public function restorePayload(array|object $payload): void + { + $this->payload = $payload; + $this->touchState(); + } + /** * Renders any leaf value as a settings-pane string. * diff --git a/src/Database/ProjectRecordDatabase.php b/src/Database/ProjectRecordDatabase.php index e5b29d9c..00ce0512 100644 --- a/src/Database/ProjectRecordDatabase.php +++ b/src/Database/ProjectRecordDatabase.php @@ -4,16 +4,30 @@ namespace Ichiloto\Editor\Database; -use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Editor\Field\PlayerPresentationFields; + +use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; +use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\SourceNode; +use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; +use Ichiloto\Editor\Dialogue\DialogueEmotionFields; use BackedEnum; use Throwable; use Ichiloto\Editor\History\TracksPersistedState; use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Editor\MenuPresentationSettings; +use Ichiloto\Editor\ProjectConfig; +use Ichiloto\Editor\ProjectSettingsFile; +use Ichiloto\Editor\Database\Projections\WholeFileProjection; use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; use RuntimeException; +use Ichiloto\Engine\Battle\Presentation\BattleStageSlot; +use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; /** * A schema-driven Database category. @@ -30,7 +44,123 @@ */ final class ProjectRecordDatabase { - use TracksPersistedState; + use TracksPersistedState { isDirty as private isRecordStateDirty; } + + /** + * Numeric identities of records removed this session, so a new record never + * takes one something may still name. + * + * @var list + */ + private array $retiredIdentities = []; + /** @var (\Closure(string): list)|null What keeps a record from being deleted ({@see useDeletionReferences()}). */ + private ?\Closure $deletionReferences = null; + private ?ReferenceCatalog $authoringReferences = null; + /** @var (\Closure(ProjectRecord): ?ReferenceCatalog)|null */ + private ?\Closure $recordReferences = null; + private bool $graphical = false; + + public function useAuthoringReferences(ReferenceCatalog $references): void + { + $this->authoringReferences = $references; + } + + /** + * Scopes the references one record chooses from to that record, where it owns some of its own: a cinematic's + * map-local subjects are on the map it starts on. + * + * @param \Closure(ProjectRecord): ?ReferenceCatalog $scope + */ + public function useRecordReferences(\Closure $scope): void + { + $this->recordReferences = $scope; + } + + /** The references a record chooses from: its own scope where it owns one, else the project's. */ + private function findRecordReferences(ProjectRecord $record): ?ReferenceCatalog + { + return ($this->recordReferences === null ? null : ($this->recordReferences)($record)) ?? $this->authoringReferences; + } + + private function coerceReferenceField(ProjectRecord $record, RecordField $field, string $rawValue, array $ownerPath = []): mixed + { + $authored = (array) $record->toArray(); + foreach ($ownerPath === [] ? explode('.', $field->key) : $ownerPath as $segment) { + $authored = is_array($authored) ? ($authored[$segment] ?? null) : null; + } + if (($issue = self::findCarryIssue($field, $authored)) !== null) { + throw new \InvalidArgumentException(sprintf('%s cannot be edited as a line: %s. Nothing was changed.', $field->label, $issue)); + } + $value = self::coerce($field, $rawValue); + $references = $this->findRecordReferences($record); + if ($references !== null && $field->entryChoices !== null && $value !== null) { + // The choices read what the field lives in, as its row does. + $holder = self::readNested((array) $record->toArray(), implode('.', array_slice($ownerPath === [] ? explode('.', $field->key) : $ownerPath, 0, -1))); + if (! in_array($value, $references->listEntryChoices($field->entryChoices, is_array($holder) ? $holder : [], (array) $record->toArray()), true)) { + throw new \InvalidArgumentException(sprintf('%s "%s" is not one this entry offers; choose one of its own.', $field->label, strval($value))); + } + } + if ($references !== null && in_array($field->reference, ['actor_ids', 'stage_timelines', 'cinematic_movement_routes', 'map_world_objects'], true) && $value !== null) { + $choices = $field->reference === 'cinematic_movement_routes' + ? $references->getMovementRouteIds($record, $ownerPath) + : $references->valuesFor($field->reference); + foreach (is_array($value) ? $value : [$value] as $selected) { + if (! in_array($selected, $choices, true)) { + throw new \InvalidArgumentException(sprintf('Unknown %s "%s"; choose a project resource.', $field->reference, strval($selected))); + } + } + } + return $value; + } + + /** + * Why a condition or write list cannot be edited through its line + * without losing part of it, by the codec that owns the line; null when + * it can, or the field holds another kind of value or none. + */ + private static function findCarryIssue(RecordField $field, mixed $authored): ?string + { + return match (true) { + $authored === null => null, + $field->codec === RecordFieldCodec::CONDITIONS => ConditionCodec::findCarryIssue($authored), + $field->codec === RecordFieldCodec::WORLD_WRITES => WorldWriteCodec::findCarryIssue($authored), + default => null, + }; + } + + /** Proves a proposed payload is valid and source-editable without changing the actual record. */ + private function assertProposedRecord(ProjectRecord $record, ProjectRecord $draft): void + { + if ($record->toArray() === $draft->toArray()) { return; } + if ($this->schema->storage === RecordStorage::CONFIG_SUBTREE) { + $path = strval($draft->get('path')); + $this->settings?->assertAcceptableValue($path, $draft->get('value'), complete: false); + if ($path === ProjectConfig::INN_PRESENTATION) { + InnPresentationFields::assertValid($draft->get('value'), $this->authoringReferences, complete: false); + } + $this->settings?->assertSourceAcceptsValue($path, $draft->get('value')); + } elseif (is_array($draft->toArray())) { + InnPresentationFields::assertChangedCommandsValid((array) $record->toArray(), $draft->toArray(), $this->authoringReferences, complete: false); + if ($this->schema->storage === RecordStorage::DIRECTORY) { + $record->file?->composeContents($this->composeRecordFilePayload($draft)); + } + } + } + + /** + * @param (\Closure(int): ?int)|null $indexMap How entries' indexes moved, for a list whose entries the + * record names by index; null when none moved. + */ + private function writeRecordEntries(ProjectRecord $record, RecordSubList $list, array $entries, ?\Closure $indexMap = null): void + { + $draft = clone $record; + $draft->setEntries($list, $entries); + if ($indexMap !== null && $list->remapIndexReferences !== null) { + $draft->restorePayload(($list->remapIndexReferences)((array) $draft->toArray(), $indexMap)); + } + $this->assertProposedRecord($record, $draft); + $record->restorePayload($draft->toArray()); + } /** * @param RecordSchema $schema The category schema. @@ -39,7 +169,6 @@ final class ProjectRecordDatabase * @param PhpDataFile|null $file The backing file, for single-file categories. * @param bool $isDirty Whether structural changes are unsaved. * @param string|null $readOnlyReason Why the category cannot be written. - * @param mixed $rootPayload The whole config payload, for CONFIG_SUBTREE storage. * @param string[] $stagedDeletions Record files to unlink on the next save. */ private function __construct( @@ -49,11 +178,14 @@ private function __construct( private ?PhpDataFile $file = null, bool $isDirty = false, private ?string $readOnlyReason = null, - private mixed $rootPayload = null, private array $stagedDeletions = [], private ?\Closure $writeBack = null, private ?string $projectRoot = null, + private ?ProjectSettingsFile $settings = null, + private ?\Closure $identityReferences = null, ) { + $this->learnKeyOrder(); + if ($schema->storage === RecordStorage::LIST_FILE && $schema->projection === null) { // What the file declares for each record -- the identity it is // addressed by and the values it was authored with -- so a save @@ -69,6 +201,15 @@ private function __construct( } } + /** + * The top-level keys of a projected file in the order the file first + * held each: a key this category's fold adds back (its records gone and + * returned, say by an undo after a save) goes back where it was. + * + * @var list + */ + private array $authoredKeyOrder = []; + /** * @var array}> * What the file held for each record when it was last read or adopted, @@ -140,7 +281,8 @@ private static function identityOf(mixed $entry, ?string $key): ?string * Describes where a record sits in its source, for a diagnostic that has * to tell two records with one identity apart. * - * Only for a category over one file, and only while the records still + * A record with a file of its own is that file. In a category over one + * file it is the record's place in it, and only while the records still * stand in the order the file was read in -- after a record is added or * removed the file's places no longer line up with the list, and a * guess would point at the wrong entry, so nothing is said. @@ -150,6 +292,12 @@ private static function identityOf(mixed $entry, ?string $key): ?string */ public function sourceContextOf(int $index): ?string { + if ($this->schema->storage === RecordStorage::DIRECTORY) { + $record = array_values($this->records)[$index] ?? null; + + return $record === null ? null : sprintf('%s/%s.php', $this->schema->relativePath, $record->recordId); + } + if ($this->schema->storage !== RecordStorage::LIST_FILE || ! $this->file instanceof PhpDataFile || ! is_array($this->file->payload)) { return null; } @@ -233,7 +381,7 @@ private function sourcePositionsIn(PhpDataFile $file): array * @param \Closure(array): void $writeBack Receives the rewritten list. * @return self The category. */ - public static function overOwnedList(RecordSchema $schema, string $ownerPath, array $entries, \Closure $writeBack): self + public static function overOwnedList(RecordSchema $schema, string $ownerPath, array $entries, \Closure $writeBack, bool $graphical = false): self { $records = []; @@ -245,6 +393,7 @@ public static function overOwnedList(RecordSchema $schema, string $ownerPath, ar $database = new self($schema, $ownerPath, $records); $database->writeBack = $writeBack; + $database->graphical = $graphical; return $database; } @@ -256,7 +405,7 @@ public static function overOwnedList(RecordSchema $schema, string $ownerPath, ar * @param RecordSchema $schema The category schema. * @return self */ - public static function fromProject(string $projectRoot, RecordSchema $schema): self + public static function fromProject(string $projectRoot, RecordSchema $schema, ?ProjectSettingsFile $settings = null, bool $graphical = false): self { // The categories evaluate authored files that construct engine // objects, and creating an entry constructs them too. The runtime @@ -266,13 +415,14 @@ public static function fromProject(string $projectRoot, RecordSchema $schema): s $path = $schema->resolvePath($projectRoot); - return match ($schema->storage) { + $database = match ($schema->storage) { RecordStorage::LIST_FILE => self::loadListFile($path, $schema, $projectRoot), RecordStorage::DIRECTORY => self::loadDirectory($path, $schema, $projectRoot), - RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot), - RecordStorage::FILE_LISTING => self::loadFileListing($path, $schema), + RecordStorage::CONFIG_SUBTREE => self::loadConfigSubtree($path, $schema, $projectRoot, $settings), RecordStorage::MAP_OWNED => throw new RuntimeException('Map-owned records are built over their owner, not loaded from a path.'), }; + $database->graphical = $graphical; + return $database; } /** @@ -280,14 +430,28 @@ public static function fromProject(string $projectRoot, RecordSchema $schema): s */ public function getRecords(): array { + $this->syncSettingRecords(); return array_values($this->records); } public function getRecordByIndex(int $index): ?ProjectRecord { + $this->syncSettingRecords(); return $this->records[$index] ?? null; } + /** + * A settings file's category lists what its owner offers now: the settings the Engine knows can grow while the + * project is open (a marker role for a place category just created). The owner keeps each setting's one record, + * so pending edits and undo are unaffected. + */ + private function syncSettingRecords(): void + { + if ($this->settings !== null && $this->schema->storage === RecordStorage::CONFIG_SUBTREE && $this->settings->getReadOnlyReason() === null) { + $this->records = $this->settings->getSettingRecords($this->schema->configPath); + } + } + /** * Returns the entry-list labels for this category. * @@ -295,22 +459,26 @@ public function getRecordByIndex(int $index): ?ProjectRecord */ public function getEntryLabels(): array { - return array_map(function (ProjectRecord $record): string { - if ($this->schema->labelFor !== null) { - // A record whose name is made of its parts -- the scope a - // weight vector applies at, what an exclusion excludes -- - // rather than stored under a key of its own. - return strval(($this->schema->labelFor)((array) $record->toArray())); - } + return array_map($this->getEntryLabel(...), $this->getRecords()); + } - $label = $record->getDisplayValue($this->schema->labelKey); + /** The entry-list label of one record. */ + public function getEntryLabel(ProjectRecord $record): string + { + if ($this->schema->labelFor !== null) { + // A record whose name is made of its parts -- the scope a + // weight vector applies at, what an exclusion excludes -- + // rather than stored under a key of its own. + return strval(($this->schema->labelFor)((array) $record->toArray())); + } - if ($label === '' && $this->schema->identityKey !== null) { - $label = $record->getDisplayValue($this->schema->identityKey); - } + $label = $record->getDisplayValue($this->schema->labelKey); - return $label === '' ? ($record->recordId ?: '(unnamed)') : $label; - }, $this->getRecords()); + if ($label === '' && $this->schema->identityKey !== null) { + $label = $record->getDisplayValue($this->schema->identityKey); + } + + return $label === '' ? ($record->recordId ?: '(unnamed)') : $label; } /** @@ -323,6 +491,17 @@ public function isEditable(): bool return $this->readOnlyReason === null; } + public function isDirty(): bool + { + if ($this->settings !== null) { + foreach ($this->records as $record) { + if ($record->isDirty()) { return true; } + } + return false; + } + return $this->isRecordStateDirty(); + } + /** * Returns the honest explanation for a read-only category. * @@ -387,7 +566,7 @@ protected function buildPersistedPayload(): string static fn(ProjectRecord $record): string => $record->recordId . '=' . PhpValueExporter::export($record->toArray()), $this->records, )) . '|' . implode(';', array_values($this->stagedDeletions)), - RecordStorage::CONFIG_SUBTREE, RecordStorage::FILE_LISTING, RecordStorage::MAP_OWNED => serialize([ + RecordStorage::CONFIG_SUBTREE, RecordStorage::MAP_OWNED => serialize([ array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->records), array_values($this->stagedDeletions), ]), @@ -416,6 +595,49 @@ public function getBackupPaths(): array return $paths; } + /** + * The rows one field of a list entry shows: its own, and for a dialogue line's emotion the speaker's catalogue + * emotions as its choices, with a warning row when the line holds one the speaker lacks. + * + * @param array $row The field's row. + * @param array $entry The entry it belongs to. + * @param array $owner The record holding the entry. + * @return list> + */ + private function describeEntryField(RecordField $field, array $row, array $entry, array $owner, ?ReferenceCatalog $references = null): array + { + if ($field->codec === RecordFieldCodec::TROOP_MEMBER && ($row['editable'] ?? true) !== false) { + // Chosen among the owning troop's own members, by number and enemy. + $labels = array_map(static fn(mixed $member, int $index): string => sprintf('%d · %s', $index + 1, + is_array($member) ? trim(strval($member['enemy'] ?? '')) : ''), array_values((array) ($owner['enemies'] ?? [])), + array_keys(array_values((array) ($owner['enemies'] ?? [])))); + $current = self::readNested($entry, $field->key); + unset($row['control']); + $row['options'] = $field->allowsNone ? ['None', ...$labels] : $labels; + $row['value'] = is_int($current) ? ($labels[$current] ?? sprintf('Member %d (not in this troop)', $current + 1)) : 'None'; + } + if ($field->referenceMapKey !== null) { + // A map-local reference reads the map its own entry names. + $map = self::readNested($entry, $field->referenceMapKey); + $row['referenceMap'] = is_string($map) ? $map : ''; + } + if ($field->entryChoices !== null && $references !== null && ($row['editable'] ?? true) !== false) { + // A field of the entry itself lives in the entry. + $holderPath = implode('.', array_slice(explode('.', $field->key), 0, -1)); + $holder = $holderPath === '' ? $entry : self::readNested($entry, $holderPath); + $values = $references->listEntryChoices($field->entryChoices, is_array($holder) ? $holder : [], $owner); + $row['options'] = ['', ...$values]; + $row['choices'] = [['value' => '', 'label' => $field->noneLabel()], + ...array_map(static fn(string $value): array => ['value' => $value, 'label' => $value], $values)]; + } + if ($field->dialogueSpeaker === null || $this->authoringReferences === null) { + return [$row]; + } + + return DialogueEmotionFields::describeRows($row, self::readNested($entry, $field->key), + $this->authoringReferences->describeDialogueEmotions($field->dialogueSpeaker, $entry, $owner)); + } + /** * Builds the settings-pane field descriptors for one record. * @@ -435,33 +657,51 @@ public function getSettingsFields(int $index): array } $isEditable = $this->isEditable() && $record->isEditable(); + $configIssue = $this->settings?->getFieldIssue($record->getDisplayValue('path')); + $isEditable = $isEditable && $configIssue === null; $fields = []; foreach ($this->schema->fieldsFor($record->toArray()) as $field) { - $fields[] = self::describeField($field, self::displayValue($field, $record->get($field->key)), $field->key, $isEditable); + $fields[] = self::describeField($field, self::displayValue($field, $record->get($field->key)), $field->key, $isEditable, + authored: $record->get($field->key)); } + if ($this->schema->key === PlayerPresentationFields::CATEGORY) { + $fields = PlayerPresentationFields::describeFields($fields, (array) $record->toArray(), $this->authoringReferences?->getAssetRoot()); + } + if ($configIssue !== null) { $fields[] = ['label' => 'Read-only', 'value' => $configIssue, 'editable' => false]; } - foreach ($this->schema->commandLists as $listKey => $commandList) { + foreach ($this->schema->commandListsFor($record->toArray()) as $listKey => $commandList) { // A record-level command list (an NPC's inline script) is a // frame to open, like a branch arm, never a wall of rows here. $fields[] = [ // Named for what it is to the record (its Script), with the // count of commands it holds. - 'label' => ucfirst($listKey), + 'label' => $commandList->heading !== '' ? $commandList->heading : ucfirst($listKey), 'value' => sprintf('%d', count($record->getSubList($listKey))), 'field' => 'commandList' . ucfirst($listKey), 'frame' => [$listKey], ]; } - $subList = $this->schema->subList; - - if ($subList === null) { - return $fields; - } + foreach ($this->schema->getInlineSubLists($record->toArray()) as $subList) { + if ($subList !== $this->schema->subList) { + // A list beside the record's own is headed by its name and + // count; its heading is where an entry is added to it. + $fields[] = [ + 'label' => $subList->heading, + 'value' => sprintf('%d', count($record->getEntries($subList))), + 'field' => $subList->prefix . 'List', + 'listHeading' => true, + ]; + } - foreach ($record->getSubList($subList->key) as $entryIndex => $entry) { - $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, [])]; + foreach ($record->getEntries($subList) as $entryIndex => $entry) { + // A list beside the record's own roots its entries' frames at its key, so a + // command arm there (a troop event page's commands) is not read as the record's own list. + $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, + $subList === $this->schema->subList ? [] : [$subList->key], (array) $record->toArray(), + $this->findRecordReferences($record))]; + } } return $fields; @@ -478,23 +718,33 @@ public function getSettingsFields(int $index): array * @param array $entry The entry payload. * @param bool $isEditable Whether edits are accepted. * @param array $basePath The frame path this entry's list lives at. + * @param array $owner The record the entry belongs to, whose own name an NPC's line speaks under. * @return array> The field descriptors. */ - private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, array $entry, bool $isEditable, array $basePath): array + private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, array $entry, bool $isEditable, array $basePath, array $owner = [], + ?ReferenceCatalog $references = null): array { $fields = []; + // Each row names the entry it belongs to and its own name within it, so an + // interface can show one selected entry's rows, labelled as that entry's. foreach ($subList->fieldsFor($entry) as $field) { - $fields[] = self::describeField( + $fields = [...$fields, ...$this->describeEntryField($field, [...self::describeField( $field, self::displayValue($field, self::readNested($entry, $field->key)), self::subFieldId($subList->prefix, $entryIndex, $field->key), $isEditable, sprintf('%s %d %s', ucfirst($subList->singular), $entryIndex + 1, $field->label), - ); + self::readNested($entry, $field->key), + ), 'entry' => [$entryIndex], 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), 'name' => $field->label, 'sourceKey' => $field->key, + 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $field->key)]], $entry, $owner, $references)]; } - $variant = $subList->variantKey !== null ? strval($entry[$subList->variantKey] ?? '') : ''; + $variant = $subList->getVariant($entry); + + if ($this->graphical) { + $fields = StagedActorFields::describeFields($entry, $fields, $this->authoringReferences?->getAssetRoot()); + } if ($variant === 'choice') { // Each option is a row to rename and a frame to open. The arm's @@ -512,6 +762,9 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'label' => $label . ' Text', 'value' => $text, 'field' => sprintf('%s%dOption%dText', $subList->prefix, $entryIndex, $optionIndex), + 'entry' => [$entryIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), + 'name' => sprintf('Option %d Text', $optionIndex + 1), ]; if ($isEditable) { @@ -524,6 +777,9 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'value' => sprintf('%d', $armCount), 'field' => sprintf('%s%dOption%dThen', $subList->prefix, $entryIndex, $optionIndex), 'frame' => [...$basePath, $entryIndex, 'options', $optionIndex, 'then'], + 'entry' => [$entryIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), + 'name' => sprintf('Option %d Commands', $optionIndex + 1), ]; } } @@ -544,22 +800,40 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'value' => sprintf('%d', count((array) ($entry[$armKey] ?? []))), 'field' => sprintf('%s%d%s', $subList->prefix, $entryIndex, ucfirst($armKey)), 'frame' => [...$basePath, $entryIndex, $armKey], + 'entry' => [$entryIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), + 'name' => $armLabel . ' Commands', ]; } $nestedList = $subList->nestedListFor($entry); if ($nestedList === null) { - return $fields; + return \Ichiloto\Editor\Maps\MapPlacement::describeFields($entry, $fields); + } + + if ($nestedList->heading !== '') { + // A headed nested list (an objective's waymarks) is added to from its heading, so the + // entry's own rows keep adding entries of the entry's own list. + $fields[] = [ + 'label' => sprintf('%s %d %s', ucfirst($subList->singular), $entryIndex + 1, $nestedList->heading), + 'value' => sprintf('%d', count($nestedList->readEntries(self::readNested($entry, $nestedList->key)))), + 'field' => $subList->prefix . $entryIndex . ucfirst($nestedList->prefix) . 'List', + 'entry' => [$entryIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($subList->singular), $entryIndex + 1), + 'name' => $nestedList->heading, + ]; } - foreach (array_values((array) ($entry[$nestedList->key] ?? [])) as $nestedIndex => $nestedEntry) { + foreach ($nestedList->readEntries(self::readNested($entry, $nestedList->key)) as $nestedIndex => $nestedEntry) { if (! is_array($nestedEntry)) { continue; } foreach ($nestedList->fieldsFor($nestedEntry) as $field) { - $fields[] = self::describeField( + $fields = [...$fields, ...$this->describeEntryField($field, ['entry' => [$entryIndex, $nestedIndex], 'entryLabel' => sprintf('%s %d', ucfirst($nestedList->singular), $nestedIndex + 1), + 'name' => $field->label, 'sourceKey' => $field->key, + 'dataPath' => [...($basePath === [] ? explode('.', $subList->key) : $basePath), $entryIndex, ...explode('.', $nestedList->key), $nestedIndex, ...explode('.', $field->key)]] + self::describeField( $field, self::displayValue($field, self::readNested($nestedEntry, $field->key)), self::nestedSubFieldId( @@ -578,7 +852,8 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, $nestedIndex + 1, $field->label, ), - ); + self::readNested($nestedEntry, $field->key), + ), $nestedEntry, $owner, $references)]; } foreach ($nestedList->armsFor($nestedEntry) as $armKey => $armLabel) { @@ -596,11 +871,14 @@ private function describeSubEntryFields(RecordSubList $subList, int $entryIndex, 'value' => sprintf('%d', count((array) ($nestedEntry[$armKey] ?? []))), 'field' => self::nestedSubFieldId($subList->prefix, $entryIndex, $nestedList->prefix, $nestedIndex, ucfirst($armKey)), 'frame' => [...$basePath, $entryIndex, $nestedList->key, $nestedIndex, $armKey], + 'entry' => [$entryIndex, $nestedIndex], + 'entryLabel' => sprintf('%s %d', ucfirst($nestedList->singular), $nestedIndex + 1), + 'name' => $armLabel . ' Commands', ]; } } - return $fields; + return \Ichiloto\Editor\Maps\MapPlacement::describeFields($entry, $fields); } /** @@ -618,6 +896,9 @@ public function setField(int $index, string $fieldId, string $rawValue): void if (! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return; } + if ($this->settings !== null && ($issue = $this->settings->getFieldIssue($record->getDisplayValue('path'))) !== null) { + throw new \Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal($issue); + } $subList = $this->schema->subList; @@ -626,7 +907,8 @@ public function setField(int $index, string $fieldId, string $rawValue): void . preg_quote($subList->prefix, '/') . '(\d+)([A-Za-z][A-Za-z0-9]*?)(\d+)([A-Za-z][A-Za-z0-9]*)$/'; - if (preg_match($nestedPattern, $fieldId, $matches) === 1) { + if (preg_match($nestedPattern, $fieldId, $matches) === 1 + && ! self::hasEntryFieldToken($subList, $record->getEntries($subList)[intval($matches[1])] ?? [], $matches[2] . $matches[3] . $matches[4])) { $this->setNestedSubField( $record, $subList, @@ -640,9 +922,11 @@ public function setField(int $index, string $fieldId, string $rawValue): void } } - if ($subList !== null && preg_match('/^' . preg_quote($subList->prefix, '/') . '(\d+)([A-Za-z][A-Za-z0-9]*)$/', $fieldId, $matches) === 1) { - $this->setSubField($record, $subList, intval($matches[1]), $matches[2], $rawValue); - return; + foreach ($this->schema->getInlineSubLists($record->toArray()) as $inlineList) { + if (preg_match('/^' . preg_quote($inlineList->prefix, '/') . '(\d+)([A-Za-z][A-Za-z0-9]*)$/', $fieldId, $matches) === 1) { + $this->setSubField($record, $inlineList, intval($matches[1]), $matches[2], $rawValue); + return; + } } foreach ($this->schema->fieldsFor($record->toArray()) as $field) { @@ -665,25 +949,344 @@ public function setField(int $index, string $fieldId, string $rawValue): void return; } - $record->set($field->key, self::coerce($field, $rawValue)); + $value = $this->coerceReferenceField($record, $field, $rawValue); + if ($this->schema->storage === RecordStorage::CONFIG_SUBTREE) { + // A setting the file leaves out already reads as its default: + // choosing that default writes nothing. + if ($record->get('value') === null && ! $this->settings?->holds(strval($record->get('path'))) && $value == $record->get('default')) { + return; + } + } + if ($field->uniqueAcrossRecords) { + is_array($value) + ? $this->assertMembersUnclaimed($record, $field, $value) + : $this->assertValueUnclaimed($record, $field, $value); + } + $previousIdentity = $this->schema->identityKey === null ? null : $record->get($this->schema->identityKey); + // An identity something still names stays, so the reference keeps resolving. + if ($field->key === $this->schema->identityKey && $this->deletionReferences !== null && strval($value) !== strval($previousIdentity) + && ($references = ($this->deletionReferences)(strval($previousIdentity))) !== []) { + throw new RecordRefusal(sprintf('%s %s is still used, so its id stays: %s.', ucfirst($this->schema->entryNoun), + strval($previousIdentity), implode('; ', $references))); + } + $before = $record->toArray(); + $draft = clone $record; + if ($this->schema->key === PlayerPresentationFields::CATEGORY) { + $draft->restorePayload(PlayerPresentationFields::applyField((array) $before, $fieldId, $rawValue, + $this->authoringReferences?->getAssetRoot() ?? throw new \InvalidArgumentException('The player presentation has no project asset root.'))); + } else { + $draft->set($field->key, $value); + } + if ($this->schema->prepareEdit !== null) { + $draft->restorePayload(($this->schema->prepareEdit)((array) $draft->toArray(), $field->key)); + } + $this->dropStaleShapeFields($draft, $before); + $this->assertProposedRecord($record, $draft); + $record->restorePayload($draft->toArray()); + if ($field->key === $this->schema->labelKey && $this->schema->identityFollowsLabel) { + $this->followLabelWithIdentity($record, strval($previousIdentity)); + } + $this->dropStaleShapeFields($record, $before); $this->touchState(); return; } } + /** + * A new entry of a keyed list under a key no other entry holds: its + * blank's, or the list's noun, numbered when taken. + * + * @param list $entries The list's entries. + * @param array $entry The entry to add. + * @return array + */ + private static function withUniqueKey(RecordSubList $list, array $entries, array $entry): array + { + if ($list->keyField === null) { + return $entry; + } + + $taken = array_map(static fn(mixed $other): string => is_array($other) ? strval($other[$list->keyField] ?? '') : '', $entries); + $keyField = array_find($list->fields, static fn(RecordField $field): bool => $field->key === $list->keyField); + if ($keyField?->reference !== null) { + $key = strval($entry[$list->keyField] ?? ''); + if (in_array($key, $taken, true)) { + throw new \InvalidArgumentException($key === '' ? 'Finish choosing the unbound resource before adding another binding.' : 'That resource already has a binding.'); + } + return $entry; + } + $base = trim(strval($entry[$list->keyField] ?? '')) ?: Slug::of('new ' . $list->singular); + $key = $base; + + for ($number = 2; in_array($key, $taken, true); $number++) { + $key = $base . '-' . $number; + } + + $entry[$list->keyField] = $key; + + return $entry; + } + + /** + * Refuses a keyed list's key edit that would leave an entry without a key, + * or give it another entry's: either would lose an entry on save. + * + * @param list $entries The list's entries. + * @throws \InvalidArgumentException + */ + private static function assertKeyAvailable(RecordSubList $list, array $entries, int $at, RecordField $field, mixed $value): void + { + if ($list->keyField === null || $field->key !== $list->keyField) { + return; + } + + $key = trim(strval($value ?? '')); + + if ($key === '') { + throw new \InvalidArgumentException(sprintf('A %s needs %s.', $list->singular, strtolower($field->label))); + } + + foreach ($entries as $index => $other) { + if ($index !== $at && is_array($other) && strval($other[$list->keyField] ?? '') === $key) { + throw new \InvalidArgumentException(sprintf('Another %s already has %s "%s".', $list->singular, strtolower($field->label), $key)); + } + } + } + + /** + * Drops the values only a record's old shape offered, when an edit + * changes which fields it offers: a spell turned into an ability keeps + * no spell effect type for the Engine to refuse. As an entry's variant + * does ({@see withoutStaleVariantFields()}). + * + * @param array|object $before The record before the edit. + */ + private function dropStaleShapeFields(ProjectRecord $record, array|object $before): void + { + if ($this->schema->fieldsFor === null || ! is_array($before)) { + return; + } + + $keys = static fn(array $fields): array => array_map(static fn(RecordField $field): string => $field->key, $fields); + $offered = $keys($this->schema->fieldsFor($record->toArray())); + + foreach (array_diff($keys($this->schema->fieldsFor($before)), $offered) as $stale) { + // A scalar becoming structured still owns its parent value. + if (array_any($offered, static fn(string $key): bool => str_starts_with($key, $stale . '.'))) { continue; } + $record->set($stale, null); + } + } + + /** + * A new record's payload without the list members other records already + * hold, for every field held by one record at most. + * + * @param array $payload The record about to be added. + * @return array + */ + private function withoutClaimedMembers(array $payload): array + { + foreach ($this->schema->fieldsFor($payload) as $field) { + $members = $field->uniqueAcrossRecords ? ($payload[$field->key] ?? null) : null; + if (! is_array($members)) { + continue; + } + $held = []; + foreach ($this->getRecords() as $record) { + $theirs = $record->get($field->key); + $held = [...$held, ...(is_array($theirs) ? $theirs : [])]; + } + $free = array_values(array_diff($members, $held)); + if ($free === [] && $field->removeWhenEmpty) { + unset($payload[$field->key]); + } else { + $payload[$field->key] = $free; + } + } + + return $payload; + } + + /** + * Refuses a value another record of the category already has, ignoring + * case, before anything changes: two classes or enemies by one name. + * + * @throws \InvalidArgumentException When another record has it. + */ + private function assertValueUnclaimed(ProjectRecord $record, RecordField $field, mixed $value): void + { + if (! is_scalar($value) || trim(strval($value)) === '') { + return; + } + + foreach ($this->getRecords() as $other) { + $theirs = $other === $record ? null : $other->get($field->key); + + if (is_scalar($theirs) && mb_strtolower(trim(strval($theirs))) === mb_strtolower(trim(strval($value)))) { + throw new \InvalidArgumentException(sprintf( + 'Another %s already has %s %s.', + $this->schema->entryNoun, + mb_strtolower($field->label), + trim(strval($value)), + )); + } + } + } + + /** + * Keeps a record's identity in step with its label, as the identity's + * slug, while nothing refers to the identity it has; once something does, + * the identity stays, so the reference keeps resolving. + */ + private function followLabelWithIdentity(ProjectRecord $record, string $previousIdentity): void + { + $identityKey = (string) $this->schema->identityKey; + + if ($previousIdentity !== '' && $this->identityReferences !== null && ($this->identityReferences)($previousIdentity)) { + return; + } + + $taken = []; + foreach ($this->getRecords() as $other) { + if ($other !== $record) { + $taken[] = $other->getDisplayValue($identityKey); + } + } + + $record->set($identityKey, Slug::unique(strval($record->get($this->schema->labelKey)), $taken, Slug::of($this->schema->entryNoun))); + } + + /** + * Says whether something in the project refers to a record by an + * identity, for a category whose identities follow their labels. The + * workspace supplies it: what refers to a record lives across the project. + * + * @param \Closure(string): bool $isReferenced + */ + public function useIdentityReferences(\Closure $isReferenced): void + { + $this->identityReferences = $isReferenced; + } + + /** + * Says what in the project still names a record by its identity, which keeps the record from being deleted + * (a place category a landmark uses). The workspace supplies it: what refers to a record lives across the + * project. + * + * @param \Closure(string): list $describe Where each reference lives, in words. + */ + public function useDeletionReferences(\Closure $describe): void + { + $this->deletionReferences = $describe; + } + + /** + * What still names the record at an index by its identity, in words; empty when it may go. + * + * @return list + */ + public function describeDeletionReferences(int $index): array + { + $record = array_values($this->records)[$index] ?? null; + if ($this->deletionReferences === null || ! $record instanceof ProjectRecord || $this->schema->identityKey === null) { + return []; + } + return ($this->deletionReferences)((string) $record->get($this->schema->identityKey)); + } + + /** + * Refuses a list member another record of the category already holds, + * naming that record, before anything changes. + * + * @param list $members The list this record would hold. + * @throws \InvalidArgumentException When another record holds one of them. + */ + private function assertMembersUnclaimed(ProjectRecord $record, RecordField $field, array $members): void + { + $held = $record->get($field->key); + $added = array_diff($members, is_array($held) ? $held : []); + + foreach ($this->getRecords() as $other) { + $theirs = $other === $record ? null : $other->get($field->key); + $taken = is_array($theirs) ? array_values(array_intersect($added, $theirs)) : []; + + if ($taken !== []) { + throw new \InvalidArgumentException(sprintf( + '%s already has %s %s; take it off there first.', + $this->getEntryLabel($other), + mb_strtolower($field->label), + implode(', ', array_map(strval(...), $taken)), + )); + } + } + } + + /** + * Returns whether this category can take a new record at all. Terms are + * the leaves of an existing config tree and listings are informational: + * there is no meaningful blank entry to append to either. + * + * @return bool True when creating a record can do something here. + */ + public function supportsRecordCreation(): bool + { + return $this->isEditable() + && ! $this->holdsOneRecord() + && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE; + } + + /** Whether the file is the category's one record (system.php), only ever edited. */ + private function holdsOneRecord(): bool + { + return $this->schema->projection instanceof WholeFileProjection; + } + + /** + * Returns whether this category can let a record go. A term is a leaf of + * the config tree it belongs to, not an entry of its own. + * + * @return bool True when deleting a record can do something here. + */ + public function supportsRecordDeletion(): bool + { + return $this->isEditable() && ! $this->holdsOneRecord() && $this->schema->storage !== RecordStorage::CONFIG_SUBTREE; + } + /** * Appends a blank record. * - * @return int|null The new record index, or null when the category is read-only. + * @return int|null The new record index, or null when no record can be made here. */ public function addRecord(): ?int { - if (! $this->isEditable() || $this->schema->storage === RecordStorage::CONFIG_SUBTREE || $this->schema->storage === RecordStorage::FILE_LISTING) { - // Terms are the leaves of an existing config tree and listings are - // informational: there is no meaningful blank entry to append. + try { + return $this->requireNewRecord(); + } catch (RecordRefusal) { return null; } + } + + /** + * Appends a blank record, or says why none can be made: the category + * takes no new records, or the project lacks what a new one needs. + * + * @param string|null $identity The identity the record is for, when it is + * made for something that already has one (an enemy's battle art), or the + * name an event script's file takes; a fresh unique one when null. + * @return int The new record index. + * @throws RecordRefusal With the reason an author reads, including an identity that already has a record. + */ + public function requireNewRecord(?string $identity = null): int + { + $cannot = sprintf('%s entries cannot be created from the editor.', ucfirst($this->schema->entryNoun)); + + if (! $this->supportsRecordCreation()) { + throw new RecordRefusal($cannot); + } + if ($this->schema->identityGiven && $identity === null) { + throw new RecordRefusal(sprintf('%s is made for what it belongs to, from that record\'s own page.', ucfirst($this->schema->entryNoun))); + } $payload = $this->schema->blank; $identityKey = $this->schema->identityKey; @@ -704,43 +1307,58 @@ public function addRecord(): ?int // across the whole file rather than this category alone. $name = $this->makeUniqueName('New ' . ucwords($this->schema->entryNoun)); $payload = $projectRoot === null - ? ($this->schema->makeBlank)($name, '') + ? ($this->schema->makeBlank)($name, '', $this->authoringReferences) : ProjectDirectoryContext::run( $projectRoot, - fn(string $root): mixed => ($this->schema->makeBlank)($name, $root), + fn(string $root): mixed => ($this->schema->makeBlank)($name, $root, $this->authoringReferences), ); - } catch (Throwable) { - return null; + } catch (RecordRefusal $refusal) { + // The factory's own reason, such as what the project lacks. + throw $refusal; + } catch (Throwable $failure) { + throw new RecordRefusal(sprintf('A new %s could not be made: %s', $this->schema->entryNoun, $failure->getMessage()), previous: $failure); } - if (! is_object($payload)) { - return null; + // A data record's blank is its data, whether it has a file of its own or is a row of a projected file; + // a constructor list's, the object. + $dataRecords = $this->schema->recordClass !== null || $this->schema->projection !== null; + if ($dataRecords ? ! is_array($payload) : ! is_object($payload)) { + throw new RecordRefusal($cannot); } } - if (is_array($payload) && $identityKey !== null && array_key_exists($identityKey, $payload)) { - $payload[$identityKey] = $this->makeUniqueIdentity(strval($payload[$identityKey])); + if ($identity !== null && $this->isNamedByFile()) { + // An event script's name is its file's: scripts and triggers name it by that. + $recordId = $this->requireNewFileName($identity); + } elseif ($identity !== null) { + if ($identityKey === null || ! is_array($payload)) { + throw new RecordRefusal(sprintf('%s entries are not made for a given name.', ucfirst($this->schema->entryNoun))); + } + if (array_any($this->getRecords(), static fn(ProjectRecord $record): bool => $record->getDisplayValue($identityKey) === $identity)) { + throw new RecordRefusal(sprintf('%s already has %s.', $identity, $this->schema->entryNoun)); + } + $payload[$identityKey] = $identity; + $recordId = $identity; + } elseif (is_array($payload) && $identityKey !== null && array_key_exists($identityKey, $payload)) { + $payload[$identityKey] = $this->makeUniqueIdentity($identity ?? $payload[$identityKey]); $recordId = strval($payload[$identityKey]); } + if (is_array($payload)) { + $payload = $this->withoutClaimedMembers($payload); + } if ($this->schema->recordFilter !== null && ! ($this->schema->recordFilter)($payload)) { // Never append what the save merge would drop: a blank that is not // a member of its own category vanishes on save, after the editor // said it was created. - return null; + throw new RecordRefusal($cannot); } $sourcePath = null; $file = null; if ($this->schema->storage === RecordStorage::DIRECTORY) { - $recordId = $recordId !== '' ? $recordId : $this->makeUniqueFileStem('new-' . str_replace(' ', '-', $this->schema->entryNoun)); - $sourcePath = $this->path . DIRECTORY_SEPARATOR . $recordId . '.php'; - $file = PhpDataFile::load($sourcePath); - - if ($this->schema->listPayloadKey !== null) { - $payload['__scriptId'] = $recordId; - } + [$recordId, $sourcePath, $file, $payload] = $this->prepareRecordFile($recordId, $payload); } // A record the file has never held: it has no authored identity or @@ -759,7 +1377,7 @@ public function addRecord(): ?int */ public function removeRecord(int $index): ?ProjectRecord { - if (! $this->isEditable() || $this->schema->storage === RecordStorage::CONFIG_SUBTREE) { + if (! $this->supportsRecordDeletion()) { return null; } @@ -776,6 +1394,10 @@ public function removeRecord(int $index): ?ProjectRecord array_splice($records, $index, 1); $this->records = $records; $this->touchState(); + $identity = $record->get($this->schema->identityKey ?? 'id'); + if (is_int($identity)) { + $this->retiredIdentities[] = $identity; + } if ($record->sourcePath !== null && is_file($record->sourcePath)) { $this->stagedDeletions[$record->sourcePath] = $record->sourcePath; @@ -902,23 +1524,27 @@ public function moveRecord(int $from, int $to): bool public function duplicateRecordSupported(): bool { return $this->isEditable() - && $this->schema->storage === RecordStorage::LIST_FILE + && ! $this->holdsOneRecord() + && in_array($this->schema->storage, [RecordStorage::LIST_FILE, RecordStorage::DIRECTORY], true) && ! $this->isConstructorAuthored(); } /** - * Duplicates a record below itself, with a fresh unique identity. + * Duplicates a record below itself, with a fresh unique identity: the + * one given, made unique, else one made from the original's. A per-file + * copy's file is named after that identity. * - * Only list-file categories duplicate: a per-file or object-backed - * record's copy would need its own source decisions, which nothing - * requires yet. + * List-file and per-file categories duplicate; a per-file copy gets a + * file of its own. Object-backed records' copies would need their own + * source decisions, which nothing requires yet. * * @param int $index The record to copy. + * @param string|null $identity What the copy is to be called. * @return int|null The copy's index, or null when nothing was copied. */ - public function duplicateRecord(int $index): ?int + public function duplicateRecord(int $index, ?string $identity = null): ?int { - if (! $this->isEditable() || $this->schema->storage !== RecordStorage::LIST_FILE) { + if (! $this->duplicateRecordSupported()) { return null; } @@ -926,16 +1552,19 @@ public function duplicateRecord(int $index): ?int $source = $records[$index] ?? null; $payload = $source?->toArray(); - if (! $source instanceof ProjectRecord || ! is_array($payload)) { + if (! $source instanceof ProjectRecord || ! $source->isEditable() || ! is_array($payload)) { return null; } $identityKey = $this->schema->identityKey; $recordId = ''; if ($identityKey !== null && array_key_exists($identityKey, $payload)) { - $payload[$identityKey] = $this->makeUniqueIdentity(strval($payload[$identityKey])); + $payload[$identityKey] = $this->makeUniqueIdentity($identity ?? $payload[$identityKey]); $recordId = strval($payload[$identityKey]); } + // A copy cannot hold what one record holds alone (an animation's + // roles): it starts without them, and the original keeps them. + $payload = $this->withoutClaimedMembers($payload); if ($this->schema->recordFilter !== null && ! ($this->schema->recordFilter)($payload)) { return null; @@ -943,36 +1572,49 @@ public function duplicateRecord(int $index): ?int // A record the file has never held: no authored identity or values, // which is what tells the source writer to insert it. - $copy = new ProjectRecord($payload, true, null, $recordId); - array_splice($records, $index + 1, 0, [$copy]); + $sourcePath = null; + $file = null; + + if ($this->schema->storage === RecordStorage::DIRECTORY) { + [$recordId, $sourcePath, $file, $payload] = $this->prepareRecordFile($recordId !== '' ? $recordId : $source->recordId, $payload); + } + + $copy = new ProjectRecord($payload, true, $sourcePath, $recordId, $file); + // A numbered copy takes the next number, so it goes last, where + // reopening the folder lists it. + $at = $this->schema->numberedFiles ? count($records) : $index + 1; + array_splice($records, $at, 0, [$copy]); $this->records = $records; $this->touchState(); - return $index + 1; + return $at; } /** - * Appends a sub-list entry (an objective, beat, member, or command). + * Adds a sub-list entry (an objective, beat, member, or command), at the + * end or at a position. * * @param int $index The record index. * @param array|null $entry The entry payload; the schema blank when null. + * @param int|null $at Where to insert it, or null for the end. * @return int|null The new entry index. */ - public function addSubItem(int $index, ?array $entry = null): ?int + public function addSubItem(int $index, ?array $entry = null, ?int $at = null, ?string $listKey = null): ?int { $record = $this->getRecordByIndex($index); - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey, $record?->toArray()); if ($subList === null || ! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return null; } - $entries = $record->getSubList($subList->key); - $entries[] = $entry ?? $subList->blank; - $record->setSubList($subList->key, $entries); + $entries = $record->getEntries($subList); + $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); + array_splice($entries, $position, 0, [self::withUniqueKey($subList, $entries, $entry ?? $subList->blank)]); + $this->writeRecordEntries($record, $subList, $entries, static fn(int $old): int => $old >= $position ? $old + 1 : $old); $this->touchState(); - return count($entries) - 1; + return $position; } /** @@ -982,23 +1624,27 @@ public function addSubItem(int $index, ?array $entry = null): ?int * @param int $entryIndex The entry index. * @return array|null The removed entry payload. */ - public function removeSubItem(int $index, int $entryIndex): ?array + public function removeSubItem(int $index, int $entryIndex, ?string $listKey = null): ?array { $record = $this->getRecordByIndex($index); - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey, $record?->toArray()); if ($subList === null || ! $record instanceof ProjectRecord || ! $this->isEditable() || ! $record->isEditable()) { return null; } - $entries = $record->getSubList($subList->key); + $entries = $record->getEntries($subList); if (! array_key_exists($entryIndex, $entries)) { return null; } [$removed] = array_splice($entries, $entryIndex, 1); - $record->setSubList($subList->key, $entries); + $this->writeRecordEntries($record, $subList, $entries, static fn(int $old): ?int => match (true) { + $old === $entryIndex => null, + $old > $entryIndex => $old - 1, + default => $old, + }); $this->touchState(); return $removed; @@ -1012,19 +1658,19 @@ public function removeSubItem(int $index, int $entryIndex): ?array * @param array $entry The entry payload. * @return void */ - public function insertSubItem(int $index, int $entryIndex, array $entry): void + public function insertSubItem(int $index, int $entryIndex, array $entry, ?string $listKey = null): void { $record = $this->getRecordByIndex($index); - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey, $record?->toArray()); if ($subList === null || ! $record instanceof ProjectRecord) { return; } - $entries = $record->getSubList($subList->key); + $entries = $record->getEntries($subList); $entryIndex = max(0, min(count($entries), $entryIndex)); array_splice($entries, $entryIndex, 0, [$entry]); - $record->setSubList($subList->key, $entries); + $this->writeRecordEntries($record, $subList, $entries, static fn(int $old): int => $old >= $entryIndex ? $old + 1 : $old); $this->touchState(); } @@ -1034,15 +1680,29 @@ public function insertSubItem(int $index, int $entryIndex, array $entry): void * @param int $index The record index. * @return int */ - public function countSubItems(int $index): int + public function countSubItems(int $index, ?string $listKey = null): int { - $subList = $this->schema->subList; + $subList = $this->schema->findInlineSubList($listKey, $this->getRecordByIndex($index)?->toArray()); if ($subList === null) { return 0; } - return count($this->getRecordByIndex($index)?->getSubList($subList->key) ?? []); + return count($this->getRecordByIndex($index)?->getEntries($subList) ?? []); + } + + /** + * Returns the entries of one of the lists a record shows inline. + * + * @param int $index The record index. + * @param string|null $listKey The list; the record's own when null. + * @return array> + */ + public function getSubItems(int $index, ?string $listKey = null): array + { + $subList = $this->schema->findInlineSubList($listKey, $this->getRecordByIndex($index)?->toArray()); + + return $subList === null ? [] : ($this->getRecordByIndex($index)?->getEntries($subList) ?? []); } /** @@ -1064,13 +1724,17 @@ public function nestedSubListContext(int $index, string $fieldId): ?array } $parentIndex = intval($parentMatch[1]); - $entry = $record->getSubList($subList->key)[$parentIndex] ?? null; + $entry = $record->getEntries($subList)[$parentIndex] ?? null; $nestedList = is_array($entry) ? $subList->nestedListFor($entry) : null; if ($nestedList === null) { return null; } + if (! self::isNestedListRow($subList->prefix, $parentIndex, $nestedList, $fieldId)) { + return null; + } + $nestedIndex = null; $pattern = '/^' . preg_quote($subList->prefix, '/') @@ -1085,8 +1749,8 @@ public function nestedSubListContext(int $index, string $fieldId): ?array return ['parentIndex' => $parentIndex, 'nestedIndex' => $nestedIndex, 'list' => $nestedList]; } - /** Appends an entry to a variant-owned nested list. */ - public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = null): ?int + /** Adds an entry to a variant-owned nested list, at the end or at a position. */ + public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = null, ?int $at = null): ?int { $context = $this->nestedListForParent($index, $parentIndex); @@ -1094,11 +1758,12 @@ public function addNestedSubItem(int $index, int $parentIndex, ?array $entry = n return null; } - $entries = array_values((array) ($context['parent'][$context['list']->key] ?? [])); - $entries[] = $entry ?? $context['list']->blank; + $entries = $context['list']->readEntries(self::readNested($context['parent'], $context['list']->key)); + $position = $at === null ? count($entries) : max(0, min(count($entries), $at)); + array_splice($entries, $position, 0, [self::withUniqueKey($context['list'], $entries, $entry ?? $context['list']->blank)]); $this->writeNestedSubList($context, $entries); - return count($entries) - 1; + return $position; } /** Removes an entry from a variant-owned nested list. */ @@ -1110,7 +1775,7 @@ public function removeNestedSubItem(int $index, int $parentIndex, int $nestedInd return null; } - $entries = array_values((array) ($context['parent'][$context['list']->key] ?? [])); + $entries = $context['list']->readEntries(self::readNested($context['parent'], $context['list']->key)); if (! array_key_exists($nestedIndex, $entries) || ! is_array($entries[$nestedIndex])) { return null; @@ -1131,7 +1796,7 @@ public function insertNestedSubItem(int $index, int $parentIndex, int $nestedInd return; } - $entries = array_values((array) ($context['parent'][$context['list']->key] ?? [])); + $entries = $context['list']->readEntries(self::readNested($context['parent'], $context['list']->key)); $nestedIndex = max(0, min(count($entries), $nestedIndex)); array_splice($entries, $nestedIndex, 0, [$entry]); $this->writeNestedSubList($context, $entries); @@ -1144,7 +1809,7 @@ public function countNestedSubItems(int $index, int $parentIndex): int return $context === null ? 0 - : count((array) ($context['parent'][$context['list']->key] ?? [])); + : count((array) self::readNested($context['parent'], $context['list']->key)); } /** @@ -1153,40 +1818,80 @@ public function countNestedSubItems(int $index, int $parentIndex): int * @return void */ public function save(): void + { + $prepared = $this->prepareSave(); + + if ($prepared !== null) { + SaveSet::commitOne(dirname($this->path), $prepared); + } + } + + /** + * What writes this category's file: the settings file a settings + * category edits through (Terms and Configuration both write + * `config.php` through it), or the category itself. + */ + public function getFileWriter(): ProjectSettingsFile|self + { + return $this->settings ?? $this; + } + + /** + * Works out the category's save without writing it, for {@see SaveSet} + * to install alone or with the rest of a change set: its own file, its + * share of a file other categories edit too, or a file per record and + * the record files it deleted. A category the map owns hands its records + * to the map now, which the map's own save writes. Null when nothing + * diverges from the last save; writing would only canonicalize authored + * formatting. + * + * @throws RuntimeException When the category is read-only or its files cannot take its edits. + */ + public function prepareSave(): ?PreparedSave { if (! $this->isEditable()) { throw new RuntimeException(sprintf('%s is read-only: %s.', $this->schema->entryNoun, $this->readOnlyReason)); } + if ($this->settings !== null) { + return $this->settings->prepareSave(); + } if (! $this->isDirty()) { - // Nothing diverges from the last save; writing would only - // canonicalize authored formatting. - return; + return null; + } + + foreach ($this->getRecords() as $record) { + if ($this->schema->storage === RecordStorage::DIRECTORY && $record->isDirty() && is_array($record->toArray())) { + $original = is_array($record->file?->payload) ? $record->file->payload : []; + if ($this->schema->listPayloadKey !== null) { $original = [$this->schema->listPayloadKey => $original]; } + InnPresentationFields::assertChangedCommandsValid($original, $record->toArray(), $this->authoringReferences); + MovementRouteFields::assertChangedCommandsValid($original, $record->toArray()); + } } if ($this->schema->storage === RecordStorage::LIST_FILE) { // The transaction adopts the written file and cleans every // category it wrote for, this one included. - SharedFileTransaction::commit([$this]); - - return; + return SharedFileTransaction::prepare([$this]); } - match ($this->schema->storage) { - RecordStorage::DIRECTORY => $this->saveDirectory(), - RecordStorage::CONFIG_SUBTREE => $this->saveConfigSubtree(), - RecordStorage::FILE_LISTING => null, - RecordStorage::MAP_OWNED => $this->writeBack !== null - ? ($this->writeBack)(array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->getRecords())) - : null, - RecordStorage::LIST_FILE => null, + $clean = function (): void { + foreach ($this->records as $record) { + $record->markClean(); + } + + $this->captureBaseline(); }; - foreach ($this->records as $record) { - $record->markClean(); + if ($this->schema->storage === RecordStorage::DIRECTORY) { + return $this->prepareDirectorySave($clean); } - $this->captureBaseline(); + if ($this->schema->storage === RecordStorage::MAP_OWNED && $this->writeBack !== null) { + ($this->writeBack)(array_map(static fn(ProjectRecord $record): array|object => $record->toArray(), $this->getRecords())); + } + + return PreparedSave::adoptOnly($this->schema->key, $clean); } /** @@ -1258,83 +1963,63 @@ private static function loadDirectory(string $path, RecordSchema $schema, ?strin ]; } - if (! is_array($payload)) { - continue; + $problem = $payload === null ? $file->readOnlyReason : null; + + if ($problem === null && $schema->recordClass !== null) { + if (is_array($payload) && is_array($payload['data'] ?? null) + && ltrim(strval($payload['class'] ?? ''), '\\') === $schema->recordClass) { + $payload = $payload['data']; + } else { + $problem = sprintf( + "%s does not return ['class' => %s::class, 'data' => [...]]", + basename($filename), + substr(strrchr('\\' . $schema->recordClass, '\\') ?: '', 1), + ); + } } - $records[] = new ProjectRecord($payload, false, $filename, $stem, $file); - } - - return new self($schema, $path, $records, null, readOnlyReason: $schema->isAlwaysReadOnly ? $schema->readOnlyNote : null); - } - - /** - * Loads a category stored as a flattened config.php subtree. - * - * @param string $path The config.php path. - * @param RecordSchema $schema The category schema. - * @return self - */ - private static function loadConfigSubtree(string $path, RecordSchema $schema, ?string $projectRoot = null): self - { - $file = PhpDataFile::load($path, $projectRoot); - $payload = is_array($file->payload) ? $file->payload : []; - $records = []; - - foreach ($schema->configPath as $root) { - if (! is_array($payload[$root] ?? null)) { + if ($problem !== null) { + // A file that cannot be read as a record stays in the list, + // read-only with the reason, rather than vanishing from it. + $records[] = new ProjectRecord([$schema->labelKey => $stem], false, $filename, $stem, $file, $problem); continue; } - foreach (self::flattenLeaves($payload[$root], $root) as $leafPath => $value) { - $records[] = new ProjectRecord(['path' => $leafPath, 'value' => $value]); + if (! is_array($payload)) { + continue; } - } - $reason = $schema->isAlwaysReadOnly ? $schema->readOnlyNote : $file->readOnlyReason; - - if ($reason === null && ! $file->exists) { - $reason = sprintf('%s does not exist in this project', basename($path)); + $records[] = new ProjectRecord($payload, false, $filename, $stem, $file); } - return new self($schema, $path, $records, $file, readOnlyReason: $reason, rootPayload: $payload); + return new self($schema, $path, $records, null, projectRoot: $projectRoot); } /** - * Lists the files in a directory without evaluating any of them. + * Loads a category stored as flattened settings of a project settings file: config.php, or the menu + * presentation catalog. The file's one owner is shared by every category over it. * - * @param string $path The directory path. + * @param string $path The settings file path. * @param RecordSchema $schema The category schema. * @return self */ - private static function loadFileListing(string $path, RecordSchema $schema): self + private static function loadConfigSubtree(string $path, RecordSchema $schema, ?string $projectRoot = null, ?ProjectSettingsFile $settings = null): self { - $records = []; - $files = is_dir($path) ? (glob($path . DIRECTORY_SEPARATOR . '*.php') ?: []) : []; - sort($files); - - foreach ($files as $filename) { - $source = (string) file_get_contents($filename); - $kind = match (true) { - preg_match('/^\s*enum\s+/m', $source) === 1 => 'PHP enum', - preg_match('/^\s*(final\s+)?(readonly\s+)?class\s+/m', $source) === 1 => 'PHP class', - preg_match('/^\s*interface\s+/m', $source) === 1 => 'PHP interface', - default => 'data file', - }; - - $records[] = new ProjectRecord( - [ - 'file' => basename($filename), - 'kind' => $kind, - 'lines' => substr_count($source, "\n") + 1, - ], - false, - $filename, - basename($filename, '.php'), - ); - } + $projectRoot ??= dirname($path); + $settings ??= self::createSettingsFile($projectRoot, $schema->relativePath); + return new self($schema, $path, $settings->getSettingRecords($schema->configPath), + readOnlyReason: $settings->getReadOnlyReason(), settings: $settings); + } - return new self($schema, $path, $records, null, readOnlyReason: $schema->readOnlyNote); + /** The owner of a settings file, by where it lives in a project. */ + public static function createSettingsFile(string $projectRoot, string $relativePath): ProjectSettingsFile + { + return match ($relativePath) { + 'config.php' => new ProjectConfig($projectRoot), + MenuPresentationSettings::RELATIVE_PATH => new MenuPresentationSettings($projectRoot), + \Ichiloto\Editor\FieldPresentationSettings::RELATIVE_PATH => new \Ichiloto\Editor\FieldPresentationSettings($projectRoot), + default => throw new RuntimeException(sprintf('%s has no settings owner.', $relativePath)), + }; } /** @@ -1347,16 +2032,13 @@ private static function loadFileListing(string $path, RecordSchema $schema): sel */ private static function resolveReadOnlyReason(RecordSchema $schema, PhpDataFile $file, array $records): ?string { - if ($schema->isAlwaysReadOnly) { - return $schema->readOnlyNote; - } - if ($file->readOnlyReason !== null) { return $file->readOnlyReason; } if ($schema->projection !== null && $file->exists) { - $projectionIssue = self::projectionPreservationIssueFor($schema->projection, $file->payload); + $projectionIssue = self::projectionPreservationIssueFor($schema->projection, $file->payload) + ?? self::findOwnedKeySourceIssue($schema->projection, $file); if ($projectionIssue !== null) { return sprintf('%s %s', basename($file->path), $projectionIssue); @@ -1374,6 +2056,54 @@ private static function resolveReadOnlyReason(RecordSchema $schema, PhpDataFile return null; } + /** + * Why a projection's own keys cannot be written as data in the file's source: the file builds one, or an array + * inside one, in a variable with its own code (a loop, a required file), which the source writer refuses to + * replace with a literal of what the code produced. Variables written as data are edited where they are written. + */ + private static function findOwnedKeySourceIssue(RecordProjection $projection, PhpDataFile $file): ?string + { + if (! $projection instanceof OwnsFileKeys || ! is_file($file->path) || ! is_array($file->payload)) { + return null; + } + try { + $document = PhpArraySourceDocument::parse((string) file_get_contents($file->path)); + } catch (SourceUnreadable) { + // A file that is not one returned array is saved by regeneration, which PhpDataFile already guards. + return null; + } + foreach ($projection->listOwnedKeys() as $key) { + $entry = $document->entryAt([$key]); + $variable = $entry === null ? null : self::findArrayVariable($document, $entry->value, $file->payload[$key] ?? null); + if ($variable !== null) { + return sprintf('builds "%s" with its own PHP code in $%s, which the editor will not replace with plain data; write it as data to edit it here', $key, $variable); + } + } + + return null; + } + + /** The first variable under a source node that the file builds in code and that holds an array, or null. */ + private static function findArrayVariable(PhpArraySourceDocument $document, SourceNode $node, mixed $value): ?string + { + if ($document->isBuiltInCode($node)) { + return is_array($value) ? ($node->variable ?? '') : null; + } + $node = $document->resolveNode($node); + if ($node->kind !== SourceNode::ARRAY || ! is_array($value)) { + return null; + } + foreach ($node->entries as $position => $entry) { + $key = $node->isList ? $position : $entry->key; + $found = (is_int($key) || is_string($key)) && array_key_exists($key, $value) ? self::findArrayVariable($document, $entry->value, $value[$key]) : null; + if ($found !== null) { + return $found; + } + } + + return null; + } + /** * Checks both projection-specific malformed shapes and the exact * read/write round trip. The latter is the final guard against a new @@ -1428,47 +2158,43 @@ private static function withoutEmptyFields(array $payload): array } /** - * Flattens a nested config subtree into dotted leaf paths. + * Re-reads the backing file so this category composes against what is + * actually there, not what it held when this category loaded. * - * @param array $tree The subtree. - * @param string $prefix The path prefix. - * @return array Leaf values keyed by dotted path. + * @return void */ - private static function flattenLeaves(array $tree, string $prefix): array + private function rereadBackingFile(): void { - $leaves = []; - - foreach ($tree as $key => $value) { - $path = $prefix . '.' . $key; - - if (is_array($value) && $value !== []) { - $leaves += self::flattenLeaves($value, $path); - continue; - } - - if (is_array($value) || is_object($value)) { - continue; - } - - $leaves[$path] = $value; + if (! $this->file instanceof PhpDataFile || ! is_file($this->file->path)) { + return; } - return $leaves; + $this->file = PhpDataFile::load($this->file->path, $this->projectRoot); + $this->learnKeyOrder(); } /** - * Re-reads the backing file so this category composes against what is - * actually there, not what it held when this category loaded. - * - * @return void + * Learns where a projected file holds its top-level keys: a key seen for + * the first time goes ahead of the next key the file holds after it that + * is already known, and a known key keeps the place it was first seen in. */ - private function rereadBackingFile(): void + private function learnKeyOrder(): void { - if (! $this->file instanceof PhpDataFile || ! is_file($this->file->path)) { + if ($this->schema->projection === null || ! is_array($this->file?->payload)) { return; } - $this->file = PhpDataFile::load($this->file->path, $this->projectRoot); + $keys = array_keys($this->file->payload); + + foreach ($keys as $position => $key) { + if (in_array($key, $this->authoredKeyOrder, true)) { + continue; + } + + $anchor = array_find(array_slice($keys, $position + 1), fn(int|string $next): bool => in_array($next, $this->authoredKeyOrder, true)); + $at = $anchor === null ? count($this->authoredKeyOrder) : (int) array_search($anchor, $this->authoredKeyOrder, true); + array_splice($this->authoredKeyOrder, $at, 0, [$key]); + } } /** @@ -1498,15 +2224,91 @@ public function backingFilePath(): string return realpath($this->path) ?: $this->path; } - /** - * Returns the project this category belongs to, for a caller that has to - * read the backing file the same way this category does. - * - * @return string|null The project root. - */ - public function projectRoot(): ?string - { - return $this->projectRoot; + /** + * What one of this category's record files holds as the category stands: + * a dirty record's file as a save would write it, or a deleted record's + * file as gone. Null when the category is not kept a file per record or + * holds no unsaved change for the file, so the file on disk is current. + * + * @return array{exists: bool, payload: mixed}|null + */ + public function readCurrentRecordFile(string $path): ?array + { + if ($this->schema->storage !== RecordStorage::DIRECTORY) { + return null; + } + $path = self::canonicalizePath($path); + foreach ($this->stagedDeletions as $deleted) { + if (self::canonicalizePath($deleted) === $path) { + return ['exists' => false, 'payload' => null]; + } + } + foreach ($this->records as $record) { + if ($record->file instanceof PhpDataFile && $record->isDirty() && self::canonicalizePath($record->file->path) === $path) { + return ['exists' => true, 'payload' => $this->composeRecordFilePayload($record)]; + } + } + + return null; + } + + /** A path as one file is known by, whether or not it exists yet. */ + public static function canonicalizePath(string $path): string + { + $folder = realpath(dirname($path)); + + return $folder === false ? $path : $folder . DIRECTORY_SEPARATOR . basename($path); + } + + /** + * Returns the project this category belongs to, for a caller that has to + * read the backing file the same way this category does. + * + * @return string|null The project root. + */ + public function projectRoot(): ?string + { + return $this->projectRoot; + } + + /** + * Puts each top-level key a fold added back where the file had it when + * first read: ahead of the next key it preceded then that the payload + * still holds. Keys the payload already held keep their order, and a key + * the file never had stays where the fold put it. + * + * @param array $before The payload before the fold. + * @param array $folded The payload after it. + * @return array + */ + private function placeReturningKeys(array $before, array $folded): array + { + $added = array_diff_key($folded, $before); + + if ($added === [] || $this->authoredKeyOrder === []) { + return $folded; + } + + $placed = array_diff_key($folded, $added); + + foreach ($added as $key => $value) { + $position = array_search($key, $this->authoredKeyOrder, true); + $anchor = $position === false ? null : array_find( + array_slice($this->authoredKeyOrder, $position + 1), + static fn(int|string $next): bool => array_key_exists($next, $placed), + ); + + if ($anchor === null) { + $placed[$key] = $value; + + continue; + } + + $at = (int) array_search($anchor, array_keys($placed), true); + $placed = array_slice($placed, 0, $at, true) + [$key => $value] + array_slice($placed, $at, null, true); + } + + return $placed; } /** @@ -1532,10 +2334,10 @@ public function projectRoot(): ?string public function foldInto(array $whole): array { if ($this->schema->projection !== null) { - return $this->schema->projection->write($whole, array_map( + return $this->placeReturningKeys($whole, $this->schema->projection->write($whole, array_map( static fn(ProjectRecord $record): array => (array) $record->toArray(), $this->getRecords(), - )); + ))); } $records = $this->getRecords(); @@ -2272,62 +3074,133 @@ private function mergeIntoFilePayload(): array * * @return void */ - private function saveDirectory(): void + /** + * What a one-file-per-record category writes for a record: its command + * list bare for an event script, its data in its envelope for a record + * class, the record itself otherwise. + */ + private function composeRecordFilePayload(ProjectRecord $record): mixed { - if (! is_dir($this->path) && ! mkdir($this->path, 0777, true) && ! is_dir($this->path)) { - throw new RuntimeException("Unable to create {$this->path}."); - } + $payload = $record->toArray(); - foreach ($this->records as $record) { - $file = $record->file; + if ($this->schema->listPayloadKey !== null && is_array($payload)) { + $payload = array_values((array) ($payload[$this->schema->listPayloadKey] ?? [])); + } - if (! $file instanceof PhpDataFile || ! $record->isDirty()) { - // A clean record's file already holds this content; skipping - // it keeps authored formatting and mtimes untouched. - continue; - } + if ($this->schema->recordClass !== null) { + $payload = ['class' => $this->schema->recordClass, 'data' => $payload]; + } - $payload = $record->toArray(); + return $payload; + } - if ($this->schema->listPayloadKey !== null && is_array($payload)) { - $payload = array_values((array) ($payload[$this->schema->listPayloadKey] ?? [])); - } + /** + * Refuses an edit the record's file could not be saved with - a value + * the author wrote as an expression the editor cannot rewrite - by working + * out exactly what a save would write, and writing nothing. + * + * @throws \Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal|SourceIdentityConflict|RuntimeException When a save would be refused. + */ + public function assertSourceAccepts(int $index): void + { + $record = $this->getRecordByIndex($index); - $file->save($payload); + if (! $record instanceof ProjectRecord || ! $record->isDirty()) { + return; } - foreach ($this->stagedDeletions as $path) { - @unlink($path); - } + match ($this->schema->storage) { + RecordStorage::LIST_FILE => SharedFileTransaction::preview([$this]), + RecordStorage::DIRECTORY => $record->file?->composeContents($this->composeRecordFilePayload($record)), + RecordStorage::CONFIG_SUBTREE => $this->settings?->getSource(), + default => null, + }; + } - $this->stagedDeletions = []; + /** Reuse the source writer's conflict gate for categories that exclusively own a file. */ + public function assertUnchangedSource(): void + { + if (! $this->schema->requireUnchangedSource || $this->file === null) { + return; + } + try { + if (! $this->file->exists && is_file($this->file->path)) { + throw new RuntimeException('The source was created outside the editor; reload before saving.'); + } + $this->file->composeContents($this->mergeIntoFilePayload(), checkDisk: true); + } catch (RuntimeException $refused) { + throw new \Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal($refused->getMessage(), previous: $refused); + } } /** - * Writes edited terms back into the project config file. + * The save of a category kept a file per record: each dirty record's + * file, and the files of records it deleted. * - * @return void + * @param \Closure(): void $clean Cleans the category once its files are installed. */ - private function saveConfigSubtree(): void + private function prepareDirectorySave(\Closure $clean): PreparedSave { - if (! $this->file instanceof PhpDataFile) { - throw new RuntimeException('No backing config file to save.'); - } - - $payload = is_array($this->rootPayload) ? $this->rootPayload : []; + $writes = []; foreach ($this->records as $record) { - $path = $record->getDisplayValue('path'); + $file = $record->file; - if ($path === '') { + if (! $file instanceof PhpDataFile || ! $record->isDirty()) { + // A clean record's file already holds this content; skipping + // it keeps authored formatting and mtimes untouched. continue; } - $payload = self::writeNested($payload, explode('.', $path), $record->get('value')); + $payload = $this->composeRecordFilePayload($record); + $writes[] = [$file, $payload, $file->composeContents($payload, checkDisk: true)]; } - $this->file->save($payload); - $this->rootPayload = $payload; + // A category that checks its records through the Engine refuses one it could not read before anything is written. + foreach ($writes as [$file, $payload]) { + if ($this->schema->saveCheck !== null + && ($reason = ($this->schema->saveCheck)(is_array($payload) ? $payload : [], (string) $this->projectRoot())) !== null) { + throw new RecordRefusal(sprintf('%s: %s', basename($file->path), $reason)); + } + } + + // A record written where a deleted one lived replaces it. + $written = array_map(static fn(array $write): string => $write[0]->path, $writes); + $deletions = array_values(array_diff($this->stagedDeletions, $written)); + + $projectRoot = $this->projectRoot; + + return new PreparedSave( + $this->schema->key, + static function (FileSetTransaction $transaction) use ($writes, $deletions): void { + foreach ($writes as [$file, , $contents]) { + $file->expectUnchanged($transaction); + $transaction->write($file->path, $contents); + } + foreach ($deletions as $path) { + $transaction->remove($path); + } + }, + // Each staged file, evaluated as the game reads it, must be the record the editor holds. + static function (array $staged) use ($writes, $projectRoot): void { + foreach ($writes as [$file, $payload]) { + $path = $staged[$file->path] ?? $file->path; + $read = static fn(): mixed => require $path; + $evaluated = $projectRoot !== null && is_dir($projectRoot) ? ProjectDirectoryContext::run($projectRoot, static fn(): mixed => $read()) : $read(); + if ($evaluated !== $payload) { + throw new RuntimeException(sprintf('%s would not read back as the edited record; nothing was written.', basename($file->path))); + } + } + }, + adopt: function () use ($writes, $clean): void { + foreach ($writes as [$file, $payload, $contents]) { + $file->adoptWritten($payload, $contents); + } + + $this->stagedDeletions = []; + $clean(); + }, + ); } /** @@ -2342,7 +3215,7 @@ private function saveConfigSubtree(): void */ private function setSubField(ProjectRecord $record, RecordSubList $subList, int $entryIndex, string $token, string $rawValue): void { - $entries = $record->getSubList($subList->key); + $entries = $record->getEntries($subList); if (! array_key_exists($entryIndex, $entries)) { return; @@ -2353,12 +3226,23 @@ private function setSubField(ProjectRecord $record, RecordSubList $subList, int continue; } + $before = $entries[$entryIndex]; + $value = $this->coerceReferenceField($record, $field, $rawValue, + [...explode('.', $subList->key), $entryIndex, ...explode('.', $field->key)]); + self::assertKeyAvailable($subList, $entries, $entryIndex, $field, $value); $entries[$entryIndex] = self::writeNested( $entries[$entryIndex], explode('.', $field->key), - self::coerce($field, $rawValue), + $value, ); - $record->setSubList($subList->key, $entries); + $entries[$entryIndex] = $subList->removeConflictingFields($entries[$entryIndex], $field->key); + + if ($field->key === $subList->variantKey) { + // A new type keeps nothing only the old one read. + $entries[$entryIndex] = self::withoutStaleVariantFields($subList, $before, $entries[$entryIndex]); + } + + $this->writeRecordEntries($record, $subList, $entries); $this->touchState(); return; @@ -2375,18 +3259,19 @@ private function setNestedSubField( string $fieldToken, string $rawValue, ): void { - $entries = $record->getSubList($subList->key); + $entries = $record->getEntries($subList); $entry = $entries[$entryIndex] ?? null; if (! is_array($entry)) { return; } - $written = self::withNestedFieldWritten($subList, $entry, $nestedPrefixToken, $nestedIndex, $fieldToken, $rawValue); + $written = $this->withNestedFieldWritten($record, $subList, $entry, $nestedPrefixToken, $nestedIndex, $fieldToken, $rawValue, + [...explode('.', $subList->key), $entryIndex]); if ($written !== null) { $entries[$entryIndex] = $written; - $record->setSubList($subList->key, $entries); + $this->writeRecordEntries($record, $subList, $entries); $this->touchState(); } } @@ -2399,6 +3284,7 @@ private function setNestedSubField( * frame's commands both own their nested lists this way. * * @param RecordSubList $subList The list the entry belongs to. + * @param ProjectRecord $record The live owning record that constrains local references. * @param array $entry The entry. * @param string $nestedPrefixToken The nested list's prefix as it appears in the field id. * @param int $nestedIndex The nested entry. @@ -2406,13 +3292,15 @@ private function setNestedSubField( * @param string $rawValue The raw edited value. * @return array|null The rewritten entry. */ - private static function withNestedFieldWritten( + private function withNestedFieldWritten( + ProjectRecord $record, RecordSubList $subList, array $entry, string $nestedPrefixToken, int $nestedIndex, string $fieldToken, string $rawValue, + array $ownerPath = [], ): ?array { $nestedList = $subList->nestedListFor($entry); @@ -2423,7 +3311,7 @@ private static function withNestedFieldWritten( return null; } - $nestedEntries = array_values((array) ($entry[$nestedList->key] ?? [])); + $nestedEntries = $nestedList->readEntries(self::readNested($entry, $nestedList->key)); if (! is_array($nestedEntries[$nestedIndex] ?? null)) { return null; @@ -2434,12 +3322,20 @@ private static function withNestedFieldWritten( continue; } + $value = $this->coerceReferenceField($record, $field, $rawValue, + [...$ownerPath, ...explode('.', $nestedList->key), $nestedIndex, ...explode('.', $field->key)]); + self::assertKeyAvailable($nestedList, $nestedEntries, $nestedIndex, $field, $value); + $before = $nestedEntries[$nestedIndex]; $nestedEntries[$nestedIndex] = self::writeNested( $nestedEntries[$nestedIndex], explode('.', $field->key), - self::coerce($field, $rawValue), + $value, ); - $entry[$nestedList->key] = $nestedEntries; + $nestedEntries[$nestedIndex] = $nestedList->removeConflictingFields($nestedEntries[$nestedIndex], $field->key); + if ($field->key === $nestedList->variantKey) { + $nestedEntries[$nestedIndex] = self::withoutStaleVariantFields($nestedList, $before, $nestedEntries[$nestedIndex]); + } + $entry = self::writeNested($entry, explode('.', $nestedList->key), $nestedList->writeEntries($nestedEntries)); return $entry; } @@ -2466,7 +3362,7 @@ public function frameNestedContext(int $recordIndex, array $framePath, string $f } $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); if ($commands === null || $frameList === null) { return null; @@ -2484,6 +3380,10 @@ public function frameNestedContext(int $recordIndex, array $framePath, string $f return null; } + if (! self::isNestedListRow($frameList->prefix, $parentIndex, $nestedList, $fieldId)) { + return null; + } + $nestedIndex = null; $pattern = '/^' . preg_quote($frameList->prefix, '/') @@ -2498,6 +3398,86 @@ public function frameNestedContext(int $recordIndex, array $framePath, string $f return ['parentIndex' => $parentIndex, 'nestedIndex' => $nestedIndex, 'list' => $nestedList]; } + /** + * Whether a row adds to and removes from a nested list: any row of its + * parent entry, or only its heading and its own entries' rows where the + * list is headed. + */ + private static function isNestedListRow(string $prefix, int $parentIndex, RecordSubList $nestedList, string $fieldId): bool + { + return $nestedList->heading === '' || str_starts_with($fieldId, $prefix . $parentIndex . ucfirst($nestedList->prefix)); + } + + /** + * Returns the item a settings row belongs to, to add after or remove: + * the nested entry (a route step, a dialogue line, a lane) the row + * edits, the choice option it names, or else the entry or command it is + * part of. The field ids carry the frame list's prefix, as the rows were + * made with it. The record's own rows, headings and an empty frame's + * placeholder belong to none. + * + * @param int $recordIndex The record. + * @param array $framePath The frame the row is in; [] for the record's own list. + * @param string $fieldId The row's field id. + * @return RecordItem|null The item, or null when the row belongs to none. + */ + public function locateItem(int $recordIndex, array $framePath, string $fieldId): ?RecordItem + { + if ($framePath === []) { + foreach ($this->schema->getInlineSubLists($this->getRecordByIndex($recordIndex)?->toArray()) as $inlineList) { + if ($inlineList === $this->schema->subList) { continue; } + if ($fieldId === $inlineList->prefix . 'List') { + return new RecordItem(RecordItem::LIST, [], -1, null, $inlineList->heading, RecordItem::ENTRY, $inlineList->singular, $inlineList->key); + } + + if (preg_match('/^' . preg_quote($inlineList->prefix, '/') . '(\d+)[A-Za-z]/', $fieldId, $matches) === 1) { + return new RecordItem(RecordItem::ENTRY, [], intval($matches[1]), null, $inlineList->singular, listKey: $inlineList->key); + } + } + } + + $list = $this->getFrameSubList($framePath); + $entries = $this->getFrameCommands($recordIndex, $framePath); + + if ($list === null || $entries === null || preg_match('/^' . preg_quote($list->prefix, '/') . '(\d+)/', $fieldId, $matches) !== 1) { + return null; + } + + $entryIndex = intval($matches[1]); + $entry = $entries[$entryIndex] ?? null; + + if (! is_array($entry)) { + return null; + } + + $nested = $this->frameNestedContext($recordIndex, $framePath, $fieldId); + + if ($nested !== null && $nested['nestedIndex'] !== null) { + return new RecordItem(RecordItem::NESTED, $framePath, $entryIndex, $nested['nestedIndex'], $nested['list']->singular); + } + + if (preg_match('/^' . preg_quote($list->prefix . $entryIndex, '/') . 'Option(\d+)/', $fieldId, $option) === 1) { + return new RecordItem(RecordItem::OPTION, $framePath, $entryIndex, intval($option[1]), 'option'); + } + + $nestedList = $list->nestedListFor($entry); + $isChoice = $list->getVariant($entry) === 'choice'; + + return new RecordItem( + RecordItem::ENTRY, + $framePath, + $entryIndex, + null, + $list->singular, + match (true) { + $nestedList !== null => RecordItem::NESTED, + $isChoice => RecordItem::OPTION, + default => null, + }, + $nestedList?->singular ?? ($isChoice ? 'option' : null), + ); + } + /** * Returns how many nested entries a frame command owns. * @@ -2513,11 +3493,11 @@ public function countFrameNestedItems(int $recordIndex, array $framePath, int $p } $commands = $this->getFrameCommands($recordIndex, $framePath) ?? []; - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); $parent = $commands[$parentIndex] ?? null; $nestedList = is_array($parent) && $frameList !== null ? $frameList->nestedListFor($parent) : null; - return $nestedList === null ? 0 : count(array_values((array) ($parent[$nestedList->key] ?? []))); + return $nestedList === null ? 0 : count($nestedList->readEntries(self::readNested($parent, $nestedList->key))); } /** @@ -2533,19 +3513,12 @@ public function countFrameNestedItems(int $recordIndex, array $framePath, int $p public function addFrameNestedItem(int $recordIndex, array $framePath, int $parentIndex, ?array $entry = null, ?int $at = null): ?int { if ($framePath === []) { - if ($at === null) { - return $this->addNestedSubItem($recordIndex, $parentIndex, $entry); - } - - $blank = $entry ?? $this->nestedListForParent($recordIndex, $parentIndex)['list']->blank ?? []; - $this->insertNestedSubItem($recordIndex, $parentIndex, $at, $blank); - - return $at; + return $this->addNestedSubItem($recordIndex, $parentIndex, $entry, $at); } $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); // Without a root sub-list (a summon's tracks), the frame's list // is the one the write goes through. $rootList = $this->schema->subList ?? $frameList; @@ -2556,10 +3529,10 @@ public function addFrameNestedItem(int $recordIndex, array $framePath, int $pare return null; } - $nestedEntries = array_values((array) ($parent[$nestedList->key] ?? [])); + $nestedEntries = $nestedList->readEntries(self::readNested($parent, $nestedList->key)); $position = $at === null ? count($nestedEntries) : max(0, min(count($nestedEntries), $at)); - array_splice($nestedEntries, $position, 0, [$entry ?? $nestedList->blank]); - $commands[$parentIndex][$nestedList->key] = $nestedEntries; + array_splice($nestedEntries, $position, 0, [self::withUniqueKey($nestedList, $nestedEntries, $entry ?? $nestedList->blank)]); + $commands[$parentIndex] = self::writeNested($parent, explode('.', $nestedList->key), $nestedList->writeEntries($nestedEntries)); $this->writeFrameCommands($record, $rootList, $framePath, $commands); return $position; @@ -2582,7 +3555,7 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath); + $frameList = $this->getFrameSubList($framePath); $rootList = $this->schema->subList ?? $frameList; $parent = $commands[$parentIndex] ?? null; $nestedList = is_array($parent) && $frameList !== null ? $frameList->nestedListFor($parent) : null; @@ -2591,7 +3564,7 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p return null; } - $nestedEntries = array_values((array) ($parent[$nestedList->key] ?? [])); + $nestedEntries = $nestedList->readEntries(self::readNested($parent, $nestedList->key)); if (! is_array($nestedEntries[$nestedIndex] ?? null)) { return null; @@ -2604,9 +3577,9 @@ public function removeFrameNestedItem(int $recordIndex, array $framePath, int $p // entry leaves the command as it was created rather than with an // empty list the runtime reads the same and the fingerprint does // not. - unset($commands[$parentIndex][$nestedList->key]); + $commands[$parentIndex] = self::writeNested($parent, explode('.', $nestedList->key), null); } else { - $commands[$parentIndex][$nestedList->key] = $nestedEntries; + $commands[$parentIndex] = self::writeNested($parent, explode('.', $nestedList->key), $nestedList->writeEntries($nestedEntries)); } $this->writeFrameCommands($record, $rootList, $framePath, $commands); @@ -2628,7 +3601,7 @@ private function nestedListForParent(int $index, int $parentIndex): ?array return null; } - $entries = $record->getSubList($subList->key); + $entries = $record->getEntries($subList); $parent = $entries[$parentIndex] ?? null; $nestedList = is_array($parent) ? $subList->nestedListFor($parent) : null; @@ -2660,8 +3633,9 @@ private function writeNestedSubList(array $context, array $nestedEntries): void } $entries = $context['entries']; - $entries[$context['parentIndex']][$context['list']->key] = array_values($nestedEntries); - $context['record']->setSubList($subList->key, $entries); + $entries[$context['parentIndex']] = self::writeNested($context['parent'], explode('.', $context['list']->key), + $nestedEntries === [] && $context['list']->removeWhenEmpty ? null : $context['list']->writeEntries($nestedEntries)); + $this->writeRecordEntries($context['record'], $subList, $entries); $this->touchState(); } @@ -2673,9 +3647,11 @@ private function writeNestedSubList(array $context, array $nestedEntries): void * @param string $fieldId The settings-field identifier. * @param bool $isEditable Whether the category and record accept edits. * @param string|null $label An override label (used for sub-list rows). + * @param mixed $authored The field's authored value, for a list its line may not carry whole. * @return array */ - private static function describeField(RecordField $field, string $value, string $fieldId, bool $isEditable, ?string $label = null): array + private static function describeField(RecordField $field, string $value, string $fieldId, bool $isEditable, ?string $label = null, + mixed $authored = null): array { $descriptor = [ 'label' => $label ?? $field->label, @@ -2683,7 +3659,16 @@ private static function describeField(RecordField $field, string $value, string 'field' => $fieldId, ]; + if (($issue = self::findCarryIssue($field, $authored)) !== null) { + // Shown as authored and left alone: committing the line would lose what it cannot carry. + return [...$descriptor, 'value' => '! ' . $issue, 'editable' => false]; + } + if (! $isEditable || $field->isReadOnly) { + // Said outright, so the pane never opens an editor on a value the + // record would refuse to take. + $descriptor['editable'] = false; + return $descriptor; } @@ -2795,6 +3780,27 @@ public static function summarizeLines(string $value): string private static function coerce(RecordField $field, string $rawValue): mixed { + if ($field->codec === RecordFieldCodec::PHYSICAL_FOOTPRINT) { + return trim($rawValue) === '' && $field->removeWhenEmpty ? null + : \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decode($rawValue); + } + if ($field->codec === RecordFieldCodec::PIECE_DOORWAY) { + $cells = \Ichiloto\Editor\Maps\PieceDoorwayCodec::decode($rawValue); + + return $cells === [] && $field->removeWhenEmpty ? null : $cells; + } + if ($field->codec === RecordFieldCodec::SOURCE_RECT) { + if (trim($rawValue) === '' && $field->removeWhenEmpty) { + return null; + } + $parts = array_map(trim(...), explode(',', $rawValue)); + if (count($parts) !== 4 || ! array_all($parts, static fn(string $part): bool => preg_match('/\A\d+\z/', $part) === 1)) { + throw new \InvalidArgumentException(sprintf('%s requires four whole numbers: x, y, width, height.', $field->label)); + } + $rect = new \Ichiloto\Engine\Rendering\Presentation\SpriteSourceRect(...array_map(intval(...), $parts)); + + return ['x' => $rect->x, 'y' => $rect->y, 'width' => $rect->width, 'height' => $rect->height]; + } if ($field->type === InputControlType::MULTILINE || $field->codec === RecordFieldCodec::LINES) { // Exact text: every space, backslash and blank line is the // author's. Only a wholly empty block is nothing. @@ -2809,6 +3815,19 @@ private static function coerce(RecordField $field, string $rawValue): mixed $trimmed = trim($rawValue); + if ($field->codec === RecordFieldCodec::TROOP_MEMBER) { + if ($trimmed === '' || $trimmed === 'None') { + if ($field->removeWhenEmpty) { return null; } + throw new \InvalidArgumentException(sprintf('%s must name a member of this troop.', $field->label)); + } + // Chosen as "1 · Djin"; the file holds the member's index. + if (preg_match('/\A(?:Member\s+)?(\d+)/', $trimmed, $match) !== 1 || intval($match[1]) < 1) { + throw new \InvalidArgumentException(sprintf('%s must name a member of this troop by its number.', $field->label)); + } + + return intval($match[1]) - 1; + } + if ($field->codec === RecordFieldCodec::POINT) { if ($trimmed === '' && $field->removeWhenEmpty) { return null; @@ -2817,14 +3836,86 @@ private static function coerce(RecordField $field, string $rawValue): mixed $parts = array_map(trim(...), explode(',', $trimmed)); if (count($parts) !== 2 || ! is_numeric($parts[0]) || ! is_numeric($parts[1])) { - throw new \InvalidArgumentException(sprintf('%s must be two numbers, x and y, separated by a comma.', $field->label)); + throw new \InvalidArgumentException(sprintf('%s must be two whole numbers separated by a comma.', $field->label)); } return [intval($parts[0]), intval($parts[1])]; } + if ($field->codec === RecordFieldCodec::BATTLE_TEST) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $decoded = json_decode($trimmed, true); + + if (! is_array($decoded)) { + throw new \InvalidArgumentException(sprintf('%s must be a battle test, written as a JSON object.', $field->label)); + } + + // The Engine's own reading: a shape it would refuse is refused here, before anything is written. + $test = ProjectBattleTest::fromArray($decoded)->toArray(); + + return $test === [] && $field->removeWhenEmpty ? null : $test; + } + + if ($field->codec === RecordFieldCodec::STAGE_SLOT) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $parts = array_map(trim(...), explode(',', $trimmed)); + + if (! in_array(count($parts), [4, 5], true) || array_filter($parts, static fn(string $part): bool => ! is_numeric($part)) !== []) { + throw new \InvalidArgumentException(sprintf('%s must be four numbers, x, depth, width and height, and optionally a display scale.', $field->label)); + } + + // Whole numbers stay integers, as an author writes them. + $numbers = array_map(static fn(string $part): int|float => floor((float) $part) === (float) $part ? (int) $part : (float) $part, $parts); + $slot = array_combine(array_slice(['x', 'depth', 'width', 'height', 'displayScale'], 0, count($numbers)), $numbers); + // The Engine's own place rules decide what a battle accepts. + BattleStageSlot::fromArray($slot, $field->label); + + return $slot; + } + + if ($field->codec === RecordFieldCodec::COORDINATES || $field->codec === RecordFieldCodec::SIZE) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $parts = array_map(trim(...), explode(',', $trimmed)); + $names = $field->codec === RecordFieldCodec::SIZE ? ['width', 'height'] : ['x', 'y']; + + if (count($parts) !== 2 || ! is_numeric($parts[0]) || ! is_numeric($parts[1]) + || ($field->codec === RecordFieldCodec::SIZE && min(floatval($parts[0]), floatval($parts[1])) <= 0.0)) { + throw new \InvalidArgumentException(sprintf( + $field->codec === RecordFieldCodec::SIZE ? '%s must be two numbers above 0, width and height, separated by a comma.' : '%s must be two numbers, x and y, separated by a comma.', + $field->label, + )); + } + + // Whole numbers stay integers, as an author writes them. + return array_combine($names, array_map(static fn(string $part): int|float => floor((float) $part) === (float) $part ? (int) $part : (float) $part, $parts)); + } + + if ($field->codec === RecordFieldCodec::NORMALIZED_POINT) { + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + + $parts = array_map(trim(...), explode(',', $trimmed)); + + if (count($parts) !== 2 || ! is_numeric($parts[0]) || ! is_numeric($parts[1]) + || min(floatval($parts[0]), floatval($parts[1])) < 0.0 || max(floatval($parts[0]), floatval($parts[1])) > 1.0) { + throw new \InvalidArgumentException(sprintf('%s must be two numbers from 0 to 1, x and y, separated by a comma.', $field->label)); + } + + return ['x' => floatval($parts[0]), 'y' => floatval($parts[1])]; + } + if ($field->codec === RecordFieldCodec::CONDITIONS) { - $conditions = ConditionCodec::decodeAll($trimmed); + $conditions = ConditionCodec::decodeAllStrictly($trimmed); return $conditions === [] && $field->removeWhenEmpty ? null : $conditions; } @@ -2836,7 +3927,7 @@ private static function coerce(RecordField $field, string $rawValue): mixed } if ($field->codec === RecordFieldCodec::WORLD_WRITES) { - $sets = WorldWriteCodec::decodeAll($trimmed); + $sets = WorldWriteCodec::decodeAllStrictly($trimmed); return $sets === [] && $field->removeWhenEmpty ? null : $sets; } @@ -2861,20 +3952,57 @@ private static function coerce(RecordField $field, string $rawValue): mixed return null; } - if ($field->codec === RecordFieldCodec::CSV_LIST) { + if (in_array($field->codec, [RecordFieldCodec::CSV_LIST, RecordFieldCodec::CSV_INTEGERS, RecordFieldCodec::CSV_TOKENS], true)) { $items = array_values(array_filter( array_map(trim(...), explode(',', $trimmed)), static fn(string $item): bool => $item !== '', )); + $isWhole = static fn(string $item): bool => preg_match('/\A-?\d+\z/', $item) === 1; + + if ($field->codec === RecordFieldCodec::CSV_INTEGERS) { + if (array_filter($items, static fn(string $item): bool => ! $isWhole($item)) !== []) { + throw new \InvalidArgumentException(sprintf('%s lists whole numbers separated by commas.', $field->label)); + } + + $items = array_map(intval(...), $items); + } elseif ($field->codec === RecordFieldCodec::CSV_TOKENS) { + $items = array_map(static fn(string $item): int|string => $isWhole($item) ? intval($item) : $item, $items); + } return $items === [] && $field->removeWhenEmpty ? null : $items; } - if ($field->enumClass !== null && is_subclass_of($field->enumClass, BackedEnum::class)) { + if ($field->codec === RecordFieldCodec::SHAPE_TILES) { + if ($trimmed === '') { + return $field->removeWhenEmpty ? null : ''; + } + + if (! str_contains($trimmed, ':')) { + return $trimmed; + } + + $shapes = []; + + foreach (explode(',', $trimmed) as $pair) { + [$shape, $tile] = array_map(trim(...), explode(':', $pair, 2)) + [1 => '']; + $shapes[$shape] = $tile; + } + + if (array_keys($shapes) !== ['horizontal', 'vertical', 'corner'] || in_array('', $shapes, true)) { + throw new \InvalidArgumentException(sprintf('%s is one tile, or one for each shape: horizontal: 5888, vertical: 5890, corner: 5892.', $field->label)); + } + + return $shapes; + } + + if ($field->enumClass !== null && enum_exists($field->enumClass)) { // The stored value is the enum case, not its string: an object // rebuild hands it straight back to a typed constructor argument. + // A case is chosen by its value or by its name, whichever the + // field offers. foreach ($field->enumClass::cases() as $case) { - if (mb_strtolower(strval($case->value)) === mb_strtolower($trimmed)) { + if (($case instanceof BackedEnum && mb_strtolower(strval($case->value)) === mb_strtolower($trimmed)) + || mb_strtolower($case->name) === mb_strtolower($trimmed)) { return $case; } } @@ -2883,6 +4011,12 @@ private static function coerce(RecordField $field, string $rawValue): mixed } if ($field->options !== []) { + // An empty choice of a field that is removed when empty is its + // absence, whether or not '' is offered as an option. + if ($trimmed === '' && $field->removeWhenEmpty) { + return null; + } + foreach ($field->options as $option) { if (mb_strtolower(strval($option)) === mb_strtolower($trimmed)) { return self::coerceScalar($field, strval($option)); @@ -2892,7 +4026,9 @@ private static function coerce(RecordField $field, string $rawValue): mixed return self::coerceScalar($field, $trimmed); } - if ($trimmed === '' && $field->removeWhenEmpty) { + // Clearing the field, or writing back what its absence reads as, is its absence. + if ($field->removeWhenEmpty && ($trimmed === '' + || ($field->displayDefault !== null && mb_strtolower($trimmed) === mb_strtolower($field->displayDefault)))) { return null; } @@ -2908,6 +4044,13 @@ private static function coerce(RecordField $field, string $rawValue): mixed */ private static function displayValue(RecordField $field, mixed $value): string { + // An enum case reads as the field offers it: by value, or by name + // where the field offers names (a value that is not readable text). + if ($value instanceof \UnitEnum && $field->enumClass !== null + && (! $value instanceof BackedEnum || ! in_array(strval($value->value), array_map(strval(...), $field->options), true))) { + return $value->name; + } + // An absent key reads as what the runtime will do with it, and an // empty string as what the runtime means by it, when the schema says // so -- neither is left as a blank for the author to decode. @@ -2920,11 +4063,16 @@ private static function displayValue(RecordField $field, mixed $value): string } return match ($field->codec) { + RecordFieldCodec::PHYSICAL_FOOTPRINT => \Ichiloto\Editor\Maps\PhysicalFootprintCodec::encode($value), + RecordFieldCodec::PIECE_DOORWAY => \Ichiloto\Editor\Maps\PieceDoorwayCodec::encode($value), RecordFieldCodec::CONDITIONS => ConditionCodec::encodeAll(is_array($value) ? $value : []), RecordFieldCodec::AFFINITIES => ElementAffinityCodec::encodeAll(is_array($value) ? $value : []), RecordFieldCodec::WORLD_WRITES => WorldWriteCodec::encodeAll(is_array($value) ? $value : []), RecordFieldCodec::ACTOR_PREDICATES => BattleEntryPredicateCodec::encodeAll(is_array($value) ? $value : []), - RecordFieldCodec::CSV_LIST => implode(', ', array_map(strval(...), is_array($value) ? $value : [])), + RecordFieldCodec::CSV_LIST, RecordFieldCodec::CSV_INTEGERS, RecordFieldCodec::CSV_TOKENS => implode(', ', array_map(strval(...), is_array($value) ? $value : [])), + RecordFieldCodec::SHAPE_TILES => is_array($value) + ? implode(', ', array_map(static fn(string $shape): string => sprintf('%s: %s', $shape, ProjectRecord::stringify($value[$shape] ?? '')), array_keys($value))) + : ProjectRecord::stringify($value), RecordFieldCodec::KEY_VALUES => ParameterMapCodec::encode(is_array($value) ? $value : []), // A sprite authored as one string is one row; as a list, its rows. RecordFieldCodec::LINES => is_array($value) @@ -2933,6 +4081,26 @@ private static function displayValue(RecordField $field, mixed $value): string RecordFieldCodec::POINT => is_array($value) ? implode(', ', array_map(strval(...), array_values($value))) : ProjectRecord::stringify($value), + RecordFieldCodec::TROOP_MEMBER => is_int($value) ? sprintf('Member %d', $value + 1) : 'None', + RecordFieldCodec::BATTLE_TEST => is_array($value) + ? (string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + : ProjectRecord::stringify($value), + RecordFieldCodec::SOURCE_RECT => is_array($value) + ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), ['x', 'y', 'width', 'height'])) + : ProjectRecord::stringify($value), + RecordFieldCodec::STAGE_SLOT => is_array($value) + ? implode(', ', array_map(static fn(string $key): string => ProjectRecord::stringify($value[$key] ?? ''), + array_key_exists('displayScale', $value) ? ['x', 'depth', 'width', 'height', 'displayScale'] : ['x', 'depth', 'width', 'height'])) + : ProjectRecord::stringify($value), + RecordFieldCodec::NORMALIZED_POINT => is_array($value) + ? sprintf('%s, %s', ProjectRecord::stringify($value['x'] ?? ''), ProjectRecord::stringify($value['y'] ?? '')) + : ProjectRecord::stringify($value), + RecordFieldCodec::COORDINATES => is_array($value) + ? sprintf('%s, %s', ProjectRecord::stringify($value['x'] ?? ''), ProjectRecord::stringify($value['y'] ?? '')) + : ProjectRecord::stringify($value), + RecordFieldCodec::SIZE => is_array($value) + ? sprintf('%s, %s', ProjectRecord::stringify($value['width'] ?? ''), ProjectRecord::stringify($value['height'] ?? '')) + : ProjectRecord::stringify($value), RecordFieldCodec::NONE => ProjectRecord::stringify($value), }; } @@ -2956,7 +4124,9 @@ private static function coerceScalar(RecordField $field, string $value): mixed /** * Casts an integer field, dropping the key when the value is zero and the - * schema treats zero as "unset". + * schema treats zero as "unset": its absence reads as zero, or as nothing + * it names. Where absence means something else (a 100% chance, the last + * frame), zero is a value of its own and is kept. * * @param RecordField $field The field schema. * @param string $value The trimmed value. @@ -2966,7 +4136,7 @@ private static function coerceInteger(RecordField $field, string $value): ?int { $number = intval($value); - return $number === 0 && $field->removeWhenEmpty ? null : $number; + return $number === 0 && $field->removeWhenEmpty && ($field->displayDefault ?? '0') === '0' ? null : $number; } /** @@ -3053,7 +4223,9 @@ public function hasCommandFrames(): bool return array_key_exists('choice', $variants) || array_key_exists('branch', $variants) || ($subList !== null && $subList->commandArms !== []) - || $this->schema->commandLists !== []; + || $this->schema->commandLists !== [] + // A list beside the record's own whose entries carry commands (a troop's event pages). + || array_any($this->schema->subLists, static fn(RecordSubList $list): bool => $list->commandArms !== []); } /** @@ -3094,7 +4266,7 @@ public function getFrameCommands(int $recordIndex, array $framePath): ?array * @param array $framePath The frame. * @return RecordSubList|null The list. */ - private function frameSubList(array $framePath): ?RecordSubList + public function getFrameSubList(array $framePath): ?RecordSubList { if ($framePath === []) { return $this->schema->subList; @@ -3106,9 +4278,51 @@ private function frameSubList(array $framePath): ?RecordSubList return $this->schema->commandLists[$rootKey]; } + $root = $rootKey === null ? null : $this->schema->findInlineSubList($rootKey); + + if ($root?->armCommandList !== null) { + // A list whose arms hold their own vocabulary (a troop event page's battle commands). + return $root->armCommandList; + } + // Descended from the sub-list into a command arm: commands from here // down. The catalog's shared command list is what every arm holds. - return RecordSchemaCatalog::eventCommandList('commands'); + return RecordSchemaCatalog::eventCommandList('commands', $this->graphical); + } + + /** + * The frame enclosing one, and the field id prefix of the row in it that + * opened the frame: the record's own list row, or the command (or + * sub-list entry) that holds the arm, for an interface to land back on. + * + * A record-level list is [key]; a list member's arm ends + * [i, listKey, j, armKey] (an option's `then`, a lane's `commands`); a + * branch, sequence or variant arm ends [i, arm]. + * + * @param array $framePath The frame, not the root. + * @return array{frame: list, landing: string} + */ + public function describeEnclosingFrame(array $framePath): array + { + $framePath = array_values($framePath); + $count = count($framePath); + + if ($count === 3 && is_string($framePath[0]) && $this->isSecondarySubList($framePath[0])) { + // An arm of an entry in a list beside the record's own: it closes back to the record. + return ['frame' => [], 'landing' => ($this->schema->findInlineSubList($framePath[0])?->prefix ?? 'command') . intval($framePath[1])]; + } + + $chunk = $count === 1 && is_string($framePath[0]) + ? 1 + : ((is_string($framePath[$count - 3] ?? null) && is_int($framePath[$count - 2] ?? null) && is_string($framePath[$count - 1] ?? null) && is_int($framePath[$count - 4] ?? null)) ? 4 : 2); + $enclosing = array_slice($framePath, 0, max(0, $count - $chunk)); + $enclosingList = $enclosing !== [] && is_string($enclosing[0]) ? ($this->schema->commandLists[$enclosing[0]] ?? null) : null; + $landing = $chunk === 1 + ? 'commandList' . ucfirst(strval($framePath[0] ?? '')) + : (($enclosing === [] ? ($this->schema->subList?->prefix ?? 'command') : ($enclosingList?->prefix ?? 'command')) + . intval($framePath[$count - $chunk] ?? 0)); + + return ['frame' => $enclosing, 'landing' => $landing]; } /** @@ -3124,13 +4338,19 @@ private function splitFramePath(array $framePath): array { $first = $framePath[0] ?? null; - if (is_string($first) && array_key_exists($first, $this->schema->commandLists)) { + if (is_string($first) && (array_key_exists($first, $this->schema->commandLists) || $this->isSecondarySubList($first))) { return [$first, array_slice($framePath, 1)]; } return [$this->schema->subList?->key, $framePath]; } + /** Whether a key names an inline list beside the record's own (a troop's event pages). */ + private function isSecondarySubList(string $key): bool + { + return array_any($this->schema->subLists, static fn(RecordSubList $list): bool => $list->key === $key); + } + /** * Returns the settings rows for one frame of a record's commands. * @@ -3149,7 +4369,7 @@ public function getFrameSettingsFields(int $recordIndex, array $framePath): arra $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $subList = $this->frameSubList($framePath); + $subList = $this->getFrameSubList($framePath); if ($subList === null || ! $record instanceof ProjectRecord || $commands === null) { return []; @@ -3160,7 +4380,8 @@ public function getFrameSettingsFields(int $recordIndex, array $framePath): arra foreach ($commands as $entryIndex => $entry) { if (is_array($entry)) { - $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, $framePath)]; + $fields = [...$fields, ...$this->describeSubEntryFields($subList, $entryIndex, $entry, $isEditable, $framePath, (array) $record->toArray(), + $this->findRecordReferences($record))]; } } @@ -3229,7 +4450,7 @@ public function setFrameField(int $recordIndex, array $framePath, string $fieldI // command's, however the frame was reached), not the schema's // sub-list prefix: an NPC's variants are "variantN…" at the root and // its script's commands "commandN…" inside. - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || $commands === null) { return; @@ -3245,16 +4466,19 @@ public function setFrameField(int $recordIndex, array $framePath, string $fieldI $framePath !== [] && preg_match('/^' . $prefix . '(\\d+)([A-Za-z][A-Za-z0-9]*?)(\\d+)([A-Za-z][A-Za-z0-9]*)$/', $fieldId, $matches) === 1 && is_array($commands[intval($matches[1])] ?? null) + && ! self::hasEntryFieldToken($frameList, $commands[intval($matches[1])], $matches[2] . $matches[3] . $matches[4]) ) { // A nested entry of a command inside the frame (a route step): // the same write the root list gets, at this depth. - $written = self::withNestedFieldWritten( + $written = $this->withNestedFieldWritten( + $record, $frameList, $commands[intval($matches[1])], $matches[2], intval($matches[3]), $matches[4], $rawValue, + [...MovementRouteFields::getFrameOwnerPath($this->schema, $framePath), intval($matches[1])], ); if ($written === null) { @@ -3275,7 +4499,8 @@ public function setFrameField(int $recordIndex, array $framePath, string $fieldI return; } - $written = $this->writeEntryFieldToken($frameList, $entry, $matches[2], $rawValue); + $written = $this->writeEntryFieldToken($record, $frameList, $entry, $matches[2], $rawValue, + [...MovementRouteFields::getFrameOwnerPath($this->schema, $framePath), $entryIndex]); if ($written === null) { return; @@ -3304,7 +4529,7 @@ public function addFrameCommand(int $recordIndex, array $framePath, ?int $afterI $commands = $this->getFrameCommands($recordIndex, $framePath); // A record whose lists are all frames (a summon's tracks and cues) // has no root sub-list; the frame's own list is the one to write. - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || ! $record instanceof ProjectRecord || $commands === null || ! $this->isEditable()) { return null; @@ -3331,7 +4556,7 @@ public function insertFrameCommand(int $recordIndex, array $framePath, int $comm $subList = $this->schema->subList; $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || ! $record instanceof ProjectRecord || $commands === null) { return; @@ -3354,7 +4579,7 @@ public function removeFrameCommand(int $recordIndex, array $framePath, int $comm $subList = $this->schema->subList; $record = $this->getRecordByIndex($recordIndex); $commands = $this->getFrameCommands($recordIndex, $framePath); - $frameList = $this->frameSubList($framePath) ?? $subList; + $frameList = $this->getFrameSubList($framePath) ?? $subList; if ($frameList === null || ! $record instanceof ProjectRecord || $commands === null || ! isset($commands[$commandIndex])) { return null; @@ -3367,14 +4592,15 @@ public function removeFrameCommand(int $recordIndex, array $framePath, int $comm } /** - * Adds an option to a choice command. + * Adds an option to a choice command, at the end or at a position. * * @param int $recordIndex The record. * @param array $framePath The frame the choice sits in. * @param int $commandIndex The choice command. + * @param int|null $at Where to insert it, or null for the end. * @return int|null The new option's index. */ - public function addChoiceOption(int $recordIndex, array $framePath, int $commandIndex): ?int + public function addChoiceOption(int $recordIndex, array $framePath, int $commandIndex, ?int $at = null): ?int { $subList = $this->schema->subList; $record = $this->getRecordByIndex($recordIndex); @@ -3390,11 +4616,12 @@ public function addChoiceOption(int $recordIndex, array $framePath, int $command } $options = array_values((array) ($entry['options'] ?? [])); - $options[] = ['text' => 'New option', 'then' => []]; + $position = $at === null ? count($options) : max(0, min(count($options), $at)); + array_splice($options, $position, 0, [['text' => 'New option', 'then' => []]]); $commands[$commandIndex]['options'] = $options; $this->writeFrameCommands($record, $subList, $framePath, $commands); - return count($options) - 1; + return $position; } /** @@ -3484,6 +4711,13 @@ public function describeFramePath(array $framePath): string return self::describeFrameFrom($relative, ucfirst($rootKey), null); } + if ($rootKey !== null && $this->isSecondarySubList($rootKey)) { + $list = $this->schema->findInlineSubList($rootKey); + + return self::describeFrameFrom($relative, $list?->heading !== '' && $list !== null ? $list->heading : ucfirst($rootKey), + $list === null ? null : ucfirst($list->singular)); + } + $subList = $this->schema->subList; $firstLevel = $subList !== null && $subList->prefix !== 'command' ? ucfirst($subList->singular) : null; @@ -3662,10 +4896,15 @@ private function writeFrameCommands(ProjectRecord $record, RecordSubList $subLis [$rootKey, $relativePath] = $this->splitFramePath($framePath); $rootKey ??= $subList->key; - $record->setSubList( + $draft = clone $record; + $draft->setSubList( $rootKey, self::withFrameList($record->getSubList($rootKey), $relativePath, $commands), + // A record-level list its file leaves out when empty goes with its last entry. + removeWhenEmpty: $this->schema->commandLists[$rootKey]->removeWhenEmpty ?? false, ); + $this->assertProposedRecord($record, $draft); + $record->restorePayload($draft->toArray()); $this->touchState(); } @@ -3674,18 +4913,21 @@ private function writeFrameCommands(ProjectRecord $record, RecordSubList $subLis * * @param RecordSubList $subList The sub-list schema. * @param array $entry The entry payload. + * @param ProjectRecord $record The live owning record that constrains local references. * @param string $token The field token. * @param string $rawValue The raw edited value. * @return array|null The rewritten entry, or null when no field matched. */ - private function writeEntryFieldToken(RecordSubList $subList, array $entry, string $token, string $rawValue): ?array + private function writeEntryFieldToken(ProjectRecord $record, RecordSubList $subList, array $entry, string $token, string $rawValue, array $ownerPath = []): ?array { foreach ($subList->fieldsFor($entry) as $field) { if (self::fieldToken($field->key) !== $token || $field->isReadOnly) { continue; } - $written = self::writeNested($entry, explode('.', $field->key), self::coerce($field, $rawValue)); + $written = self::writeNested($entry, explode('.', $field->key), $this->coerceReferenceField($record, $field, $rawValue, + [...$ownerPath, ...explode('.', $field->key)])); + $written = $subList->removeConflictingFields($written, $field->key); if ($field->key === $subList->variantKey && $written !== null) { $written = self::withoutStaleVariantFields($subList, $entry, $written); @@ -3702,7 +4944,10 @@ private function writeEntryFieldToken(RecordSubList $subList, array $entry, stri * when an entry changes variant: a wait turned into a transfer keeps * nothing of its seconds, and a finalizer command turned from a switch * write into a player move carries no name or value for the Engine's - * strict shapes to refuse. Unknown keys -- neither variant's -- stay. + * strict shapes to refuse. A value is dropped by its whole path, so two + * variants that keep different shapes under one key (a stamped piece's + * glyph rows, a connected piece's glyph per shape) never mix. Unknown + * keys -- neither variant's -- stay. * * @param array $before The entry before the change. * @param array $after The entry with the new variant. @@ -3710,27 +4955,24 @@ private function writeEntryFieldToken(RecordSubList $subList, array $entry, stri */ private static function withoutStaleVariantFields(RecordSubList $subList, array $before, array $after): array { - $roots = static function (array $fields): array { - $keys = []; - - foreach ($fields as $field) { - $keys[] = explode('.', $field->key)[0]; - } + if ($subList->getVariant($before) === $subList->getVariant($after)) { + // The same variant picked again changes nothing. + return $after; + } - return $keys; - }; - $oldRoots = $roots($subList->fieldsFor($before)); - $newRoots = $roots($subList->fieldsFor($after)); + $keys = static fn(array $fields): array => array_map(static fn(RecordField $field): string => $field->key, $fields); + $newKeys = $keys($subList->fieldsFor($after)); $oldNested = $subList->nestedListFor($before); $newNested = $subList->nestedListFor($after); - foreach ($oldRoots as $root) { - if ($root !== $subList->variantKey && ! in_array($root, $newRoots, true)) { - unset($after[$root]); + foreach ($keys($subList->fieldsFor($before)) as $key) { + if ($key !== $subList->variantKey && ! in_array($key, $newKeys, true)) { + $after = self::writeNested($after, explode('.', $key), null); } } - if ($oldNested !== null && ($newNested === null || $newNested->key !== $oldNested->key)) { + if ($oldNested !== null && $newNested !== $oldNested) { + // Another list, even under the same key, holds another shape. unset($after[$oldNested->key]); } @@ -3808,6 +5050,12 @@ private static function fieldToken(string $key): string return (string) preg_replace('/[^A-Za-z0-9]/', '', ucfirst($key)); } + /** Declared fields take precedence over the encoded nested-list shape, even with digits in their names. */ + private static function hasEntryFieldToken(RecordSubList $list, array $entry, string $token): bool + { + return array_any($list->fieldsFor($entry), static fn(RecordField $field): bool => self::fieldToken($field->key) === $token); + } + /** * Returns an identity not already used by another record. * @@ -3853,9 +5101,23 @@ private function makeUniqueName(string $preferred): string return $candidate; } - private function makeUniqueIdentity(string $preferred): string + private function makeUniqueIdentity(mixed $preferred): int|string { $identityKey = $this->schema->identityKey ?? 'id'; + + if (is_int($preferred)) { + // A numeric identity (an animation's id) is the next number after + // the largest any record holds or held this session, so it never + // reuses one something may still name. + $numbers = [...array_filter( + array_map(static fn(ProjectRecord $record): mixed => $record->get($identityKey), $this->getRecords()), + is_int(...), + ), ...$this->retiredIdentities]; + + return $numbers === [] ? $preferred : max($numbers) + 1; + } + + $preferred = strval($preferred); $existing = array_map( static fn(ProjectRecord $record): string => $record->getDisplayValue($identityKey), $this->getRecords(), @@ -3871,6 +5133,80 @@ private function makeUniqueIdentity(string $preferred): string return $candidate; } + /** Whether a record's name is the stem of its own file, as an event script's is. */ + private function isNamedByFile(): bool + { + return $this->schema->storage === RecordStorage::DIRECTORY && $this->schema->identityKey === null + && $this->schema->listPayloadKey !== null; + } + + /** + * Refuses a name a new per-file record cannot take as its file stem: one + * that is not already in id form, or one another record or file has. + * + * @return string The name, as the file stem. + * @throws RecordRefusal With the reason an author reads. + */ + private function requireNewFileName(string $name): string + { + $name = trim($name); + if ($name === '' || Slug::of($name) !== $name) { + throw new RecordRefusal(sprintf('A %s name is written in lower case with hyphens, such as "%s".', + $this->schema->entryNoun, Slug::of($name) ?: 'payday-notice')); + } + $taken = array_any($this->getRecords(), static fn(ProjectRecord $record): bool => $record->recordId === $name) + || is_file($this->path . DIRECTORY_SEPARATOR . $name . '.php'); + if ($taken) { + throw new RecordRefusal(sprintf('There is already an %s named %s.', $this->schema->entryNoun, $name)); + } + + return $name; + } + + /** + * Chooses the file a new per-file record is written to: named after its + * identity, or after the category when it has none, and never an existing + * file. + * + * @param string $identity The record's identity, or an empty string. + * @param array|object $payload The record payload. + * @return array{0: string, 1: string, 2: PhpDataFile, 3: array|object} The file stem, + * path, (not yet written) file and payload. + */ + private function prepareRecordFile(string $identity, array|object $payload): array + { + $slug = Slug::of($identity) ?: 'new-' . Slug::of($this->schema->entryNoun); + $stem = $this->makeUniqueFileStem($this->schema->numberedFiles ? sprintf('%04d-%s', $this->findNextFileNumber(), $slug) : $slug); + $sourcePath = $this->path . DIRECTORY_SEPARATOR . $stem . '.php'; + + if ($this->schema->listPayloadKey !== null && is_array($payload)) { + $payload['__scriptId'] = $stem; + } + + return [$stem, $sourcePath, PhpDataFile::load($sourcePath), $payload]; + } + + /** + * The number after the highest a record file of this category carries, + * on disk or not yet written, so a new record is listed last. + */ + private function findNextFileNumber(): int + { + $stems = [ + ...array_map(static fn(string $file): string => basename($file, '.php'), glob($this->path . DIRECTORY_SEPARATOR . '*.php') ?: []), + ...array_map(static fn(ProjectRecord $record): string => $record->recordId, $this->records), + ]; + $highest = 0; + + foreach ($stems as $stem) { + if (preg_match('/\A(\d+)-/', $stem, $match) === 1) { + $highest = max($highest, (int) $match[1]); + } + } + + return $highest + 1; + } + /** * Returns a file stem not already present in the category directory. * diff --git a/src/Database/ProjectTroopKeeper.php b/src/Database/ProjectTroopKeeper.php new file mode 100644 index 00000000..1f7ebd94 --- /dev/null +++ b/src/Database/ProjectTroopKeeper.php @@ -0,0 +1,42 @@ +projectRoot); + $troops = $workspace->getRecordDatabase('troops') ?? throw new RuntimeException('the project has no troops to add to'); + $enemies = array_values(array_map(strval(...), new ReferenceCatalog($workspace)->valuesFor('enemies'))); + $change = new TroopAuthoring()->addTroop($troops, $members, $enemies); + $troops->save(); + $record = $troops->getRecordByIndex((int) $change->index); + } catch (RecordRefusal|RuntimeException $refused) { + throw new RuntimeException($refused->getMessage(), previous: $refused); + } catch (Throwable $failure) { + throw new RuntimeException('Data/troops.php could not be written: ' . $failure->getMessage(), previous: $failure); + } + + return sprintf('Kept as %s in Troops.', $record === null ? 'a new troop' : $troops->getEntryLabel($record)); + } +} diff --git a/src/Database/Projections/BattlerBindingProjection.php b/src/Database/Projections/BattlerBindingProjection.php new file mode 100644 index 00000000..b4e3157b --- /dev/null +++ b/src/Database/Projections/BattlerBindingProjection.php @@ -0,0 +1,112 @@ +side] ?? null) ? $whole[$this->side] : [] as $identity => $entry) { + if (is_array($entry)) { + $rows[] = [self::IDENTITY => strval($identity), ...$entry]; + } + } + + return $rows; + } + + /** + * @inheritDoc + */ + public function write(array $whole, array $rows): array + { + $entries = []; + + foreach ($rows as $row) { + $identity = trim(strval($row[self::IDENTITY] ?? '')); + if ($identity === '') { + continue; + } + unset($row[self::IDENTITY]); + $entries[$identity] = $row; + } + + if ($entries === []) { + unset($whole[$this->side]); + + return $whole; + } + + $whole[$this->side] = $entries; + + return $whole; + } + + /** @inheritDoc */ + public function preservationIssue(mixed $whole): ?string + { + if (! is_array($whole)) { + return sprintf('returns %s, not an array', get_debug_type($whole)); + } + if (! array_key_exists($this->side, $whole)) { + return null; + } + $entries = $whole[$this->side]; + if (! is_array($entries) || ($entries !== [] && array_is_list($entries))) { + return sprintf('field "%s" is not a map of battler identities', $this->side); + } + foreach ($entries as $identity => $entry) { + if (! is_array($entry)) { + return sprintf('field "%s" entry "%s" is %s, not a binding', $this->side, strval($identity), get_debug_type($entry)); + } + if (array_key_exists(self::IDENTITY, $entry)) { + return sprintf('field "%s" entry "%s" holds "%s", which the editor uses for the identity', $this->side, strval($identity), self::IDENTITY); + } + } + + return null; + } + + /** + * @inheritDoc + * + * The map is written in row order, which it keeps. + */ + public function ordersRecords(): bool + { + return true; + } +} diff --git a/src/Database/Projections/GroupedDictionaryProjection.php b/src/Database/Projections/GroupedDictionaryProjection.php new file mode 100644 index 00000000..60b3b4a7 --- /dev/null +++ b/src/Database/Projections/GroupedDictionaryProjection.php @@ -0,0 +1,118 @@ + $groups Each file key holding a dictionary, with the group value its records carry. + * @param string $identityKey The record field the dictionary key is read into and written from. + * @param string $groupKey The record field naming the group a record belongs to. + * @param array $fileKeys Keys a file must hold that its records do not, such as a schema id, + * written when the file has none of its own; an authored value is never replaced. + */ + public function __construct(private array $groups, private string $identityKey = 'id', private string $groupKey = 'group', + private array $fileKeys = []) + { + } + + /** @inheritDoc */ + public function listOwnedKeys(): array + { + return array_keys($this->groups); + } + + /** @inheritDoc */ + public function read(array $whole): array + { + $rows = []; + foreach ($this->groups as $key => $group) { + foreach (is_array($whole[$key] ?? null) ? $whole[$key] : [] as $id => $entry) { + $rows[] = [$this->identityKey => (string) $id, $this->groupKey => $group, ...(is_array($entry) ? $entry : [])]; + } + } + + return $rows; + } + + /** @inheritDoc */ + public function write(array $whole, array $rows): array + { + $dictionaries = array_fill_keys(array_keys($this->groups), []); + foreach ($rows as $row) { + $key = array_search($row[$this->groupKey] ?? null, $this->groups, true); + $key = is_string($key) ? $key : array_key_first($this->groups); + $id = (string) ($row[$this->identityKey] ?? ''); + unset($row[$this->identityKey], $row[$this->groupKey]); + $dictionaries[$key][$id] = $row; + } + $written = $whole; + foreach ($dictionaries as $key => $dictionary) { + if ($dictionary !== []) { + $written[$key] = $dictionary; + } elseif (is_array($whole[$key] ?? null) && $whole[$key] !== []) { + // A group this write empties goes, so moving its last record away and back leaves the file as it was; + // one the file holds empty stays as authored. + unset($written[$key]); + } + } + if ($written === $whole) { + return $whole; + } + + return [...$this->fileKeys, ...$written]; + } + + /** @inheritDoc */ + public function preservationIssue(mixed $whole): ?string + { + if (! is_array($whole)) { + return sprintf('returns %s, not an array', get_debug_type($whole)); + } + $seen = []; + foreach (array_keys($this->groups) as $key) { + if (! array_key_exists($key, $whole)) { + continue; + } + $dictionary = $whole[$key]; + if (! is_array($dictionary) || ($dictionary !== [] && array_is_list($dictionary))) { + return sprintf('field "%s" is %s, not a dictionary keyed by id', $key, is_array($dictionary) ? 'an ordered list' : get_debug_type($dictionary)); + } + foreach ($dictionary as $id => $entry) { + if (! is_string($id) || ! is_array($entry) || array_key_exists($this->identityKey, $entry) || array_key_exists($this->groupKey, $entry)) { + return sprintf('field "%s" entry "%s" is not a record the editor can preserve under its key', $key, $id); + } + if (isset($seen[$id])) { + return sprintf('names "%s" in both "%s" and "%s"', $id, $seen[$id], $key); + } + $seen[$id] = $key; + } + } + + return null; + } + + /** + * @inheritDoc + * + * Records are written grouped by their dictionary, so an order across groups is not the file's. + */ + public function ordersRecords(): bool + { + return false; + } +} diff --git a/src/Database/Projections/KeyedDictionaryProjection.php b/src/Database/Projections/KeyedDictionaryProjection.php new file mode 100644 index 00000000..c0ee00ef --- /dev/null +++ b/src/Database/Projections/KeyedDictionaryProjection.php @@ -0,0 +1,123 @@ + $fileKeys Keys a file must hold that its records do not, such as a schema id, + * written when the file has none of its own; an authored value is never replaced. + * @param string|null $valueKey For a dictionary of plain values, the record field each value is read into. + */ + public function __construct(private string $key, private string $identityKey = 'id', private array $fileKeys = [], + private ?string $valueKey = null) + { + } + + /** @inheritDoc */ + public function listOwnedKeys(): array + { + return [$this->key]; + } + + /** + * @inheritDoc + */ + public function read(array $whole): array + { + $dictionary = $whole[$this->key] ?? null; + if (! is_array($dictionary)) { + return []; + } + + $rows = []; + foreach ($dictionary as $id => $entry) { + $rows[] = $this->valueKey !== null + ? [$this->identityKey => (string) $id, $this->valueKey => $entry] + : [$this->identityKey => (string) $id, ...(is_array($entry) ? $entry : [])]; + } + + return $rows; + } + + /** + * @inheritDoc + */ + public function write(array $whole, array $rows): array + { + $dictionary = []; + foreach ($rows as $row) { + $id = (string) ($row[$this->identityKey] ?? ''); + unset($row[$this->identityKey]); + $dictionary[$id] = $this->valueKey !== null ? ($row[$this->valueKey] ?? null) : $row; + } + if ($dictionary === [] && ! array_key_exists($this->key, $whole)) { + return $whole; + } + + return [...$this->fileKeys, ...$whole, $this->key => $dictionary]; + } + + /** + * Explains why rewriting this projection would change authored shape. + */ + public function preservationIssue(mixed $whole): ?string + { + if (! is_array($whole)) { + return sprintf('returns %s, not an array', get_debug_type($whole)); + } + + if (! array_key_exists($this->key, $whole)) { + return null; + } + + $dictionary = $whole[$this->key]; + if (! is_array($dictionary) || ($dictionary !== [] && array_is_list($dictionary))) { + return sprintf('field "%s" is %s, not a dictionary keyed by id', $this->key, is_array($dictionary) ? 'an ordered list' : get_debug_type($dictionary)); + } + + foreach ($dictionary as $id => $entry) { + if ($this->valueKey !== null) { + if (! is_string($id) || ! is_scalar($entry)) { + return sprintf('field "%s" entry "%s" is not a plain value the editor can preserve under its key', $this->key, $id); + } + continue; + } + if (! is_string($id) || ! is_array($entry) || array_key_exists($this->identityKey, $entry)) { + return sprintf('field "%s" entry "%s" is not a record the editor can preserve under its key', $this->key, $id); + } + } + + return null; + } + + /** + * @inheritDoc + * + * The dictionary is written in record order, so record order is the file's. + */ + public function ordersRecords(): bool + { + return true; + } +} diff --git a/src/Database/Projections/KeyedListProjection.php b/src/Database/Projections/KeyedListProjection.php index 7acaf4d3..ef82f9fd 100644 --- a/src/Database/Projections/KeyedListProjection.php +++ b/src/Database/Projections/KeyedListProjection.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor\Database\Projections; +use Ichiloto\Editor\Database\OwnsFileKeys; use Ichiloto\Editor\Database\RecordProjection; /** @@ -16,7 +17,7 @@ * * @package Ichiloto\Editor\Database\Projections */ -final readonly class KeyedListProjection implements RecordProjection +final readonly class KeyedListProjection implements RecordProjection, OwnsFileKeys { /** * @param string $key The key holding this category's list. @@ -25,6 +26,12 @@ public function __construct(private string $key) { } + /** @inheritDoc */ + public function listOwnedKeys(): array + { + return [$this->key]; + } + /** * @inheritDoc */ diff --git a/src/Database/Projections/KnowledgeRecordTypeProjection.php b/src/Database/Projections/KnowledgeRecordTypeProjection.php index 9610cd14..b54e4829 100644 --- a/src/Database/Projections/KnowledgeRecordTypeProjection.php +++ b/src/Database/Projections/KnowledgeRecordTypeProjection.php @@ -9,11 +9,12 @@ /** * The kinds of record a project's knowledge catalogue declares. * - * The runtime reads them as a flat list of names, which is not a shape an - * editor can put rows on, so each name becomes a record of its own and the - * flat list is rebuilt on the way out. Every subject's Record Type is picked - * from what this declares, so a project that cannot author them cannot - * introduce a kind of record at all. + * The runtime reads them as an ordered list, each an id alone or an id with + * the label players see it called, which is not a shape an editor can put + * rows on, so each becomes a record of its own and the list is rebuilt on + * the way out: a type without a label stays its bare id. Every subject's + * Record Type is picked from what this declares, so a project that cannot + * author them cannot introduce a kind of record at all. * * @package Ichiloto\Editor\Database\Projections */ @@ -28,7 +29,9 @@ public function read(array $whole): array foreach (is_array($whole['recordTypes'] ?? null) ? $whole['recordTypes'] : [] as $type) { if (is_scalar($type)) { - $rows[] = ['type' => strval($type)]; + $rows[] = ['type' => strval($type), 'label' => '']; + } elseif (is_array($type)) { + $rows[] = ['type' => strval($type['id'] ?? ''), 'label' => strval($type['label'] ?? '')]; } } @@ -42,11 +45,15 @@ public function write(array $whole, array $rows): array { $types = []; + $ids = []; + foreach ($rows as $row) { $type = trim(strval($row['type'] ?? '')); + $label = trim(strval($row['label'] ?? '')); - if ($type !== '' && ! in_array($type, $types, true)) { - $types[] = $type; + if ($type !== '' && ! in_array($type, $ids, true)) { + $ids[] = $type; + $types[] = $label === '' ? $type : ['id' => $type, 'label' => $label]; } } @@ -82,7 +89,11 @@ public function preservationIssue(mixed $whole): ?string } foreach ($types as $index => $type) { - if (! is_scalar($type)) { + // An id with a label is written back as it was given. + $labelled = is_array($type) && array_diff(array_keys($type), ['id', 'label']) === [] + && is_scalar($type['id'] ?? null) && is_scalar($type['label'] ?? ''); + + if (! is_scalar($type) && ! $labelled) { return sprintf( 'field "recordTypes" entry %d is %s, which the editor cannot preserve', $index + 1, diff --git a/src/Database/Projections/WholeFileProjection.php b/src/Database/Projections/WholeFileProjection.php new file mode 100644 index 00000000..5a27f833 --- /dev/null +++ b/src/Database/Projections/WholeFileProjection.php @@ -0,0 +1,97 @@ + $keys The top-level keys this category owns. + */ + public function __construct(public array $keys) + { + } + + /** + * @inheritDoc + */ + public function read(array $whole): array + { + return [array_intersect_key($whole, array_flip($this->keys))]; + } + + /** + * @inheritDoc + */ + public function write(array $whole, array $rows): array + { + $record = $rows[0] ?? null; + + if (! is_array($record)) { + return $whole; + } + + foreach ($this->keys as $key) { + if (array_key_exists($key, $record)) { + $whole[$key] = $record[$key]; + } else { + unset($whole[$key]); + } + } + + return $whole; + } + + /** + * @inheritDoc + */ + public function preservationIssue(mixed $whole): ?string + { + return is_array($whole) && ! array_is_list($whole) || $whole === [] + ? null + : sprintf('returns %s, not one map of settings', is_array($whole) ? 'a list' : get_debug_type($whole)); + } + + /** + * @inheritDoc + */ + public function ordersRecords(): bool + { + return false; + } + + /** + * The key of a field or list path this projection would not read or + * write, or null when it owns every one. + * + * @param list $paths Dotted field and list paths. + */ + public function findUnownedKey(array $paths): ?string + { + foreach ($paths as $path) { + $key = explode('.', $path, 2)[0]; + + if (! in_array($key, $this->keys, true)) { + return $key; + } + } + + return null; + } +} diff --git a/src/Database/QuestReferences.php b/src/Database/QuestReferences.php index 8012cb17..c89375d6 100644 --- a/src/Database/QuestReferences.php +++ b/src/Database/QuestReferences.php @@ -67,8 +67,8 @@ private function inQuests(string $questId): array { $found = []; - foreach ($this->workspace->questDatabase->getQuests() as $quest) { - if (! $quest instanceof ProjectQuest || $quest->getId() === $questId) { + foreach ($this->workspace->getQuests() as $quest) { + if ($quest->getId() === $questId) { continue; } @@ -145,7 +145,7 @@ private function inMaps(string $questId): array $found = []; foreach ($this->workspace->maps as $map) { - foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { if (! is_array($definition)) { continue; } diff --git a/src/Database/RecordAuthoring.php b/src/Database/RecordAuthoring.php new file mode 100644 index 00000000..9c24b106 --- /dev/null +++ b/src/Database/RecordAuthoring.php @@ -0,0 +1,544 @@ + $framePath The frame the row belongs to; [] for the record itself. + * @param string $label The row's label, which names the undo step. + * @throws RecordRefusal When the category or record is read-only or gone, the frame is gone, or the value is refused. + */ + public function applyField( + ProjectRecordDatabase $database, + int $index, + array $framePath, + string $fieldId, + string $rawValue, + string $label, + ): RecordChange { + $record = $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); + $before = $record->toArray(); + + try { + if ($database->hasCommandFrames()) { + $database->setFrameField($index, $framePath, $fieldId, $rawValue); + } else { + $database->setField($index, $fieldId, $rawValue); + } + } catch (ParameterMapSyntaxError|SourcePreservationRefusal|InvalidArgumentException $refused) { + // The value is the author's to correct; the record was left as it was. + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + $after = $record->toArray(); + + if ($before === $after) { + return new RecordChange(null, $index); + } + + try { + $database->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + // The file could not be saved with it: refused now, not at save. + $record->restorePayload($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + sprintf('%s edit', $label), + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + ), $index, note: self::describeIdentityFollow($database, $fieldId, $framePath, $before, $after)); + } + + /** + * What a rename did to an identity that follows its label: moved with it, + * or stayed because something refers to it. + * + * @param array|object $before + * @param array|object $after + */ + private static function describeIdentityFollow(ProjectRecordDatabase $database, string $fieldId, array $framePath, array|object $before, array|object $after): ?string + { + $schema = $database->schema; + + if (! $schema->identityFollowsLabel || $framePath !== [] || $fieldId !== $schema->labelKey || ! is_array($before) || ! is_array($after)) { + return null; + } + + $was = strval($before[$schema->identityKey] ?? ''); + $is = strval($after[$schema->identityKey] ?? ''); + + if ($was !== $is) { + return sprintf('Its id is now %s.', $is); + } + + return Slug::of(strval($after[$schema->labelKey] ?? '')) === $is + ? null + : sprintf('Its id stays %s, which other things point at.', $is); + } + + /** + * Adds an item at a row: after the item the row belongs to, or, with + * `$child`, at the end of what that item holds (a route's steps, a + * choice's options). With no row it adds at the end of the frame, or of + * the record's own list. + * + * @param array $framePath The frame the row belongs to. + * @param string|null $fieldId The row's field id; null for the end of the frame. + * @param bool $child True to add beneath the row's item rather than after it. + * @throws RecordRefusal When the category or record is read-only or gone, the frame is gone, or the row's item cannot take it. + */ + public function addItem(ProjectRecordDatabase $database, int $index, array $framePath, ?string $fieldId, bool $child = false): RecordChange + { + // The list is the row's, so it is required where the row is found. + $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); + + if ($fieldId === null) { + if ($child) { + throw new RecordRefusal('Name the row whose item the new one goes beneath.'); + } + + return $this->addEntry($database, $index, $framePath); + } + + $item = $database->locateItem($index, $framePath, $fieldId) + ?? throw new RecordRefusal('That row belongs to no item to add after; add at the end of the list instead.'); + + if ($item->kind === RecordItem::LIST) { + // A list's heading: the entry goes first in that list. + return $this->addEntry($database, $index, $framePath, -1, $item->listKey); + } + + if ($child) { + return match ($item->kind === RecordItem::ENTRY ? $item->childKind : null) { + RecordItem::NESTED => $this->addNestedItem($database, $index, $framePath, $item->entryIndex), + RecordItem::OPTION => $this->addOption($database, $index, $framePath, $item->entryIndex), + default => throw new RecordRefusal(sprintf('A %s holds nothing that can be added beneath it.', $item->noun)), + }; + } + + return match ($item->kind) { + RecordItem::NESTED => $this->addNestedItem($database, $index, $framePath, $item->entryIndex, $item->childIndex), + RecordItem::OPTION => $this->addOption($database, $index, $framePath, $item->entryIndex, $item->childIndex), + default => $this->addEntry($database, $index, $framePath, $item->entryIndex, $item->listKey), + }; + } + + /** + * Removes the item a row belongs to: a route step, line or lane, a + * choice's option with its whole arm, or an entry or command with + * everything under it. A row that belongs to none changes nothing. + * + * @param array $framePath The frame the row belongs to. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function removeItem(ProjectRecordDatabase $database, int $index, array $framePath, string $fieldId): RecordChange + { + $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); + $item = $database->locateItem($index, $framePath, $fieldId); + + return match ($item?->kind) { + null, RecordItem::LIST => new RecordChange(null), + RecordItem::NESTED => $this->removeNestedItem($database, $index, $framePath, $item->entryIndex, (int) $item->childIndex), + RecordItem::OPTION => $this->removeOption($database, $index, $framePath, $item->entryIndex, (int) $item->childIndex), + default => $this->removeEntry($database, $index, $framePath, $item->entryIndex, $item->listKey), + }; + } + + /** + * Adds a blank entry to the record's own list, or a blank command to a + * frame: after an entry, or at the end. + * + * @param array $framePath The frame; [] for the record's own list. + * @param int|null $after The entry it follows; null for the end, -1 for the start. + * @param string|null $listKey At the record's root, the inline list; the record's own when null. + * @throws RecordRefusal When the category or record is read-only or gone, or there is no such list. + */ + public function addEntry(ProjectRecordDatabase $database, int $index, array $framePath, ?int $after = null, ?string $listKey = null): RecordChange + { + $list = $this->requireList($database, $index, $framePath, $listKey); + $at = $framePath === [] + ? $database->addSubItem($index, at: $after === null ? null : $after + 1, listKey: $listKey) + : $database->addFrameCommand($index, $framePath, $after); + $entry = $at === null ? null : (($framePath === [] ? $database->getSubItems($index, $listKey) : $database->getFrameCommands($index, $framePath))[$at] ?? null); + + if ($at === null || ! is_array($entry)) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s add', ucfirst($list->singular)), + $framePath === [] + ? static fn() => $database->insertSubItem($index, $at, $entry, $listKey) + : static fn() => $database->insertFrameCommand($index, $framePath, $at, $entry), + $framePath === [] + ? static fn() => $database->removeSubItem($index, $at, $listKey) + : static fn() => $database->removeFrameCommand($index, $framePath, $at), + ), $at); + } + + /** + * Moves an entry of the record's own list one or more places, as one + * undo step: order is meaning for lists read first-match, such as an + * NPC's pages. Moving past either end changes nothing. + * + * @param string|null $listKey The inline list; the record's own when null. + * @throws RecordRefusal When the category or record is read-only or gone, or there is no such list. + */ + public function moveEntry(ProjectRecordDatabase $database, int $index, int $entryIndex, int $step, ?string $listKey = null): RecordChange + { + $list = $this->requireList($database, $index, [], $listKey); + $entries = $database->getSubItems($index, $listKey); + $to = $entryIndex + $step; + + if ($step === 0 || ! isset($entries[$entryIndex], $entries[$to])) { + return new RecordChange(null, $entryIndex); + } + + $move = static function (int $from, int $at) use ($database, $index, $listKey): void { + $entry = $database->removeSubItem($index, $from, $listKey); + if (is_array($entry)) { $database->insertSubItem($index, $at, $entry, $listKey); } + }; + $move($entryIndex, $to); + + return new RecordChange(new GenericCommand( + sprintf('%s move', ucfirst($list->singular)), + static fn() => $move($entryIndex, $to), + static fn() => $move($to, $entryIndex), + ), $to); + } + + /** + * Removes an entry of the record's own list, or a command of a frame, + * with everything under it. + * + * @param array $framePath The frame; [] for the record's own list. + * @param string|null $listKey At the record's root, the inline list; the record's own when null. + * @throws RecordRefusal When the category or record is read-only or gone, or there is no such list. + */ + public function removeEntry(ProjectRecordDatabase $database, int $index, array $framePath, int $entryIndex, ?string $listKey = null): RecordChange + { + $list = $this->requireList($database, $index, $framePath, $listKey); + $removed = $framePath === [] + ? $database->removeSubItem($index, $entryIndex, $listKey) + : $database->removeFrameCommand($index, $framePath, $entryIndex); + + if ($removed === null) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s remove', ucfirst($list->singular)), + $framePath === [] + ? static fn() => $database->removeSubItem($index, $entryIndex, $listKey) + : static fn() => $database->removeFrameCommand($index, $framePath, $entryIndex), + $framePath === [] + ? static fn() => $database->insertSubItem($index, $entryIndex, $removed, $listKey) + : static fn() => $database->insertFrameCommand($index, $framePath, $entryIndex, $removed), + ), $entryIndex, $removed); + } + + /** + * Adds a blank entry to the nested list an entry owns (a route step, + * a dialogue line, a lane): after one of them, or at the end. + * + * @param array $framePath The frame the owning entry is in. + * @param int|null $after The nested entry it follows; null for the end. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function addNestedItem(ProjectRecordDatabase $database, int $index, array $framePath, int $parentIndex, ?int $after = null): RecordChange + { + $list = $this->requireList($database, $index, $framePath); + $nestedList = $this->findNestedList($database, $index, $framePath, $list, $parentIndex); + $at = $nestedList === null ? null : $database->addFrameNestedItem($index, $framePath, $parentIndex, null, $after === null ? null : $after + 1); + $entry = $at === null || $nestedList === null + ? null + : (new ProjectRecord($database->getFrameCommands($index, $framePath)[$parentIndex]))->getEntries($nestedList)[$at] ?? null; + + if ($at === null || $nestedList === null || ! is_array($entry)) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s add', ucfirst($nestedList->singular)), + static fn() => $database->addFrameNestedItem($index, $framePath, $parentIndex, $entry, $at), + static fn() => $database->removeFrameNestedItem($index, $framePath, $parentIndex, $at), + ), $at); + } + + /** + * Removes an entry of the nested list an entry owns. + * + * @param array $framePath The frame the owning entry is in. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function removeNestedItem(ProjectRecordDatabase $database, int $index, array $framePath, int $parentIndex, int $nestedIndex): RecordChange + { + $list = $this->requireList($database, $index, $framePath); + $nestedList = $this->findNestedList($database, $index, $framePath, $list, $parentIndex); + $removed = $nestedList === null ? null : $database->removeFrameNestedItem($index, $framePath, $parentIndex, $nestedIndex); + + if ($removed === null || $nestedList === null) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + sprintf('%s remove', ucfirst($nestedList->singular)), + static fn() => $database->removeFrameNestedItem($index, $framePath, $parentIndex, $nestedIndex), + static fn() => $database->addFrameNestedItem($index, $framePath, $parentIndex, $removed, $nestedIndex), + ), $nestedIndex, $removed); + } + + /** + * Adds an option to a choice command: after one of its options, or at + * the end. + * + * @param array $framePath The frame the choice is in. + * @param int|null $after The option it follows; null for the end. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function addOption(ProjectRecordDatabase $database, int $index, array $framePath, int $commandIndex, ?int $after = null): RecordChange + { + $this->requireList($database, $index, $framePath); + $at = $database->addChoiceOption($index, $framePath, $commandIndex, $after === null ? null : $after + 1); + $option = $at === null ? null : ($database->getFrameCommands($index, $framePath)[$commandIndex]['options'][$at] ?? null); + + if ($at === null || ! is_array($option)) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + 'Option add', + static fn() => $database->insertChoiceOption($index, $framePath, $commandIndex, $at, $option), + static fn() => $database->removeChoiceOption($index, $framePath, $commandIndex, $at), + ), $at); + } + + /** + * Removes an option from a choice command, its arm included. + * + * @param array $framePath The frame the choice is in. + * @throws RecordRefusal When the category or record is read-only or gone, or the frame is gone. + */ + public function removeOption(ProjectRecordDatabase $database, int $index, array $framePath, int $commandIndex, int $optionIndex): RecordChange + { + $this->requireList($database, $index, $framePath); + $removed = $database->removeChoiceOption($index, $framePath, $commandIndex, $optionIndex); + + if ($removed === null) { + return new RecordChange(null); + } + + return new RecordChange(new GenericCommand( + 'Option remove', + static fn() => $database->removeChoiceOption($index, $framePath, $commandIndex, $optionIndex), + static fn() => $database->insertChoiceOption($index, $framePath, $commandIndex, $optionIndex, $removed), + ), $optionIndex, $removed); + } + + /** + * Creates a blank record at the end of the category, for an identity + * when one is given. Its index is the change's. + * + * @throws RecordRefusal When the category is read-only, takes no new records, the project lacks what one needs, or the identity has one. + */ + public function createRecord(ProjectRecordDatabase $database, ?string $identity = null): RecordChange + { + $this->requireEditable($database); + $index = $database->requireNewRecord($identity); + $record = $database->getRecordByIndex($index) + ?? throw new RecordRefusal(sprintf('%s entries cannot be created from the editor.', ucfirst($database->schema->entryNoun))); + + return new RecordChange(new GenericCommand( + sprintf('%s create', ucfirst($database->schema->entryNoun)), + static fn() => $database->insertRecord($index, $record), + static fn() => $database->removeRecord($index), + ), $index); + } + + /** + * Duplicates a record below itself, under a fresh identity: the one given + * (made unique), else one made from the original's. The copy's index is + * the change's. + * + * @throws RecordRefusal When the category or record is read-only or gone, or the record cannot be duplicated. + */ + public function duplicateRecord(ProjectRecordDatabase $database, int $index, ?string $identity = null): RecordChange + { + $this->requireRecord($database, $index); + if ($identity !== null && trim($identity) === '') { + throw new RecordRefusal(sprintf('A %s copy needs a name.', $database->schema->entryNoun)); + } + $copyIndex = $database->duplicateRecord($index, $identity === null ? null : trim($identity)); + $copy = $copyIndex === null ? null : $database->getRecordByIndex($copyIndex); + + if ($copyIndex === null || $copy === null) { + throw new RecordRefusal(sprintf('This %s cannot be duplicated.', $database->schema->entryNoun)); + } + + return new RecordChange(new GenericCommand( + sprintf('%s duplicate', ucfirst($database->schema->entryNoun)), + static fn() => $database->insertRecord($copyIndex, $copy), + static fn() => $database->removeRecord($copyIndex), + ), $copyIndex); + } + + /** + * Moves a record one place up or down its list. Declaration order is + * part of some categories' meaning - battle-entry rules break priority + * ties by it - so the move is a recorded edit, made only where the file + * keeps the order. A move past either end changes nothing. The record's + * new index is the change's. + * + * @param int $step -1 for up, 1 for down. + * @throws RecordRefusal When the category is read-only, the record is gone, or the file would not keep the order. + */ + public function moveRecord(ProjectRecordDatabase $database, int $index, int $step): RecordChange + { + $this->requireEditable($database); + + if (! $database->supportsDurableReorder()) { + // Refusing beats a reorder the file cannot keep: nothing moves, + // nothing dirties, and the author learns why. + throw new RecordRefusal((string) $database->reorderRefusalReason()); + } + + $this->requireRecord($database, $index); + $to = $index + $step; + + if (! $database->moveRecord($index, $to)) { + return new RecordChange(null, $index); + } + + return new RecordChange(new GenericCommand( + sprintf('%s move', ucfirst($database->schema->entryNoun)), + static fn() => $database->moveRecord($index, $to), + static fn() => $database->moveRecord($to, $index), + ), $to); + } + + /** + * Deletes a record. The file changes on save, which is what makes the + * undo honest. The change's index is the record to select next: the + * one before it, or null when none is left. + * + * @throws RecordRefusal When the category is read-only, the record is gone, something still names it, or the + * category keeps its entries. + */ + public function deleteRecord(ProjectRecordDatabase $database, int $index): RecordChange + { + $this->requireRecord($database, $index); + $label = $database->getEntryLabels()[$index] ?? ''; + $references = $database->describeDeletionReferences($index); + if ($references !== []) { + throw new RecordRefusal(sprintf('%s %s is still used: %s.', ucfirst($database->schema->entryNoun), $label, implode('; ', $references))); + } + $removed = $database->removeRecord($index) + ?? throw new RecordRefusal('This category does not support entry deletion yet.'); + $remaining = count($database->getRecords()); + + return new RecordChange(new GenericCommand( + sprintf('Delete %s %s', $database->schema->entryNoun, $label), + static function () use ($database, $index): void { + $database->removeRecord($index); + }, + static fn() => $database->insertRecord($index, $removed), + ), $remaining === 0 ? null : min(max(0, $index - 1), $remaining - 1)); + } + + /** @throws RecordRefusal When the category cannot be written. */ + private function requireEditable(ProjectRecordDatabase $database): void + { + if (! $database->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $database->getReadOnlyReason() ?? 'this category cannot be written')); + } + } + + /** + * The record at an index of a category that can be written. A record + * whose own values cannot be written is the category's to protect: its + * read-only rows say so, and its writes change nothing. + * + * @throws RecordRefusal When the category cannot be written or there is no such record. + */ + private function requireRecord(ProjectRecordDatabase $database, int $index): ProjectRecord + { + $this->requireEditable($database); + + return $database->getRecordByIndex($index) + ?? throw new RecordRefusal(sprintf('%s has no record %d.', $database->schema->key, $index)); + } + + /** + * Refuses a frame that no longer resolves, or any frame in a category + * whose commands do not nest. + * + * @param array $framePath + * @throws RecordRefusal + */ + private function requireFrame(ProjectRecordDatabase $database, int $index, array $framePath): void + { + if ($framePath !== [] && (! $database->hasCommandFrames() || $database->getFrameCommands($index, $framePath) === null)) { + throw new RecordRefusal(sprintf('%s is no longer there.', $database->describeFramePath($framePath))); + } + } + + /** + * The list a frame holds - the record's own at the root - refusing a + * record or frame that cannot take an item. + * + * @param array $framePath + * @throws RecordRefusal + */ + private function requireList(ProjectRecordDatabase $database, int $index, array $framePath, ?string $listKey = null): RecordSubList + { + $this->requireRecord($database, $index); + $this->requireFrame($database, $index, $framePath); + + return ($framePath === [] && $listKey !== null ? $database->schema->findInlineSubList($listKey) : $database->getFrameSubList($framePath)) + ?? throw new RecordRefusal(sprintf('A %s has no list of its own to add to.', $database->schema->entryNoun)); + } + + /** The nested list an entry of a frame owns, or null when it owns none. */ + private function findNestedList(ProjectRecordDatabase $database, int $index, array $framePath, RecordSubList $list, int $parentIndex): ?RecordSubList + { + $parent = $database->getFrameCommands($index, $framePath)[$parentIndex] ?? null; + + return is_array($parent) ? $list->nestedListFor($parent) : null; + } +} diff --git a/src/Database/RecordCategory.php b/src/Database/RecordCategory.php new file mode 100644 index 00000000..3bd80546 --- /dev/null +++ b/src/Database/RecordCategory.php @@ -0,0 +1,143 @@ +key = $database->schema->key; + } + + public function isEditable(): bool + { + return $this->database->isEditable(); + } + + public function getReadOnlyReason(): ?string + { + return $this->database->getReadOnlyReason(); + } + + public function isDirty(): bool + { + return $this->database->isDirty(); + } + + public function supportsRecordCreation(): bool + { + return $this->database->supportsRecordCreation(); + } + + public function supportsRecordDuplication(): bool + { + return $this->database->duplicateRecordSupported(); + } + + public function supportsRecordDeletion(): bool + { + return $this->database->supportsRecordDeletion(); + } + + public function supportsDurableReorder(): bool + { + return $this->database->supportsDurableReorder(); + } + + public function getRecordLabels(): array + { + return array_values(array_map('strval', $this->database->getEntryLabels())); + } + + public function getRecordRows(int $index, array $frame): array + { + if ($this->database->getRecordByIndex($index) === null) { + throw new RecordRefusal(sprintf('%s has no record %d.', $this->key, $index)); + } + if ($frame !== [] && (! $this->database->hasCommandFrames() || $this->database->getFrameCommands($index, $frame) === null)) { + throw new RecordRefusal(sprintf('%s is no longer there; read the record again.', $this->database->describeFramePath($frame))); + } + + return $this->database->getFrameSettingsFields($index, $frame); + } + + public function describeFrame(array $frame): ?string + { + return $frame === [] ? null : $this->database->describeFramePath($frame); + } + + public function getListNoun(array $frame): ?string + { + return $this->database->getFrameSubList($frame)?->singular; + } + + public function locateItem(int $index, array $frame, string $fieldId): ?RecordItem + { + return $this->database->locateItem($index, $frame, $fieldId); + } + + public function applyField(int $index, array $frame, string $fieldId, string $value, string $label): RecordChange + { + return $this->authoring->applyField($this->database, $index, $frame, $fieldId, $value, $label); + } + + public function addItem(int $index, array $frame, ?string $fieldId, bool $child): RecordChange + { + return $this->authoring->addItem($this->database, $index, $frame, $fieldId, $child); + } + + public function removeItem(int $index, array $frame, string $fieldId): RecordChange + { + return $this->authoring->removeItem($this->database, $index, $frame, $fieldId); + } + + public function createRecord(?string $identity = null): RecordChange + { + return $this->authoring->createRecord($this->database, $identity); + } + + public function duplicateRecord(int $index, ?string $identity = null): RecordChange + { + return $this->authoring->duplicateRecord($this->database, $index, $identity); + } + + public function deleteRecord(int $index): RecordChange + { + return $this->authoring->deleteRecord($this->database, $index); + } + + public function moveRecord(int $index, int $step): RecordChange + { + return $this->authoring->moveRecord($this->database, $index, $step); + } + + public function getBackupPaths(): array + { + return array_values($this->database->getBackupPaths()); + } + + public function save(): void + { + if (! $this->database->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $this->database->getReadOnlyReason() ?? 'this category cannot be written')); + } + + try { + $this->database->save(); + } catch (SourceIdentityConflict|SourcePreservationRefusal $refusal) { + throw new RecordRefusal($refusal->getMessage(), previous: $refusal); + } + } +} diff --git a/src/Database/RecordChange.php b/src/Database/RecordChange.php new file mode 100644 index 00000000..90b38aa9 --- /dev/null +++ b/src/Database/RecordChange.php @@ -0,0 +1,30 @@ +|null $removed What an item removal took out, as the list held it. + * @param string|null $note What else the change did that the author should hear about (a renamed quest's id). + */ + public function __construct( + public ?Command $command, + public ?int $index = null, + public ?array $removed = null, + public ?string $note = null, + ) { + } +} diff --git a/src/Database/RecordField.php b/src/Database/RecordField.php index b9800652..473d6a53 100644 --- a/src/Database/RecordField.php +++ b/src/Database/RecordField.php @@ -4,7 +4,9 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Dialogue\DialogueLineSpeaker; use Ichiloto\Editor\Inspector\InputControlType; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialogueContext; /** * One editable field on a Database record. @@ -40,6 +42,16 @@ * types this surface may author, when the runtime restricts it to fewer * than the writer's full vocabulary (a transactional boundary rejects * quest acceptance). Null offers everything the writer applies. + * @param bool $uniqueAcrossRecords For a list field, whether a member may + * be held by one record of the category at most (an animation role plays + * one animation), so taking one another record holds is refused. + * @param string|null $referenceMapKey For a map-local reference (an event + * marker, an NPC), the entry field naming the map its choices come from, + * when that is not the map the author has selected. + * @param string|null $entryChoices For a field chosen among values its own + * entry decides (an attachment of the world object the entry names), the + * source {@see ReferenceCatalog::listEntryChoices()} reads them from. The + * row offers them as options, and an edit outside them is refused. */ public function __construct( public string $key, @@ -56,9 +68,62 @@ public function __construct( public ?string $displayDefault = null, public ?string $blankLabel = null, public ?array $writeTypes = null, + public bool $uniqueAcrossRecords = false, + /** For a line's emotion, how the line names its speaker, whose catalogue emotions it is chosen from. */ + public ?DialogueLineSpeaker $dialogueSpeaker = null, + public ?string $referenceMapKey = null, + public ?string $entryChoices = null, ) { } + /** + * Returns a member of the troop that owns the entry, chosen by number and + * enemy name and stored as its index in the troop's enemies. + * + * @param bool $required Whether the entry must name one; an optional one can be None. + */ + public static function troopMember(string $key, string $label, bool $required = true): self + { + return new self($key, $label, InputControlType::INTEGER, removeWhenEmpty: ! $required, + codec: RecordFieldCodec::TROOP_MEMBER, allowsNone: ! $required); + } + + /** + * Returns an optional field chosen among the values its own entry + * decides, read from a {@see ReferenceCatalog::listEntryChoices()} source. + */ + public static function entryChoice(string $key, string $label, string $source, string $noneLabel): self + { + return new self($key, $label, options: [''], removeWhenEmpty: true, displayDefault: $noneLabel, entryChoices: $source); + } + + /** + * Returns a reference to something on a map the entry itself names: a + * quest waymark's event marker is chosen from the waymark's map. + * + * @param string $key The payload key. + * @param string $label The settings-pane label. + * @param string $category The map-local kind of resource it names. + * @param string $mapKey The entry field holding the map id. + * @return self The field, unset by default. + */ + public static function mapReference(string $key, string $label, string $category, string $mapKey): self + { + return new self($key, $label, removeWhenEmpty: true, reference: $category, allowsNone: true, referenceMapKey: $mapKey); + } + + /** + * Returns a dialogue line's emotion: chosen among the emotions the line's speaker has in the game's dialogue + * catalogue, as the rule says who speaks. Absent, the Engine shows Neutral. + * + * @param DialogueLineSpeaker $speaker How the line names its speaker. + * @return self The field. + */ + public static function emotion(DialogueLineSpeaker $speaker): self + { + return new self('emotion', 'Emotion', removeWhenEmpty: true, displayDefault: DialogueContext::DEFAULT_EMOTION, dialogueSpeaker: $speaker); + } + /** * Returns a field that names another resource. * @@ -122,6 +187,8 @@ public function asReadOnly(): self $this->displayDefault, $this->blankLabel, $this->writeTypes, + dialogueSpeaker: $this->dialogueSpeaker, + entryChoices: $this->entryChoices, ); } @@ -135,9 +202,10 @@ public function asReadOnly(): self * @param string $key The payload key. * @param string $label The settings-pane label. * @param bool $removeWhenEmpty Whether `false` drops the key (matching engine defaults). + * @param string|null $displayDefault What an absent key reads as, such as the engine's `false`. * @return self */ - public static function boolean(string $key, string $label, bool $removeWhenEmpty = true): self + public static function boolean(string $key, string $label, bool $removeWhenEmpty = true, ?string $displayDefault = null): self { return new self( $key, @@ -145,6 +213,7 @@ public static function boolean(string $key, string $label, bool $removeWhenEmpty InputControlType::BOOLEAN, ['false', 'true'], $removeWhenEmpty, + displayDefault: $displayDefault, ); } } diff --git a/src/Database/RecordFieldCodec.php b/src/Database/RecordFieldCodec.php index 5779a89d..25e94b65 100644 --- a/src/Database/RecordFieldCodec.php +++ b/src/Database/RecordFieldCodec.php @@ -22,6 +22,29 @@ enum RecordFieldCodec: string /** A list of plain strings, shown comma-separated. */ case CSV_LIST = 'csv_list'; + /** A list of whole numbers, shown comma-separated: a tileset's tile identities. */ + case CSV_INTEGERS = 'csv_integers'; + + /** + * A list shown comma-separated whose whole numbers are stored as numbers + * and anything else as text: a tileset's shadow casters, sheet names and + * tile identities alike. + */ + case CSV_TOKENS = 'csv_tokens'; + + /** + * A connected piece's tile on one layer: one tile entry for every shape + * (`5888`), or one per shape, shown as `horizontal: 5888, vertical: 5890, + * corner: 5892`. + */ + case SHAPE_TILES = 'shape_tiles'; + + /** Optional physical stamping rows, encoded as JSON integers or null and stored as final CollisionType cases or null. */ + case PHYSICAL_FOOTPRINT = 'physical_footprint'; + + /** A stamped piece's painted doors, shown and typed as `2,4; 4,4 front` ({@see \Ichiloto\Editor\Maps\PieceDoorwayCodec}). */ + case PIECE_DOORWAY = 'piece_doorway'; + /** An element => multiplier map, edited a row at a time. */ case AFFINITIES = 'affinities'; @@ -47,7 +70,58 @@ enum RecordFieldCodec: string case LINES = 'lines'; /** - * A point stored as `[x, y]`, shown as `x, y`. + * Two whole numbers stored as `[first, second]`, shown as `first, second`: + * a staged position's `[x, y]`, or a condition's `[minimum, maximum]`. */ case POINT = 'point'; + + /** + * A point within a whole, stored as `['x' => x, 'y' => y]` with each + * from 0 to 1, shown as `x, y`: an image's pivot. Both or neither. + */ + case NORMALIZED_POINT = 'normalized_point'; + + /** + * A point in authored units, stored as `['x' => x, 'y' => y]`, shown as + * `x, y`: where a cinematic stage subject stands, a camera key's focus, + * an image's offset on the stage. Whole numbers stay integers; both or + * neither. + */ + case COORDINATES = 'coordinates'; + + /** + * A size in authored units, stored as `['width' => w, 'height' => h]`, + * shown as `width, height`: a cinematic stage's canvas, a subject's + * registered box. Whole numbers stay integers; both or neither. + */ + case SIZE = 'size'; + + /** One optional pixel crop, stored atomically as integer x, y, width and height. */ + case SOURCE_RECT = 'source_rect'; + + /** + * A battler's place in a graphical formation, the Engine's + * BattleStageSlot, stored as `['x' => x, 'depth' => d, 'width' => w, + * 'height' => h]` with an optional `'displayScale' => s`, shown as + * `x, depth, width, height` or `x, depth, width, height, s`: the feet's + * canvas column and how far back from the arena ground's near edge they + * stand, so it stands on any arena's ground. Whole or not at all, so one + * edit moves it as one step, and its display scale is kept. + */ + case STAGE_SLOT = 'stage_slot'; + + /** + * A project's battle test, the Engine's ProjectBattleTest: the troop to + * preselect, the party (each member's actor, level, equipment and + * loadout) and the arena. Stored as the Engine writes it and shown as + * one line of JSON; a value the Engine would not read is refused. + */ + case BATTLE_TEST = 'battle_test'; + + /** + * A member of the troop that owns the entry, stored as its 0-based index + * in the troop's `enemies` and chosen as "1 · Djin": what a troop event + * page's conditions and battle commands name an enemy by. + */ + case TROOP_MEMBER = 'troop_member'; } diff --git a/src/Database/RecordItem.php b/src/Database/RecordItem.php new file mode 100644 index 00000000..17b10214 --- /dev/null +++ b/src/Database/RecordItem.php @@ -0,0 +1,53 @@ + $framePath The frame whose list holds the entry. + * @param int $entryIndex The entry, or the entry a nested item or option belongs to. + * @param int|null $childIndex The nested item or option; null for an entry. + * @param string $noun What the item is called, singular. + * @param string|null $childKind What an entry holds beneath it, when it can hold more: {@see NESTED} for its nested list's entries, {@see OPTION} for a choice's options. + * @param string|null $childNoun What those are called, singular. + * @param string|null $listKey The inline list the item is in, when it is not the record's own. + */ + public function __construct( + public string $kind, + public array $framePath, + public int $entryIndex, + public ?int $childIndex, + public string $noun, + public ?string $childKind = null, + public ?string $childNoun = null, + public ?string $listKey = null, + ) { + } +} diff --git a/src/Database/RecordPanes.php b/src/Database/RecordPanes.php new file mode 100644 index 00000000..9fa92c7c --- /dev/null +++ b/src/Database/RecordPanes.php @@ -0,0 +1,413 @@ +}> + */ + public static function describe(ProjectWorkspace $workspace, string $category, int $index): array + { + $data = static fn(): ?array => ($record = $workspace->getRecordDatabase($category)?->getRecordByIndex($index)) === null + ? null + : (array) $record->toArray(); + + return match ($category) { + 'actors' => self::describeActor($workspace->actorDatabase->getActorByIndex($index)), + 'classes' => self::describeClass(($row = $data()) === null ? null : ProjectClass::fromArray($row, $index + 1)), + 'terms' => self::describeTerm($data()), + 'skills' => self::describeSkill($data(), $workspace->getRecordDatabase('skills')?->getRecordByIndex($index)?->recordId), + 'quests' => self::describeQuest(($row = $data()) === null ? null : new ProjectQuest($row)), + 'system' => self::describeSystem($workspace), + 'battle_entry_rules' => ['cue' => ['title' => 'Execution Order', 'lines' => self::describeBattleEntryOrder($workspace, $index)]], + default => [], + }; + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeActor(?ProjectActor $actor): array + { + if ($actor === null) { + return self::panes('Collections', ['No actor selected.'], 'Stats', ['No actor selected.'], ['No actor selected.']); + } + + $abilities = $actor->getAbilities(); + $magic = $actor->getMagic(); + $images = $actor->getImages(); + $battleLines = $actor->getBattleSpriteLines(); + + return self::panes( + 'Collections', + [ + 'Abilities', + sprintf('Learned: %d', count($abilities['learned'] ?? [])), + sprintf('Learnables: %d', count($abilities['learnables'] ?? [])), + sprintf('Sort: %s', (string) ($abilities['sortOrder'] ?? 'A-Z')), + '', + 'Magic', + sprintf('Learned: %d', count($magic['learned'] ?? [])), + sprintf('Learnables: %d', count($magic['learnables'] ?? [])), + sprintf('Sort: %s', (string) ($magic['sortOrder'] ?? 'A-Z')), + ], + 'Stats', + [ + sprintf('HP %d/%d', $actor->getStat('currentHp'), $actor->getStat('totalHp')), + sprintf('MP %d/%d', $actor->getStat('currentMp'), $actor->getStat('totalMp')), + sprintf('AP %d/%d', $actor->getStat('currentAp'), $actor->getStat('totalAp')), + sprintf('ATK %d', $actor->getStat('attack')), + sprintf('DEF %d', $actor->getStat('defence')), + sprintf('MAT %d', $actor->getStat('magicAttack')), + sprintf('MDF %d', $actor->getStat('magicDefence')), + sprintf('SPD %d', $actor->getStat('speed')), + sprintf('GRC %d', $actor->getStat('grace')), + sprintf('EVA %d', $actor->getStat('evasion')), + sprintf('ACC %d', $actor->getStat('accuracy')), + sprintf('CRT %d', $actor->getStat('critical')), + ], + [ + sprintf('Actor ID: %s', $actor->id), + sprintf('Field sprites: %d', count($images['field'] ?? [])), + sprintf('Dialog portraits: %d', count($images['dialog'] ?? [])), + '', + 'Battle Sprite', + ...($battleLines !== [] ? $battleLines : ['(no battle sprite configured)']), + ], + ); + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeClass(?ProjectClass $class): array + { + if ($class === null) { + return self::panes('Experience Curve', ['No class selected.'], 'Stat Curves', ['No class selected.'], ['No class selected.']); + } + + $curve = $class->getExperienceCurve(); + $labels = [ + 'totalHp' => 'HP', 'totalMp' => 'MP', 'attack' => 'ATK', 'defence' => 'DEF', 'magicAttack' => 'MAT', + 'magicDefence' => 'MDF', 'speed' => 'SPD', 'grace' => 'GRC', 'evasion' => 'EVA', + ]; + $curves = []; + + foreach ($class->getParameterCurves() as $key => $parameter) { + $curves[] = sprintf('%-3s %d +%d / %d', $labels[$key] ?? strtoupper($key), $parameter['baseValue'], $parameter['extraGrowth'], $parameter['flatIncrement']); + } + + $experience = new ExperienceCurveGenerator( + baseValue: $curve['baseValue'], + extraValue: $curve['extraValue'], + accelerationA: $curve['accelerationA'], + accelerationB: $curve['accelerationB'], + ); + $generator = static function (string $key) use ($class): ParameterCurveGenerator { + $parameter = $class->getParameterCurve($key); + + return new ParameterCurveGenerator(1, $parameter['baseValue'], $parameter['extraGrowth'], $parameter['flatIncrement']); + }; + [$hp, $mp, $attack] = [$generator('totalHp'), $generator('totalMp'), $generator('attack')]; + $samples = []; + + foreach ([1, 10, 25, 50, 99] as $level) { + if ($level <= $class->getMaxLevel()) { + $samples[] = sprintf('Lv%02d HP%-4d MP%-3d ATK%-3d EXP%-6d', $level, $hp->getValue($level), $mp->getValue($level), $attack->getValue($level), $experience->getValue($level)); + } + } + + return self::panes( + 'Experience Curve', + [ + sprintf('Base: %d', $curve['baseValue']), + sprintf('Extra: %d', $curve['extraValue']), + sprintf('Accel A: %d', $curve['accelerationA']), + sprintf('Accel B: %d', $curve['accelerationB']), + '', + sprintf('Initial Lv: %d', $class->getInitialLevel()), + sprintf('Max Lv: %d', $class->getMaxLevel()), + sprintf('Traits: %d', count($class->getTraits())), + ], + 'Stat Curves', + $curves, + [sprintf('Class ID: %04d', $class->id), sprintf('Name: %s', $class->getName()), '', 'Curve Samples', ...$samples], + ); + } + + /** + * @param array|null $data The skill's record data. + * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> + */ + /** + * A term as the Engine says it: what it is for, its default, the placeholders it fills in, and for a battle + * callout what a battle will show. + * + * @param array|null $data + * @return array}> + */ + private static function describeTerm(?array $data): array + { + if ($data === null || ! array_key_exists('default', $data) || ! is_array($data['placeholders'] ?? null)) { + return []; + } + $value = $data['value'] ?? null; + $text = is_string($value) ? $value : strval($data['default']); + $lines = [ + strval($data['description'] ?? ''), + '', + sprintf('Default: %s', trim(strval($data['default'])) === '' ? '(blank)' : strval($data['default'])), + ...array_map(static fn(string $token, string $meaning): string => sprintf('%s = %s', $token, $meaning), + array_keys($data['placeholders']), array_values($data['placeholders'])), + ]; + if (str_starts_with(strval($data['path'] ?? ''), 'messages.battle.announcements.')) { + $shown = BattleAnnouncement::format($text, 'Actor', 'Action'); + $lines = [...$lines, '', $shown === '' ? 'Shows: nothing, the command is used silently' : sprintf('Shows: %s', $shown)]; + } + + return ['preview' => ['title' => 'Term', 'lines' => $lines]]; + } + + /** + * What a skill's battle message will show, formatted by the Engine as a battle formats it, with how to write one. + * + * @param array $invocation + * @return list + */ + private static function describeBattleMessage(array $invocation, string $name): array + { + $message = $invocation['message'] ?? RecordSchemaCatalog::SKILL_MESSAGE_DEFAULT; + if (! is_string($message)) { + return ['Battle message: (not text)']; + } + $shown = BattleAnnouncement::format($message, 'Actor', $name === '' ? 'Skill' : $name); + + return [ + sprintf('Battle message: %s', trim($message) === '' ? '(blank)' : $message), + $shown === '' ? 'Shows: nothing, the skill is used silently' : sprintf('Shows: %s', $shown), + '%1 = who acts, %2 = this skill, blank = no message', + ]; + } + + private static function describeSkill(?array $data, ?string $file): array + { + if ($data === null) { + return self::panes('Effects', ['No skill selected.'], 'Scope', ['No skill selected.'], ['No skill selected.']); + } + + $scope = (array) ($data['scope'] ?? []); + $invocation = (array) ($data['invocation'] ?? []); + $effects = array_values(array_map(self::describeSkillEffect(...), array_filter((array) ($data['effects'] ?? []), is_array(...)))); + $effects = $effects === [] ? ['No effects configured.'] : $effects; + + return self::panes( + 'Effects', + $effects, + 'Scope', + [ + sprintf('Side: %s', strval($scope['side'] ?? '')), + sprintf('Number: %s', strval($scope['number'] ?? '')), + sprintf('Status: %s', strval($scope['status'] ?? '')), + sprintf('Targets: %s', ($scope['targetCount'] ?? null) === null ? 'Auto' : strval($scope['targetCount'])), + '', + sprintf('Occasion: %s', strval($data['occasion'] ?? '')), + sprintf('Repeat: %d', intval($invocation['repeat'] ?? 1)), + sprintf('AP Gain: %d', intval($invocation['apGain'] ?? 0)), + ], + [ + sprintf('File: %s/%s.php', SkillCatalog::DIRECTORY, strval($file)), + sprintf('Name: %s', strval($data['name'] ?? '')), + sprintf('Kind: %s', ucfirst(strval($data['kind'] ?? ''))), + sprintf('Occasion: %s', strval($data['occasion'] ?? '')), + sprintf('Cost: %d MP', intval($data['cost'] ?? 0)), + sprintf('Cooldown: %d', intval($data['cooldown'] ?? 0)), + '', + sprintf('Scope: %s / %s / %s', strval($scope['side'] ?? ''), strval($scope['number'] ?? ''), strval($scope['status'] ?? '')), + ...self::describeBattleMessage($invocation, strval($data['name'] ?? '')), + '', + 'Effects', + ...$effects, + ], + ); + } + + /** + * One effect of a skill record, as the Effects pane lists it. + * + * @param array $effect The effect's record data. + */ + private static function describeSkillEffect(array $effect): string + { + $parts = match (true) { + isset($effect['formula']) => [strval($effect['formula'])], + isset($effect['stateId']) => [sprintf('%s, %d%%', strval($effect['stateId']), intval($effect['chancePercent'] ?? 100))], + isset($effect['stateIds']) && is_array($effect['stateIds']) => [implode(', ', array_map('strval', $effect['stateIds']))], + isset($effect['facts']) && is_array($effect['facts']) => [sprintf('%s, %d%%', + implode(', ', array_map('strval', $effect['facts'])), intval($effect['chancePercent'] ?? 100))], + isset($effect['stat']) => [sprintf('%s %+d%s', strval($effect['stat']), intval($effect['delta'] ?? 0), ($effect['affectsUser'] ?? false) === true ? ' on the user' : '')], + default => [], + }; + + foreach (['element', 'resolutionKind'] as $key) { + if (isset($effect[$key])) { + $parts[] = strval($effect[$key]); + } + } + + if (isset($effect['variance'])) { + $parts[] = sprintf('±%d%%', (int) round(floatval($effect['variance']) * 100)); + } + + return sprintf('%s: %s', ucfirst(str_replace('_', ' ', strval($effect['type'] ?? 'effect'))), implode(' · ', $parts)); + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeQuest(?ProjectQuest $quest): array + { + if ($quest === null) { + return self::panes('Objectives', ['No quest selected.'], 'Rewards', ['No quest selected.'], ['No quest selected.']); + } + + return self::panes( + 'Objectives', + $quest->getObjectiveSummaryLines(), + 'Rewards', + [ + sprintf('Gold: %d', $quest->getRewardGold()), + sprintf('EXP: %d', $quest->getRewardExperience()), + sprintf('SP: %d', $quest->getRewardSkillPoints()), + sprintf('Items: %s', $quest->getRewardItemsString() === '' ? '-' : $quest->getRewardItemsString()), + '', + 'Prereqs', + ...$quest->getPrerequisiteSummaryLines(), + ], + [ + sprintf('Quest ID: %s', $quest->getId()), + sprintf('Name: %s', $quest->getName()), + sprintf('Giver: %s', $quest->getGiver() === '' ? '-' : $quest->getGiver()), + sprintf('Objectives: %d', count($quest->getObjectives())), + sprintf('Prereqs: %d', count($quest->getPrerequisites())), + '', + 'Description', + $quest->getDescription() === '' ? '(none)' : $quest->getDescription(), + '', + 'Objectives', + ...$quest->getObjectiveSummaryLines(), + ], + ); + } + + /** @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ + private static function describeSystem(ProjectWorkspace $workspace): array + { + // The battle settings as the system record holds them, with the + // defaults the engine reads where it holds none. + $battle = $workspace->getSystemField('battle'); + $battle = is_array($battle) ? $battle : []; + $activeTime = is_array($battle['activeTime'] ?? null) ? $battle['activeTime'] : []; + $engine = strval($battle['engine'] ?? 'traditional'); + $mode = strval($activeTime['mode'] ?? 'wait'); + $fillRate = max(1, intval($activeTime['baseFillRate'] ?? 35)); + $speedFactor = max(0, intval($activeTime['speedFactorPercent'] ?? 35)); + $activeTimeOn = $engine === 'active_time'; + + return self::panes( + 'Battle Settings', + [ + sprintf('Engine: %s', $engine), + sprintf('Time Gauge Mode: %s', $mode), + sprintf('Base Fill Rate: %d', $fillRate), + sprintf('Speed Factor: %d%%', $speedFactor), + ], + 'Notes', + $activeTimeOn + ? ['Time gauge battles are enabled.', 'Mode: wait', 'This first slice uses wait-mode flow', 'during command selection and resolution.'] + : ['Traditional turn-based battles.', 'Time gauge settings are stored but inactive.', 'Switch Battle Engine to active_time', 'to enable gauge-driven turns.'], + $activeTimeOn + ? [ + 'Battle Engine', 'Time Gauge', '', + sprintf('Mode: %s', $mode), sprintf('Base Fill Rate: %d', $fillRate), sprintf('Speed Factor: %d%%', $speedFactor), '', + 'This engine fills battler gauges', 'continuously and resolves actions', 'as battlers become ready.', + ] + : [ + 'Battle Engine', 'Traditional Turn-Based', '', + 'Battlers act in a queued round order.', 'Time gauge settings are ignored until you', 'switch the project to active_time.', + ], + ); + } + + /** + * The order matching battle-entry rules run in: priority, then + * declaration order, the given rule marked. + * + * @return list + */ + private static function describeBattleEntryOrder(ProjectWorkspace $workspace, int $selected): array + { + $rules = []; + + foreach ($workspace->getRecordDatabase('battle_entry_rules')?->getRecords() ?? [] as $index => $record) { + $priority = $record->get('priority'); + $rules[] = [ + 'index' => $index, + 'priority' => is_int($priority) ? $priority : 0, + 'id' => trim(strval($record->get('id') ?? '')) ?: '(no id)', + ]; + } + + if ($rules === []) { + return ['No rules yet.', '', 'Battles begin unchanged.']; + } + + usort($rules, static fn(array $left, array $right): int => [$left['priority'], $left['index']] <=> [$right['priority'], $right['index']]); + $lines = ['Runs in this order:']; + + foreach ($rules as $position => $rule) { + $lines[] = sprintf( + '%s%2d. %s%s', + $rule['index'] === $selected ? '> ' : ' ', + $position + 1, + $rule['id'], + $rule['priority'] !== 0 ? sprintf(' (p %d)', $rule['priority']) : '', + ); + } + + return $lines; + } + + /** + * @param list $cue + * @param list $frames + * @param list $preview + * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> + */ + private static function panes(string $cueTitle, array $cue, string $framesTitle, array $frames, array $preview): array + { + return [ + 'cue' => ['title' => $cueTitle, 'lines' => array_values($cue)], + 'frames' => ['title' => $framesTitle, 'lines' => array_values($frames)], + 'preview' => ['title' => 'Preview', 'lines' => array_values($preview)], + ]; + } +} diff --git a/src/Database/RecordRefusal.php b/src/Database/RecordRefusal.php new file mode 100644 index 00000000..17400720 --- /dev/null +++ b/src/Database/RecordRefusal.php @@ -0,0 +1,18 @@ +): string|null $labelFor The entry * label, when a record's name is made of its parts rather than stored. + * @param string|null $recordClass For one-file-per-record categories whose + * files return `['class' => $recordClass, 'data' => [...]]`: the record is + * the data, and saves write it back inside the same envelope. + * @param list $subLists Lists a record holds beside its own + * (`subList`), each shown inline under its own heading: a quest's reward + * items beside its objectives. Every list's prefix is its own. + * @param bool $identityFollowsLabel Whether the identity is its label's slug, kept + * in step on rename while nothing refers to it (a quest's id follows its name). + * @param bool $numberedFiles For one-file-per-record categories listed in file + * order (skills): a file is named `0001-.php`, a new or copied record + * takes the next number, and so goes last, as reopening lists it. + * @param Closure(array, string): ?string|null $saveCheck Why the runtime could + * not read the file as a save would write it, given that whole payload and the project root, or + * null when it can. Edits on the way there are free; the save is refused before a byte is written. + * @param bool $identityGiven Whether a record only ever belongs to something another category + * names (an enemy's battle art), so one is made for that name or not at all, never under an + * invented one the file could not key. + * @param Closure(array): string[]|null $commandListsFor The keys of the command lists + * one record offers, when they depend on what the record is: a summon's stage lists exist only + * where its stage does. Absent, every command list is offered. */ public function __construct( public string $key, @@ -60,12 +81,58 @@ public function __construct( public ?Closure $recordFilter = null, public ?Closure $makeBlank = null, public array $commandLists = [], - public bool $isAlwaysReadOnly = false, - public string $readOnlyNote = '', public ?RecordProjection $projection = null, public ?Closure $fieldsFor = null, public ?Closure $labelFor = null, + public ?string $recordClass = null, + public array $subLists = [], + public bool $identityFollowsLabel = false, + public bool $numberedFiles = false, + public ?Closure $saveCheck = null, + public bool $identityGiven = false, + public ?Closure $commandListsFor = null, + /** @var Closure(array): list|null Selects the inline lists offered by this record's shape. */ + public ?Closure $subListsFor = null, + /** Exclusive source owners refuse external edits rather than merging a stale snapshot. */ + public bool $requireUnchangedSource = false, + /** Normalize dependent fields on an edited record before accepting the draft. */ + public ?Closure $prepareEdit = null, ) { + if ($projection instanceof WholeFileProjection) { + // A field outside the keys the category owns would read blank and + // its edits would never reach the file. + $unowned = $projection->findUnownedKey([ + ...array_map(static fn(RecordField $field): string => $field->key, $fields), + ...array_map(static fn(RecordSubList $list): string => $list->key, $this->getInlineSubLists()), + ]); + + if ($unowned !== null) { + throw new LogicException(sprintf('The %s category edits "%s", a key its file projection does not own.', $key, $unowned)); + } + } + } + + /** + * The lists a record's rows show inline, its own first. + * + * @return list + */ + public function getInlineSubLists(array|object|null $payload = null): array + { + $lists = $this->subList === null ? $this->subLists : [$this->subList, ...$this->subLists]; + if ($payload === null || $this->subListsFor === null) { return $lists; } + $keys = ($this->subListsFor)((array) $payload); + return array_values(array_filter($lists, static fn(RecordSubList $list): bool => in_array($list->key, $keys, true))); + } + + /** The inline list held under a payload key; the record's own when the key is null. */ + public function findInlineSubList(?string $key, array|object|null $payload = null): ?RecordSubList + { + if ($key === null) { + return $this->subList; + } + + return array_find($this->getInlineSubLists($payload), static fn(RecordSubList $list): bool => $list->key === $key); } /** @@ -86,6 +153,25 @@ public function fieldsFor(array|object $payload): array return $fields; } + /** + * Returns the command lists one record offers, by key: every one, unless + * the record's shape decides. + * + * @param array|object $payload The record payload. + * @return array The lists. + */ + public function commandListsFor(array|object $payload): array + { + if ($this->commandListsFor === null) { + return $this->commandLists; + } + + /** @var string[] $keys */ + $keys = ($this->commandListsFor)(is_array($payload) ? $payload : (array) $payload); + + return array_intersect_key($this->commandLists, array_flip($keys)); + } + /** * Returns the absolute path this category reads from. * diff --git a/src/Database/RecordSchemaCatalog.php b/src/Database/RecordSchemaCatalog.php index 9adcbbb2..a3aadec8 100644 --- a/src/Database/RecordSchemaCatalog.php +++ b/src/Database/RecordSchemaCatalog.php @@ -4,10 +4,21 @@ namespace Ichiloto\Editor\Database; +use Ichiloto\Editor\Dialogue\DialogueLineSpeaker; +use Ichiloto\Engine\Animations\EffectScale; +use Ichiloto\Engine\Battle\Presentation\BattlerContact; +use Ichiloto\Engine\Entities\Enemies\EnemyHabitat; +use Ichiloto\Editor\Dialogue\DialoguePortraitFields; +use Ichiloto\Editor\Dialogue\DialogueSpeakerFields; +use Ichiloto\Editor\Field\PlayerPresentationFields; + use Closure; +use LogicException; use Ichiloto\Editor\ActorStatPreview; +use Ichiloto\Editor\Database\Projections\BattlerBindingProjection; use Ichiloto\Editor\Database\Projections\KeyedListProjection; +use Ichiloto\Editor\Actors\ActorAuthoring; use Ichiloto\Editor\Database\Projections\KnowledgeEnemyMappingProjection; use Ichiloto\Editor\Database\Projections\KnowledgeRecordTypeProjection; use Ichiloto\Editor\Database\Projections\KnowledgeReportProjection; @@ -15,24 +26,72 @@ use Ichiloto\Editor\Database\Projections\OptimizationOutcomeProjection; use Ichiloto\Editor\Database\Projections\OptimizationWeightProjection; use Ichiloto\Editor\EquipmentOptimizationPolicy; +use Ichiloto\Editor\ProjectConfig; use Ichiloto\Editor\Field\ProjectNpc; +use Ichiloto\Editor\Inspector\InputControl; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\PermanentGrowthCatalog; +use Ichiloto\Engine\Entities\States\State; +use Ichiloto\Engine\Entities\States\StateDisposition; +use Ichiloto\Engine\Scenes\Arena\ProjectBattleTest; +use Ichiloto\Engine\Progression\LevelUpRecovery; +use Ichiloto\Engine\Progression\ProgressionSettings; +use Ichiloto\Engine\Field\DashSettings; +use Ichiloto\Engine\Animations\AnimationTargetPosition; +use Ichiloto\Editor\Events\ProjectScriptCommands; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandField; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandFieldKind; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; +use Ichiloto\Engine\Battle\Events\BattleEventSpan; +use Ichiloto\Engine\Events\Interpreter\EventExecutionScope; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; +use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; +use Ichiloto\Engine\Events\Triggers\ScriptEventTrigger; use Ichiloto\Engine\Entities\Enemies\Enemy; +use Ichiloto\Engine\Entities\Enemies\EnemyCatalog; +use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; +use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; +use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus; +use Ichiloto\Engine\Entities\Enumerations\Occasion; +use Ichiloto\Engine\Entities\Skills\Skill; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; +use Ichiloto\Engine\Entities\Skills\SkillDraft; +use Ichiloto\Engine\Entities\Skills\SkillRecord; +use Ichiloto\Engine\Entities\Skills\SkillResolutionScope; +use Ichiloto\Engine\Field\MapAtlasTerrain; +use Ichiloto\Engine\Rendering\Tilesets\TerrainKind; +use Ichiloto\Engine\Rendering\Tilesets\TerrainPassage; +use Ichiloto\Engine\Rendering\Tilesets\TileAppearance; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Rendering\Tilesets\TilesetAppearance; +use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; +use Ichiloto\Engine\Rendering\Tilesets\TilesetSheet; +use Ichiloto\Engine\Rendering\Tilesets\TilesetThicket; +use Ichiloto\Engine\Quests\QuestObjectiveType; +use Ichiloto\Engine\Income\IncomeCadence; +use Ichiloto\Engine\Income\IncomeCatalog; +use Ichiloto\Engine\Income\IncomeDelivery; +use Ichiloto\Engine\Income\IncomeSchedule; +use Ichiloto\Engine\Quests\QuestWaymarkSubject; +use Ichiloto\Engine\Battle\Enumerations\BattleEngineType; +use Ichiloto\Engine\Battle\Presentation\BattlePoseRole; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; +use Ichiloto\Engine\Battle\Presentation\BattlerBindings; +use Ichiloto\Engine\Core\Enumerations\MovementHeading; +use Ichiloto\Editor\Database\Projections\WholeFileProjection; +use Ichiloto\Engine\Entities\Enumerations\ActionConditionType; use Ichiloto\Engine\Entities\Enumerations\ItemUserType; use Ichiloto\Engine\Progress\Knowledge\KnowledgeProgressService; -use Ichiloto\Engine\Entities\Inventory\Accessory; +use Ichiloto\Engine\Entities\Inventory\InventoryItem; +use Ichiloto\Engine\Entities\Inventory\ItemCatalog; +use Ichiloto\Engine\Entities\Inventory\ItemRecord; +use Ichiloto\Engine\Entities\Enumerations\ValueBasis; use Ichiloto\Engine\Entities\Inventory\EquipmentSlotType; -use Ichiloto\Engine\Entities\Inventory\Armor; -use Ichiloto\Engine\Entities\Inventory\Items\Item; -use Ichiloto\Engine\Entities\Inventory\Weapons\Weapon; use Ichiloto\Engine\Entities\Enumerations\ArmorType; use Ichiloto\Engine\Entities\Enumerations\WeaponType; use Ichiloto\Engine\Entities\Character; -use Ichiloto\Engine\Entities\ParameterChanges; -use Ichiloto\Engine\Entities\Stats; -use Ichiloto\Engine\Battle\BattleRewards; /** * The schemas behind the Database categories added in Phase 6. @@ -41,34 +100,59 @@ * when the file loads (see `PhpDataFile`). What these schemas declare is * where records live, what an entry is called, and which fields the settings * pane shows. Categories whose authored files are PHP constructor calls - * (`items.php`, `enemies.php`) still get real field lists, because browsing - * an enemy's stats is useful even when the editor refuses to rewrite them. + * (`items.php`) still get real field lists, because browsing an item is + * useful even when the editor refuses to rewrite it. */ final class RecordSchemaCatalog { + /** A skill's battle message where none is authored: the Engine shows the action's name. */ + public const string SKILL_MESSAGE_DEFAULT = '%2'; + + /** How a skit without a backdrop of its own reads: it shows the backdrop of the place it plays in. */ + public const string SKIT_BACKDROP_NONE = 'None: the place\'s backdrop'; + /** - * The event-script command types the engine's interpreter understands. + * The event-script command types built into the engine's interpreter. * * Imported from the runtime so the editor cannot drift into a duplicate - * command registry. + * command vocabulary. Registered commands join them through + * getEventCommandTypes(). */ public const array EVENT_COMMAND_TYPES = EventInterpreter::COMMAND_TYPES; + /** + * The stats a class grows by a curve, in `ClassStore`'s order, with the + * abbreviations their rows are labelled by. + */ + private const array CLASS_CURVE_STATS = [ + 'totalHp' => 'HP', 'totalMp' => 'MP', 'attack' => 'ATK', 'defence' => 'DEF', 'magicAttack' => 'MAT', + 'magicDefence' => 'MDF', 'speed' => 'SPD', 'grace' => 'GRC', 'evasion' => 'EVA', + ]; + /** * Returns every schema-driven category, keyed by Database category key. * * @return array */ - public static function all(): array + public static function all(bool $graphical = false): array { $schemas = [ + self::classes(), + self::quests(), + self::incomeSchedules(), + self::system(), self::states(), self::troops(), + self::animations(), self::battleEntryRules(), self::items(), self::weapons(), self::armors(), self::enemies(), + self::skills(), + MasteryFields::getRegionSchema(), + MasteryFields::getBranchSchema(), + MasteryFields::getMapSchema($graphical), self::skits(), self::knowledgeSubjects(), self::knowledgeReports(), @@ -78,10 +162,25 @@ public static function all(): array self::optimizeWeights(), self::optimizeOutcomes(), self::optimizeExclusions(), - self::commonEvents(), + self::commonEvents($graphical), self::terms(), + WordFormFields::getSchema(), + \Ichiloto\Editor\Field\MapCategoryFields::getSchema(), + \Ichiloto\Editor\Field\MapRoleFields::getSchema(), + \Ichiloto\Editor\Field\PlaceTypeFields::getSchema(), + \Ichiloto\Editor\Field\RegionFields::getSchema(), + DialoguePortraitFields::getSchema(), + DialogueSpeakerFields::getSchema(), + FieldCueFields::getSchema(), + self::configuration($graphical), self::types(), - self::tilesets(), + self::tilesets($graphical), + self::battlerArt('actors'), + self::battlerArt('enemies'), + self::battleScaleReference(), + ...($graphical ? [PlayerPresentationFields::getSchema(), \Ichiloto\Editor\Field\FieldResourceFields::getSchema(), + \Ichiloto\Editor\Field\TextRoleFields::getSchema(), MenuThemeFields::getSchema(), + BattleArenaFields::getSchema(), BattleArenaFields::getDefaultSchema()] : []), ]; $keyed = []; @@ -126,6 +225,26 @@ private static function states(): RecordSchema new RecordField('tickFormula', 'Tick Formula', removeWhenEmpty: true), RecordField::boolean('preventsAction', 'Prevents Action'), RecordField::boolean('persistsAfterBattle', 'Persists After Battle'), + // The badge text menus and battle show where no icon art is bound. + new RecordField('label', 'Badge Label', removeWhenEmpty: true, displayDefault: '(from the name)'), + // Outside battle: steps walked per turn of the tick formula, and whether that can knock out. + new RecordField('fieldTickSteps', 'Field Steps Per Turn', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(not in the field)'), + RecordField::boolean('fieldTickKnocksOut', 'Field Ticks Knock Out'), + // Inns and full party recovery cure it unless the author says otherwise. + RecordField::boolean('curedByRest', 'Cured By Rest', removeWhenEmpty: false, displayDefault: 'true'), + // Whether it harms, enhances or is neutral to its bearer; battle poses follow it. + new RecordField('disposition', 'Disposition', + options: array_map(static fn(StateDisposition $disposition): string => $disposition->value, StateDisposition::cases()), + removeWhenEmpty: true, displayDefault: StateDisposition::HARMFUL->value), + // A bearer of this state counters while it is active. + self::counterAttackField(), + // Protection: scales every hit a guard could block (0.5 halves it, 0 blocks it). + new RecordField('damageTakenRate', 'Damage Taken Rate', InputControlType::FLOAT, removeWhenEmpty: true, displayDefault: '1 (unchanged)'), + // Cover: the bearer takes the single-target hits meant for its weakest ally. + RecordField::boolean('coversAllies', 'Covers Allies'), + // Confusion: the chance each turn that the bearer attacks a random battler of either side instead. + new RecordField('confusionChancePercent', 'Confusion Chance %', InputControlType::INTEGER, removeWhenEmpty: true, + displayDefault: '0 (never)'), ], labelKey: 'name', identityKey: 'id', @@ -135,15 +254,82 @@ private static function states(): RecordSchema 'icon' => '', 'description' => '', ], + saveCheck: self::checkStates(...), ); } + /** + * Why the Engine could not read the states file as it would be saved: each + * entry goes through the Engine's own state reader, which would otherwise + * skip an invalid state at runtime. + * + * @param array $whole The states, as they would be saved. + * @param string $projectRoot The project root. + * @return string|null Why, or null when every state reads. + */ + public static function checkStates(array $whole, string $projectRoot): ?string + { + foreach ($whole as $index => $entry) { + try { + State::fromArray(is_array($entry) ? $entry : []); + } catch (\InvalidArgumentException $error) { + return sprintf('State %d could not be read as it would be saved: %s', intval($index) + 1, $error->getMessage()); + } + } + + return null; + } + /** * Encounter groups — `assets/Data/troops.php`, a plain array read through * the engine's `get_troop()` helper. Fully editable, members included. * * @return RecordSchema */ + /** + * Animations -- `assets/Data/animations.php`, the list the Engine's + * AnimationLibrary reads. A record names the effect timelines it plays on + * the caster and the target, and the battle roles it plays for; a role + * plays one animation, so taking one another record holds is refused. + * An older record's own frames and cues are kept exactly as written and + * shown read-only beside the position they play at: the Engine still + * plays them through its importer, and new animation is authored as + * effect timelines. + */ + private static function animations(): RecordSchema + { + $fields = [ + new RecordField('id', 'Id', InputControlType::INTEGER, isReadOnly: true), + new RecordField('name', 'Name'), + RecordField::reference('sourceEffect', 'Caster Effect', 'effects', allowsNone: true, noneLabel: '(none)'), + RecordField::reference('targetEffect', 'Target Effect', 'effects', allowsNone: true, noneLabel: '(none)'), + new RecordField('roles', 'Roles', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true, + reference: 'animation_roles', uniqueAcrossRecords: true), + ]; + $legacy = [ + new RecordField('position', 'Legacy Position', + options: array_map(static fn(AnimationTargetPosition $position): string => $position->value, AnimationTargetPosition::cases()), + removeWhenEmpty: true), + new RecordField('maxFrames', 'Legacy Frame Count', InputControlType::INTEGER, isReadOnly: true), + new RecordField('frames', 'Legacy Frames', isReadOnly: true), + new RecordField('cues', 'Legacy Cues', isReadOnly: true), + ]; + + return new RecordSchema( + key: 'animations', + entryNoun: 'animation', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/animations.php', + fields: $fields, + labelKey: 'name', + identityKey: 'id', + // A numeric identity: a new animation takes the next free number. + blank: ['id' => 1, 'name' => 'New Animation'], + fieldsFor: static fn(array $row): array => array_key_exists('frames', $row) || array_key_exists('cues', $row) + ? [...$fields, ...$legacy] : $fields, + ); + } + private static function troops(): RecordSchema { return new RecordSchema( @@ -169,14 +355,24 @@ private static function troops(): RecordSchema removeWhenEmpty: true, displayDefault: 'ordinary', ), + // A troop's own battle music (a boss theme) outranks the + // project's battle theme; left unset, the battle plays that. + new RecordField( + 'bgm', + 'Battle Music', + removeWhenEmpty: true, + reference: 'bgm', + allowsNone: true, + displayDefault: 'Project battle theme', + ), ], labelKey: 'name', identityKey: 'name', + // A new troop starts empty, as in RPG Maker: its members are + // chosen from the project's own enemies, never assumed. blank: [ 'name' => 'New Troop', - 'enemies' => [ - ['enemy' => 'Regular Bat', 'position' => [15, 7]], - ], + 'enemies' => [], ], subList: new RecordSubList( key: 'enemies', @@ -186,9 +382,55 @@ private static function troops(): RecordSchema RecordField::reference('enemy', 'Enemy', 'enemies'), new RecordField('position.0', 'X', InputControlType::INTEGER), new RecordField('position.1', 'Y', InputControlType::INTEGER), + // The graphical battle's own placement: the column its feet stand on and how far back + // from any arena ground's near edge, and its contain limits; never the terminal position above. + // Without one the battle arranges the member beside the others. + new RecordField('graphicalPlacement', 'Battle Placement', codec: RecordFieldCodec::STAGE_SLOT, removeWhenEmpty: true, + displayDefault: '(arranged by the battle)'), + // Damage cannot take the member below this share of its HP, so an event page can end the + // fight first (a concession); 0 lets it be knocked out. + new RecordField('knockoutFloorPercent', 'Cannot Fall Below HP %', InputControlType::INTEGER, removeWhenEmpty: true), ], - blank: ['enemy' => 'Regular Bat', 'position' => [15, 7]], + // The enemy is chosen when a member is added (TroopAuthoring). + blank: ['enemy' => '', 'position' => [15, 7]], + // Event pages name members by index; they follow a member that moves. + remapIndexReferences: TroopEventPageContract::remapMembers(...), ), + subLists: [self::troopEventPageList()], + // The Engine reads event pages when a battle starts; a page it would refuse is refused here. + saveCheck: TroopEventPageContract::checkTroops(...), + ); + } + + /** + * A troop's battle event pages, the Engine's BattleEventPage: when each + * runs over the paused battle and the battle commands it runs. Every + * given condition must hold; members are chosen from the troop itself. + */ + private static function troopEventPageList(): RecordSubList + { + return new RecordSubList( + key: 'events', + prefix: 'page', + singular: 'event page', + fields: [ + new RecordField('name', 'Name', removeWhenEmpty: true), + new RecordField('span', 'Span', options: array_map(static fn(BattleEventSpan $span): string => $span->value, BattleEventSpan::cases())), + // 0 or blank leaves a condition out. + new RecordField('conditions.turn', 'Turn', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('conditions.turnEvery', 'Then Every N Turns', InputControlType::INTEGER, removeWhenEmpty: true), + RecordField::troopMember('conditions.enemy', 'Enemy', required: false), + new RecordField('conditions.enemyHpPercent', 'Enemy HP At Or Below %', InputControlType::INTEGER, removeWhenEmpty: true), + RecordField::reference('conditions.actor', 'Party Member', 'actor_ids', allowsNone: true), + new RecordField('conditions.actorHpPercent', 'Party Member HP At Or Below %', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('conditions.world', 'World Conditions', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + ], + // A new page opens the battle, as RPG Maker's first page does; its commands are added in its frame. + blank: ['span' => BattleEventSpan::BATTLE->value, 'conditions' => ['turn' => 1], 'commands' => []], + commandArms: ['commands' => 'Commands'], + heading: 'Event Pages', + removeWhenEmpty: true, + armCommandList: self::eventCommandList('commands', scope: EventExecutionScope::BATTLE), ); } @@ -271,42 +513,86 @@ private static function battleEntryRules(): RecordSchema ); } + /** A skill's or item's Effect Scale, bounded and defaulted by the Engine's animation binding. */ + private static function animationScaleField(): RecordField + { + return new RecordField('animationScale', 'Effect Scale', InputControlType::FLOAT, removeWhenEmpty: true, + displayDefault: (string) EffectScale::DEFAULT); + } + /** - * Consumables and key items — the `Item` entries of `assets/Data/items.php`. - * - * The file is authored as `new Item(...)` constructor calls, so the editor - * browses it and never rewrites it. + * Consumables and key items: one record per numbered file under + * `assets/Data/Items`, in the form the Engine's ItemRecord reads, with + * whom an item reaches, when, its effects and its animation. * * @return RecordSchema */ private static function items(): RecordSchema { + $enumValues = static fn(array $cases): array => array_map(static fn(\BackedEnum $case): string => strval($case->value), $cases); + return new RecordSchema( key: 'items', entryNoun: 'item', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/items.php', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . ItemCatalog::DIRECTORIES[0], fields: [ ...self::inventoryFields(), - // Only a plain item carries a stack limit: the engine's - // Equipment constructor does not take one. - new RecordField('maxQuantity', 'Max Quantity', InputControlType::INTEGER), + new RecordField('scope.side', 'Scope Side', options: $enumValues(ItemScopeSide::cases()), removeWhenEmpty: true, displayDefault: ItemScopeSide::NONE->value), + new RecordField('scope.number', 'Scope Number', options: $enumValues(ItemScopeNumber::cases()), removeWhenEmpty: true, displayDefault: ItemScopeNumber::ONE->value), + new RecordField('scope.status', 'Scope Status', options: $enumValues(ItemScopeStatus::cases()), removeWhenEmpty: true, displayDefault: ItemScopeStatus::ALIVE->value), + new RecordField('scope.randomNumber', 'Random Targets', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('occasion', 'Occasion', options: $enumValues(Occasion::cases()), removeWhenEmpty: true, displayDefault: Occasion::ALWAYS->value), + new RecordField( + 'animationId', + 'Animation', + InputControlType::INTEGER, + reference: 'animation_ids', + removeWhenEmpty: true, + allowsNone: true, + displayDefault: '(None)', + ), + // How large the bound animation's target effect plays: one timeline serves every grade of a spell. + self::animationScaleField(), ], labelKey: 'name', identityKey: 'id', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Item, - makeBlank: static fn(string $name): object => new Item( - $name, - 'What it does.', - '✨', - 0, - id: self::inventoryDefinitionId('item', $name), + subList: new RecordSubList( + key: 'effects', + prefix: 'effect', + singular: 'effect', + fields: [ + new RecordField('type', 'Type', options: array_keys(ItemRecord::EFFECTS)), + new RecordField('name', 'Name'), + new RecordField('description', 'Description'), + ], + blank: ['type' => 'hp_recovery', 'name' => 'Recover HP', 'description' => 'Recovers HP', 'value' => 50, 'successRate' => 1.0], + // A cure names the states it removes; every other effect carries a value. + variants: array_map(static fn(string $type): array => $type === 'remove_state' + ? [new RecordField('stateIds', 'States', reference: 'states', codec: RecordFieldCodec::CSV_LIST)] + : [ + new RecordField('value', 'Value', InputControlType::INTEGER), + new RecordField('successRate', 'Success Rate', InputControlType::FLOAT), + new RecordField('valueBasis', 'Value Basis', options: $enumValues(ValueBasis::cases()), removeWhenEmpty: true, displayDefault: ValueBasis::ACTUAL->value), + ], array_combine(array_keys(ItemRecord::EFFECTS), array_keys(ItemRecord::EFFECTS))), + variantKey: 'type', + removeWhenEmpty: true, ), + makeBlank: static fn(string $name): array => [ + 'kind' => 'item', + 'id' => self::inventoryDefinitionId('item', $name), + 'name' => $name, + 'description' => 'What it does.', + 'icon' => '✨', + 'price' => 0, + ], + recordClass: InventoryItem::class, + numberedFiles: true, ); } /** - * Weapons — the `Weapon` entries of `assets/Data/items.php`. Edited by rebuilding the entry. + * Weapons: one record per numbered file under `assets/Data/Weapons`. * * @return RecordSchema */ @@ -315,69 +601,80 @@ private static function weapons(): RecordSchema return new RecordSchema( key: 'weapons', entryNoun: 'weapon', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/items.php', - fields: self::equipmentFields([ + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . ItemCatalog::DIRECTORIES[1], + fields: self::equipmentFields(EquipmentSlotType::WEAPON, [ new RecordField( 'equipmentType', 'Equipment Type', options: array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases()), - enumClass: WeaponType::class, + removeWhenEmpty: true, ), ...self::parameterChangeFields(), ]), labelKey: 'name', identityKey: 'id', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Weapon, - makeBlank: static fn(string $name): object => new Weapon( - $name, - 'What it does.', - '🗡', - 0, - equipmentType: WeaponType::SWORD, - parameterChanges: new ParameterChanges(attack: 1), - id: self::inventoryDefinitionId('equipment', $name), - ), + makeBlank: static fn(string $name): array => [ + 'kind' => 'weapon', + 'id' => self::inventoryDefinitionId('equipment', $name), + 'name' => $name, + 'description' => 'What it does.', + 'icon' => '🗡', + 'price' => 0, + 'equipmentType' => WeaponType::SWORD->value, + 'parameterChanges' => ['attack' => 1], + ], + recordClass: InventoryItem::class, + numberedFiles: true, ); } /** - * Armors and accessories — the `Armor`/`Accessory` entries of - * `assets/Data/items.php`. Edited by rebuilding the entry. + * Armors and accessories: one record per numbered file under + * `assets/Data/Armors`. An accessory has no equipment type. * * @return RecordSchema */ private static function armors(): RecordSchema { + $kind = new RecordField('kind', 'Kind', options: ['armor', 'accessory']); + $type = new RecordField( + 'equipmentType', + 'Equipment Type', + options: array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), + removeWhenEmpty: true, + ); + $fields = static fn(bool $isAccessory): array => [ + $kind, + ...self::equipmentFields($isAccessory ? EquipmentSlotType::ACCESSORY : EquipmentSlotType::BODY, [ + ...($isAccessory ? [] : [$type]), + ...self::parameterChangeFields(), + ]), + ]; + return new RecordSchema( key: 'armors', entryNoun: 'armor', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/items.php', - fields: self::equipmentFields([ - new RecordField( - 'equipmentType', - 'Equipment Type', - options: array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), - enumClass: ArmorType::class, - ), - ...self::parameterChangeFields(), - ]), + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . ItemCatalog::DIRECTORIES[2], + fields: $fields(false), labelKey: 'name', identityKey: 'id', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Armor || $entry instanceof Accessory, - makeBlank: static fn(string $name): object => new Armor( - $name, - 'What it protects against.', - '🛡', - 0, - equipmentType: ArmorType::GENERAL_ARMOR, - parameterChanges: new ParameterChanges(defence: 1), - id: self::inventoryDefinitionId('equipment', $name), - ), + makeBlank: static fn(string $name): array => [ + 'kind' => 'armor', + 'id' => self::inventoryDefinitionId('equipment', $name), + 'name' => $name, + 'description' => 'What it protects against.', + 'icon' => '🛡', + 'price' => 0, + 'equipmentType' => ArmorType::GENERAL_ARMOR->value, + 'parameterChanges' => ['defence' => 1], + ], + fieldsFor: static fn(array $row): array => $fields(strval($row['kind'] ?? 'armor') === 'accessory'), + recordClass: InventoryItem::class, + numberedFiles: true, ); } - /** * Knowledge subjects — the `subjects` list of * `assets/Data/knowledge.php`. @@ -414,6 +711,9 @@ private static function knowledgeSubjects(): RecordSchema new RecordField('habitats', 'Habitats', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), new RecordField('observations', 'Observations', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), new RecordField('displayOrder', 'Display Order', InputControlType::INTEGER, step: 10), + // A summon met in battle: its record reads that summon's + // Summon Info rather than a copy of it. + new RecordField('summon', 'Summon', removeWhenEmpty: true, reference: 'summons', allowsNone: true, displayDefault: '(none)'), // A subject the party has not met yet is authored, but not // listed, until something discovers it. RecordField::boolean('hidden', 'Hidden Until Discovered'), @@ -492,7 +792,7 @@ private static function knowledgeReports(): RecordSchema * Knowledge record types — the `recordTypes` list of the catalogue. * * The kinds of record a project declares, and the only kinds a subject - * can be. + * can be, each with the label players see it called. * * @return RecordSchema */ @@ -503,10 +803,14 @@ private static function knowledgeRecordTypes(): RecordSchema entryNoun: 'record type', storage: RecordStorage::LIST_FILE, relativePath: 'assets/Data/knowledge.php', - fields: [new RecordField('type', 'Record Type')], + fields: [ + new RecordField('type', 'Record Type'), + // What players see the kind called; without one they see its id. + new RecordField('label', 'Label'), + ], labelKey: 'type', identityKey: 'type', - blank: ['type' => 'new-record-type'], + blank: ['type' => 'new-record-type', 'label' => ''], projection: new KnowledgeRecordTypeProjection(), ); } @@ -751,11 +1055,380 @@ private static function optimizeExclusions(): RecordSchema } /** - * Enemies — `assets/Data/enemies.php`. + * System — `assets/Data/system.php`, one map the engine reads at start: + * the title, starting gold, party, inventory and position, battle + * settings, progression rules and the dash. Its elements are the Types category's. The file is the category's one record, + * only ever edited. The starting party and inventory are lists of their + * own; a party member is a bare actor id, as the file authors it. + * + * @return RecordSchema + */ + private static function system(): RecordSchema + { + return new RecordSchema( + key: 'system', + entryNoun: 'system settings', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/system.php', + fields: [ + new RecordField('title', 'Title'), + new RecordField('currency.amount', 'Starting Gold', InputControlType::INTEGER), + RecordField::reference('startingPositions.player.destinationMap', 'Start Map', 'maps'), + new RecordField('startingPositions.player.spawnPoint.x', 'Start X', InputControlType::INTEGER), + new RecordField('startingPositions.player.spawnPoint.y', 'Start Y', InputControlType::INTEGER), + new RecordField( + 'startingPositions.player.spawnSprite.0', + 'Start Facing', + options: array_map(static fn(MovementHeading $heading): string => $heading->value, MovementHeading::cases()), + ), + new RecordField( + 'battle.engine', + 'Battle Engine', + options: array_map(static fn(BattleEngineType $engine): string => $engine->value, BattleEngineType::cases()), + ), + new RecordField('battle.opening.preemptiveChancePercent', 'Preemptive Chance %', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.opening.ambushChancePercent', 'Ambush Chance %', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.activeTime.mode', 'Time Gauge Mode', options: ['wait']), + new RecordField('battle.activeTime.baseFillRate', 'Time Gauge Fill Rate', InputControlType::INTEGER), + new RecordField('battle.activeTime.speedFactorPercent', 'Time Gauge Speed Factor %', InputControlType::INTEGER), + new RecordField('battle.activeTime.openingVariance', 'Time Gauge Opening Variance', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('battle.activeTime.openingSpeedFactorPercent', 'Time Gauge Opening Speed %', InputControlType::INTEGER, removeWhenEmpty: true), + // The Engine's ProgressionSettings: what a level gained does to current HP and MP. + new RecordField( + 'progression.' . ProgressionSettings::LEVEL_UP_RECOVERY, + 'Level Up HP/MP', + options: array_map(static fn(LevelUpRecovery $mode): string => $mode->value, LevelUpRecovery::cases()), + displayDefault: LevelUpRecovery::FULL->value, + ), + // The Engine's DashSettings: whether the player may dash, as RPG Maker's dash, and how much faster. + // A map turns it off in its own Inspector (Disable Dashing). + RecordField::boolean('dash.' . DashSettings::ENABLED, 'Dash', displayDefault: 'false'), + new RecordField('dash.' . DashSettings::SPEED_MULTIPLIER, 'Dash Speed Multiplier', InputControlType::FLOAT, + removeWhenEmpty: true, displayDefault: InputControl::formatFloat(DashSettings::DEFAULT_SPEED_MULTIPLIER)), + // Test settings, as RPG Maker keeps its Battle Test: normal play never reads them. + new RecordField(ProjectBattleTest::SYSTEM_KEY, 'Battle Test', removeWhenEmpty: true, codec: RecordFieldCodec::BATTLE_TEST), + ], + labelKey: 'title', + identityKey: null, + // The keys System owns; its elements are the Types category's. + projection: new WholeFileProjection(['title', 'currency', 'startingPositions', 'startingParty', 'startingInventory', 'battle', 'progression', DashSettings::KEY, ProjectBattleTest::SYSTEM_KEY]), + subLists: [ + new RecordSubList( + key: 'startingParty', + prefix: 'member', + singular: 'party member', + fields: [RecordField::reference('actor', 'Actor', 'actor_ids')], + blank: ['actor' => ''], + heading: 'Starting Party', + scalarKey: 'actor', + ), + new RecordSubList( + key: 'startingInventory', + prefix: 'stock', + singular: 'starting item', + fields: [ + RecordField::reference('item', 'Item', 'inventory'), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER), + ], + blank: ['item' => '', 'quantity' => 1], + heading: 'Starting Inventory', + ), + ], + ); + } + + /** + * Quests — `assets/Data/quests.php`, as the engine's `Quest::fromArray` + * reads it. A quest's id is its name's slug and follows a rename while + * nothing refers to it (the workspace knows what does). Objectives are + * its own list; the target an objective is picked from follows its type. + * Reward items are a second list, each a bare item name until it is given + * a quantity, the two forms the engine reads. + * + * @return RecordSchema + */ + private static function quests(): RecordSchema + { + $objectiveFields = static fn(?string $reference): array => [ + $reference === null + ? new RecordField('target', 'Target') + : RecordField::reference('target', 'Target', $reference), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('description', 'Text', removeWhenEmpty: true), + new RecordField('revealedDescription', 'Revealed', removeWhenEmpty: true), + new RecordField('revealConditions', 'Reveal When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + ]; + $variants = []; + + foreach (QuestObjectiveType::cases() as $type) { + $variants[$type->value] = $objectiveFields(match ($type) { + // Collecting is not limited to consumables: a quest may ask + // for a weapon or a piece of armor, and the runtime resolves + // all three from one catalogue. + QuestObjectiveType::COLLECT => 'inventory', + QuestObjectiveType::DEFEAT => 'enemies', + QuestObjectiveType::REACH_MAP => 'maps', + QuestObjectiveType::TALK_TO => 'actors', + default => null, + }); + } + + return new RecordSchema( + key: 'quests', + entryNoun: 'quest', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/quests.php', + fields: [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('name', 'Name'), + new RecordField('description', 'Description'), + new RecordField('giver', 'Giver'), + RecordField::boolean('optional', 'Optional'), + new RecordField('rewards.gold', 'Reward Gold', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('rewards.experience', 'Reward EXP', InputControlType::INTEGER, removeWhenEmpty: true), + // Mastery points (SP) every travelling member earns with the EXP. + new RecordField('rewards.skillPoints', 'Reward SP', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('prerequisites', 'Prereqs', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + ], + labelKey: 'name', + identityKey: 'id', + blank: [ + 'id' => 'new-quest', + 'name' => 'New Quest', + 'description' => '', + 'objectives' => [['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target']], + ], + subList: new RecordSubList( + key: 'objectives', + prefix: 'objective', + singular: 'objective', + fields: [ + new RecordField( + 'type', + 'Type', + options: array_map(static fn(QuestObjectiveType $type): string => $type->value, QuestObjectiveType::cases()), + ), + ], + blank: ['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target'], + variants: $variants, + variantKey: 'type', + nestedLists: ['*' => self::questWaymarks('waymarks', 'waymark', 'waymark', 'Waymarks')], + ), + subLists: [ + // Where the quest starts, shown while it can start, so the player is led to its giver. + self::questWaymarks('startWaymarks', 'startWaymark', 'start waymark', 'Start Waymarks'), + new RecordSubList( + key: 'rewards.items', + prefix: 'reward', + singular: 'reward item', + fields: [ + RecordField::reference('item', 'Item', 'inventory'), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER, removeWhenEmpty: true), + ], + blank: ['item' => ''], + heading: 'Reward Items', + scalarKey: 'item', + removeWhenEmpty: true, + ), + ], + identityFollowsLabel: true, + ); + } + + /** + * Income schedules -- the `schedules` list of `assets/Data/income.php`, + * the file the Engine's IncomeCatalog reads. A project without the file + * has no income; the first saved schedule creates it. * - * The file builds `new Enemy(...)` objects and shares skill instances - * between them through local variables, so it cannot be regenerated from - * the loaded values. Edited by rebuilding the entry. + * A schedule's id follows its name until a script names it, so payday, + * collect_income and adjust_income keep resolving. Its pay table is an + * ordered list of condition rows; the first that holds sets the pay. + * + * @return RecordSchema + */ + private static function incomeSchedules(): RecordSchema + { + $fields = [ + new RecordField('id', 'Id', isReadOnly: true), + new RecordField('name', 'Name'), + new RecordField('conditions', 'Runs When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + new RecordField('cadence', 'Counts', options: IncomeCadence::values()), + // Seconds of active play, steps or won battles, by cadence. + 'interval' => new RecordField('interval', 'Pays Every', InputControlType::INTEGER, removeWhenEmpty: true), + 'idleTimeout' => new RecordField( + 'idleTimeout', + 'Idle After (s)', + InputControlType::INTEGER, + removeWhenEmpty: true, + displayDefault: strval(IncomeSchedule::DEFAULT_IDLE_TIMEOUT), + ), + new RecordField('amount', 'Pay', InputControlType::INTEGER), + new RecordField( + 'delivery', + 'Delivery', + options: IncomeDelivery::values(), + removeWhenEmpty: true, + displayDefault: IncomeDelivery::DEPOSIT->value, + ), + ]; + + return new RecordSchema( + key: 'income_schedules', + entryNoun: 'income schedule', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/' . IncomeCatalog::FILE, + fields: array_values($fields), + // Only an active-time schedule has an idle timeout, and the event + // cadence has no interval: changing the cadence drops what no longer applies. + fieldsFor: static function (array $row) use ($fields): array { + $cadence = IncomeCadence::tryFrom(strval($row['cadence'] ?? '')) ?? IncomeCadence::ACTIVE_TIME; + $shaped = $fields; + if (! $cadence->isCounted()) { unset($shaped['interval']); } + if ($cadence !== IncomeCadence::ACTIVE_TIME) { unset($shaped['idleTimeout']); } + return array_values($shaped); + }, + labelKey: 'name', + identityKey: 'id', + blank: [ + 'id' => 'new-income', + 'name' => 'New Income', + 'cadence' => IncomeCadence::ACTIVE_TIME->value, + 'interval' => 3600, + 'amount' => 0, + ], + projection: new KeyedListProjection('schedules'), + subList: new RecordSubList( + key: 'rates', + prefix: 'rate', + singular: 'rate', + fields: [ + // Required at runtime: a row without conditions would always hold. + new RecordField('conditions', 'When', codec: RecordFieldCodec::CONDITIONS), + new RecordField('amount', 'Pay', InputControlType::INTEGER), + ], + blank: ['conditions' => [], 'amount' => 0], + heading: 'Pay Rates', + removeWhenEmpty: true, + ), + identityFollowsLabel: true, + ); + } + + /** + * Quest waymarks: where an objective sends the player, or, for a quest's + * start waymarks, where it starts. Each names a map and at most one + * stable subject on it, chosen from that map's own events, NPCs, world + * objects or landmarks, never coordinates. Its conditions say when it + * shows: a phase, or one part of free-order work. + */ + private static function questWaymarks(string $key, string $prefix, string $singular, string $heading): RecordSubList + { + $subjects = array_map(static fn(QuestWaymarkSubject $subject): string => $subject->value, QuestWaymarkSubject::cases()); + + return new RecordSubList( + key: $key, + prefix: $prefix, + singular: $singular, + fields: [ + RecordField::reference('map', 'Map', 'maps'), + RecordField::mapReference(QuestWaymarkSubject::EVENT->value, 'Event', 'event_markers', 'map'), + RecordField::mapReference(QuestWaymarkSubject::NPC->value, 'NPC', 'map_npcs', 'map'), + RecordField::mapReference(QuestWaymarkSubject::OBJECT->value, 'Object', 'map_world_objects', 'map'), + RecordField::mapReference(QuestWaymarkSubject::LANDMARK->value, 'Landmark', 'map_landmarks', 'map'), + new RecordField('conditions', 'Shows When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + ], + blank: ['map' => ''], + exclusiveFields: [$subjects], + heading: $heading, + removeWhenEmpty: true, + ); + } + + /** + * Classes — `assets/Data/classes.php`, the plain data the engine's + * `ClassStore` reads: growth curves, equipment types, skills learned by + * level, and the curriculum of Mastery nodes the role recommends. A class + * is addressed by its numeric id; new ones take the next. + * + * @return RecordSchema + */ + private static function classes(): RecordSchema + { + $curves = []; + + foreach (self::CLASS_CURVE_STATS as $stat => $label) { + $curves[] = new RecordField("parameterCurves.{$stat}.baseValue", "{$label} Base", InputControlType::INTEGER); + $curves[] = new RecordField("parameterCurves.{$stat}.extraGrowth", "{$label} Growth", InputControlType::INTEGER); + $curves[] = new RecordField("parameterCurves.{$stat}.flatIncrement", "{$label} Per Level", InputControlType::INTEGER); + } + + return new RecordSchema( + key: 'classes', + entryNoun: 'class', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/classes.php', + fields: [ + new RecordField('id', 'Id', InputControlType::INTEGER, isReadOnly: true), + new RecordField('name', 'Name', uniqueAcrossRecords: true), + new RecordField('description', 'Description'), + new RecordField('note', 'Note'), + new RecordField('initialLevel', 'Initial Level', InputControlType::INTEGER), + new RecordField('maxLevel', 'Max Level', InputControlType::INTEGER), + new RecordField('equipment.weapons', 'Weapon Types', codec: RecordFieldCodec::CSV_LIST, reference: 'weapon_types'), + new RecordField('equipment.armor', 'Armor Types', codec: RecordFieldCodec::CSV_LIST, reference: 'armor_types'), + // The Mastery nodes the role recommends, by node id; a guide, never a limit. + new RecordField('curriculum', 'Curriculum', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), + new RecordField('experienceCurve.baseValue', 'EXP Base', InputControlType::INTEGER), + new RecordField('experienceCurve.extraValue', 'EXP Extra', InputControlType::INTEGER), + new RecordField('experienceCurve.accelerationA', 'EXP Accel A', InputControlType::INTEGER), + new RecordField('experienceCurve.accelerationB', 'EXP Accel B', InputControlType::INTEGER), + ...$curves, + ], + labelKey: 'name', + identityKey: 'id', + blank: [ + 'id' => 1, + 'name' => 'New Class', + 'description' => '', + 'initialLevel' => 1, + 'maxLevel' => 99, + 'equipment' => ['weapons' => [], 'armor' => []], + 'skillsToLearn' => [], + 'experienceCurve' => ['baseValue' => 30, 'extraValue' => 20, 'accelerationA' => 30, 'accelerationB' => 30], + 'parameterCurves' => [ + 'totalHp' => ['baseValue' => 120, 'extraGrowth' => 500, 'flatIncrement' => 40], + 'totalMp' => ['baseValue' => 12, 'extraGrowth' => 100, 'flatIncrement' => 10], + 'attack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], + 'defence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], + 'magicAttack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], + 'magicDefence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], + 'speed' => ['baseValue' => 10, 'extraGrowth' => 20, 'flatIncrement' => 1], + 'grace' => ['baseValue' => 10, 'extraGrowth' => 15, 'flatIncrement' => 1], + 'evasion' => ['baseValue' => 5, 'extraGrowth' => 10, 'flatIncrement' => 1], + ], + ], + subList: new RecordSubList( + key: 'skillsToLearn', + prefix: 'learn', + singular: 'skill to learn', + fields: [ + new RecordField('level', 'Level', InputControlType::INTEGER), + RecordField::reference('skill', 'Skill', 'skills'), + new RecordField('note', 'Note', removeWhenEmpty: true), + ], + blank: ['level' => 2, 'skill' => ''], + ), + ); + } + + /** + * Enemies — one record per file under `assets/Data/Enemies`, the form the + * engine's `EnemyRecord` reads. Each file returns + * `['class' => Enemy::class, 'data' => [...]]`; `enemies.php` is the + * barrel that loads them. + * + * Action patterns name skills in the project's skill catalogue, so a + * pattern is edited by picking the skill rather than rebuilding it. * * @return RecordSchema */ @@ -764,32 +1437,81 @@ private static function enemies(): RecordSchema return new RecordSchema( key: 'enemies', entryNoun: 'enemy', - storage: RecordStorage::LIST_FILE, - relativePath: 'assets/Data/enemies.php', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . EnemyCatalog::DIRECTORY, fields: [ - new RecordField('name', 'Name'), + new RecordField('name', 'Name', uniqueAcrossRecords: true), new RecordField('level', 'Level', InputControlType::INTEGER), RecordField::reference('imagePath', 'Sprite', 'enemy_sprites'), - new RecordField('stats.totalHp', 'HP', InputControlType::INTEGER), - new RecordField('stats.totalMp', 'MP', InputControlType::INTEGER), + new RecordField('stats.maxHp', 'Max HP', InputControlType::INTEGER), + new RecordField('stats.maxMp', 'Max MP', InputControlType::INTEGER), new RecordField('stats.attack', 'Attack', InputControlType::INTEGER), new RecordField('stats.defence', 'Defence', InputControlType::INTEGER), new RecordField('stats.magicAttack', 'Magic Attack', InputControlType::INTEGER), new RecordField('stats.magicDefence', 'Magic Defence', InputControlType::INTEGER), + new RecordField('stats.speed', 'Speed', InputControlType::INTEGER), new RecordField('stats.grace', 'Grace', InputControlType::INTEGER), new RecordField('stats.evasion', 'Evasion', InputControlType::INTEGER), new RecordField('rewards.experience', 'Reward EXP', InputControlType::INTEGER), + // Mastery points (SP) every travelling member earns with the EXP; an enemy without them gives none. + new RecordField('rewards.skillPoints', 'Reward SP', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('rewards.gold', 'Reward Gold', InputControlType::INTEGER), - new RecordField('elementAffinities', 'Element Affinities', codec: RecordFieldCodec::AFFINITIES), - new RecordField('actionPatterns', 'Action Patterns', isReadOnly: true), + new RecordField('elementAffinities', 'Element Affinities', removeWhenEmpty: true, codec: RecordFieldCodec::AFFINITIES), + new RecordField('stateResistances', 'State Resistances', removeWhenEmpty: true, codec: RecordFieldCodec::KEY_VALUES), + RecordField::reference('knowledgeSubjectId', 'Knowledge Subject', 'knowledge_subjects', allowsNone: true), + // Where it lives, so a graphical battle stands it on its arena's land or water, or in the air over either. + new RecordField('habitat', 'Habitat', options: array_column(EnemyHabitat::cases(), 'value'), removeWhenEmpty: true, + displayDefault: EnemyHabitat::LAND->value), + self::counterAttackField(), + // What the enemy attacks with when none of its patterns + // applies: a battle-usable basic skill, or the Engine's own + // attack, the same choice an actor makes. + new RecordField('attackSkill', 'Attack Skill', reference: 'attack_skills', removeWhenEmpty: true, + allowsNone: true, displayDefault: ActorAuthoring::BUILT_IN_ATTACK), ], labelKey: 'name', identityKey: 'name', - recordFilter: static fn(mixed $entry): bool => $entry instanceof Enemy, - // An Enemy loads its sprite in its constructor, so a blank needs a - // real file to point at; a project with no enemy sprites cannot - // author an enemy yet, and creation refuses rather than crashing. - makeBlank: static function (string $name, string $projectRoot): ?object { + subList: new RecordSubList( + key: 'actionPatterns', + prefix: 'pattern', + singular: 'action pattern', + fields: [ + RecordField::reference('skill', 'Skill', 'skills'), + new RecordField('rating', 'Rating', InputControlType::INTEGER), + new RecordField( + 'condition.type', + 'Condition', + options: array_map(static fn(ActionConditionType $type): string => $type->value, ActionConditionType::cases()), + removeWhenEmpty: true, + displayDefault: ActionConditionType::ALWAYS->value, + ), + new RecordField('condition.range', 'Range (min, max)', removeWhenEmpty: true, codec: RecordFieldCodec::POINT), + new RecordField('condition.a', 'Condition A', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('condition.b', 'Condition B', InputControlType::INTEGER, removeWhenEmpty: true), + ], + blank: ['skill' => '', 'rating' => 5], + ), + subLists: [ + // What the enemy may drop when defeated, each with its chance + // (0 to 1). The Engine resolves the item by its definition id. + new RecordSubList( + key: 'rewards.items', + prefix: 'drop', + singular: 'drop', + fields: [ + RecordField::reference('item', 'Item', 'inventory'), + new RecordField('rate', 'Drop Rate', InputControlType::FLOAT), + ], + blank: ['item' => '', 'rate' => 0.1], + heading: 'Drops', + removeWhenEmpty: true, + ), + ], + // A new enemy needs a real sprite to load, so it starts on the + // project's first one; a project with no enemy sprites cannot + // author an enemy yet, and creation refuses, saying so, rather + // than writing a record the engine would reject. + makeBlank: static function (string $name, string $projectRoot): array { $spriteDirectory = rtrim($projectRoot, DIRECTORY_SEPARATOR) . '/assets/Graphics/Enemies'; $sprites = is_dir($spriteDirectory) ? array_values(array_filter( @@ -801,20 +1523,143 @@ private static function enemies(): RecordSchema : []; if ($sprites === []) { - return null; + throw new RecordRefusal('A new enemy starts on the project\'s first enemy sprite. Add one under assets/Graphics/Enemies first.'); } - return new Enemy( - $name, - 1, - new Stats(currentHp: 10, attack: 5, defence: 5, speed: 5), + return [ + 'name' => $name, + 'level' => 1, // graphics() appends the extension itself, so the stem is // what an imagePath stores. - pathinfo($sprites[0], PATHINFO_FILENAME), - new BattleRewards(1, 1, []), - [], - ); + 'imagePath' => pathinfo($sprites[0], PATHINFO_FILENAME), + 'stats' => [ + 'maxHp' => 10, 'maxMp' => 10, 'attack' => 5, 'defence' => 5, 'magicAttack' => 5, + 'magicDefence' => 5, 'speed' => 5, 'grace' => 1, 'evasion' => 0, + ], + 'rewards' => ['experience' => 1, 'gold' => 1], + ]; }, + recordClass: Enemy::class, + ); + } + + /** + * Skills: one record per file under `assets/Data/Skills`, in the form + * the Engine's SkillRecord reads, numbered in the order menus list them. + * A skill's kind (an attack, an ability or a spell) is a value of the + * record; a spell also states its effect type. Effects are a list, each + * a `type` with that type's own values. `skills.php` is the barrel that + * returns them. + * + * @return RecordSchema + */ + private static function skills(): RecordSchema + { + $fields = [ + new RecordField('kind', 'Kind', options: array_keys(SkillRecord::KINDS)), + new RecordField('name', 'Name', uniqueAcrossRecords: true), + new RecordField('description', 'Description'), + new RecordField('icon', 'Icon'), + new RecordField('cost', 'Cost', InputControlType::INTEGER), + new RecordField('cooldown', 'Cooldown', InputControlType::INTEGER), + new RecordField('occasion', 'Occasion', options: array_map(static fn(Occasion $occasion): string => $occasion->value, Occasion::cases())), + new RecordField('scope.side', 'Scope Side', options: array_map(static fn(ItemScopeSide $side): string => $side->value, ItemScopeSide::cases())), + new RecordField('scope.number', 'Scope Number', options: array_map(static fn(ItemScopeNumber $number): string => $number->value, ItemScopeNumber::cases())), + new RecordField('scope.status', 'Scope Status', options: array_map(static fn(ItemScopeStatus $status): string => $status->value, ItemScopeStatus::cases())), + new RecordField('scope.targetCount', 'Target Count', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: 'Auto'), + new RecordField('invocation.message', 'Battle Message', displayDefault: self::SKILL_MESSAGE_DEFAULT), + new RecordField('invocation.speed', 'Invoke Speed', InputControlType::INTEGER), + new RecordField('invocation.accuracy', 'Accuracy', InputControlType::INTEGER), + new RecordField('invocation.repeat', 'Repeat', InputControlType::INTEGER), + new RecordField('invocation.apGain', 'AP Gain', InputControlType::INTEGER), + new RecordField('invocation.hitScope', 'Hit Roll', reference: 'resolution_scopes', removeWhenEmpty: true, allowsNone: true, displayDefault: SkillResolutionScope::PER_HIT->value), + new RecordField('invocation.criticalScope', 'Critical Roll', reference: 'resolution_scopes', removeWhenEmpty: true, allowsNone: true, displayDefault: SkillResolutionScope::PER_HIT->value), + new RecordField( + 'animationId', + 'Animation', + InputControlType::INTEGER, + reference: 'animation_ids', + removeWhenEmpty: true, + allowsNone: true, + displayDefault: '(Legacy fallback)', + ), + // How large the bound animation's target effect plays: one timeline serves every grade of a spell. + self::animationScaleField(), + // The values still awaiting the author (cost, occasion, scope, effects, effects.0.formula, effects.0.variance), left out of the record + // until assigned: the Engine keeps such a record as a draft nobody can learn or cast, and never guesses them. + new RecordField(SkillDraft::KEY, 'Awaiting Author', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true, displayDefault: '(none)'), + ]; + // Only a spell has an effect type; left out, the Engine infers it + // from the spell's effects. + $effectType = new RecordField('effectType', 'Effect Type', reference: 'magic_effect_types', removeWhenEmpty: true, allowsNone: true, displayDefault: '(from its effects)'); + $formula = static fn(bool $resolves): array => [ + new RecordField('formula', 'Formula'), + new RecordField('element', 'Element', reference: 'elements', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + new RecordField('variance', 'Variance', InputControlType::FLOAT), + new RecordField('isCriticalHit', 'Can Critical', InputControlType::BOOLEAN, removeWhenEmpty: true), + ...($resolves ? [new RecordField('resolutionKind', 'Resolves As', reference: 'resolution_kinds', removeWhenEmpty: true, allowsNone: true, displayDefault: '(by effect)')] : []), + ]; + $variants = []; + + foreach (array_keys(SkillRecord::FORMULA_EFFECTS) as $type) { + $variants[$type] = $formula($type === 'hp_damage'); + } + + $variants['add_state'] = [ + RecordField::reference('stateId', 'State', 'states'), + new RecordField('chancePercent', 'Chance %', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '100'), + ]; + $variants['remove_state'] = [ + new RecordField('stateIds', 'States', reference: 'states', codec: RecordFieldCodec::CSV_LIST), + // Rolled for each listed state the target has (a 75% treatment cures three times in four). + new RecordField('chancePercent', 'Chance %', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '100'), + ]; + // What a reveal uncovers about each target for the rest of the battle, and the chance it succeeds. + $variants['reveal'] = [ + new RecordField('facts', 'Reveals', reference: 'reveal_facts', codec: RecordFieldCodec::CSV_LIST), + new RecordField('chancePercent', 'Chance %', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '100'), + ]; + $variants['modify_stat_stage'] = [ + new RecordField('stat', 'Stat', options: Character::buffableStats()), + new RecordField('delta', 'Stages', InputControlType::INTEGER), + new RecordField('affectsUser', 'On User', InputControlType::BOOLEAN, removeWhenEmpty: true), + ]; + + return new RecordSchema( + key: 'skills', + entryNoun: 'skill', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/Data/' . SkillCatalog::DIRECTORY, + fields: $fields, + labelKey: 'name', + identityKey: 'name', + blank: [ + 'kind' => 'special', + 'name' => 'New Skill', + 'description' => '', + 'icon' => '', + 'cost' => 0, + 'cooldown' => 0, + 'occasion' => Occasion::BATTLE_SCREEN->value, + 'scope' => ['side' => ItemScopeSide::ENEMY->value, 'number' => ItemScopeNumber::ONE->value, 'status' => ItemScopeStatus::ALIVE->value], + 'invocation' => ['message' => self::SKILL_MESSAGE_DEFAULT, 'speed' => 0, 'accuracy' => 100, 'repeat' => 1, 'apGain' => 10], + 'effects' => [], + ], + subList: new RecordSubList( + key: 'effects', + prefix: 'effect', + singular: 'effect', + fields: [ + new RecordField('type', 'Type', options: SkillRecord::getEffectTypes()), + ], + blank: ['type' => 'hp_damage', 'formula' => '$user->stats->attack * 2', 'variance' => 0.2], + variants: $variants, + variantKey: 'type', + ), + // A spell has an effect type; a learned non-magic ability may grant a counter attack instead. + fieldsFor: static fn(array $row): array => strval($row['kind'] ?? '') === 'magic' ? [...$fields, $effectType] : [...$fields, self::counterAttackField()], + recordClass: Skill::class, + numberedFiles: true, ); } @@ -838,6 +1683,9 @@ private static function skits(): RecordSchema new RecordField('id', 'Id'), new RecordField('title', 'Title'), RecordField::reference('where', 'Where', 'maps'), + // Only for a skit whose content needs one setting wherever it plays; otherwise the place's shows. + new RecordField(\Ichiloto\Engine\Field\SkitBackdrop::SKIT_KEY, 'Backdrop', reference: 'png_assets', removeWhenEmpty: true, + allowsNone: true, displayDefault: self::SKIT_BACKDROP_NONE), new RecordField('conditions', 'Conditions', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), new RecordField('speed', 'Speed (chars/sec)', InputControlType::INTEGER, removeWhenEmpty: true), ], @@ -847,7 +1695,7 @@ private static function skits(): RecordSchema 'id' => 'new-skit', 'title' => 'New Skit', 'beats' => [ - ['speaker' => 'Speaker', 'text' => 'Say something.'], + ['actor' => '', 'text' => 'Say something.'], ], ], subList: new RecordSubList( @@ -855,10 +1703,13 @@ private static function skits(): RecordSchema prefix: 'beat', singular: 'beat', fields: [ - RecordField::reference('speaker', 'Speaker', 'actors'), + RecordField::reference('actor', 'Actor', 'actor_ids', allowsNone: true, noneLabel: '(Non-actor speaker)'), + new RecordField('speaker', 'Non-actor Speaker', removeWhenEmpty: true), + RecordField::emotion(DialogueLineSpeaker::forSkitBeat()), new RecordField('text', 'Text'), ], - blank: ['speaker' => 'Speaker', 'text' => 'Say something.'], + blank: ['actor' => '', 'text' => 'Say something.'], + exclusiveFields: [['actor', 'speaker']], ), ); } @@ -873,7 +1724,7 @@ private static function skits(): RecordSchema * * @return RecordSchema */ - private static function commonEvents(): RecordSchema + private static function commonEvents(bool $graphical = false): RecordSchema { return new RecordSchema( key: 'common_events', @@ -890,18 +1741,83 @@ private static function commonEvents(): RecordSchema ['type' => 'text', 'name' => '', 'text' => 'Something happens.'], ], ], - subList: self::eventCommandList('commands'), + // A reusable script is what cinematic sessions call, so it is edited with their full vocabulary. + subList: CutsceneSchemas::cinematicCommandList('commands', $graphical, ['type' => 'text', 'name' => '', 'text' => 'Something happens.']), listPayloadKey: 'commands', ); } + /** + * Configuration — the settings in the project's `config.php` that are not + * UI vocabulary: saving, accessibility, interface, the field (Always + * Dash's first value), graphics, audio and the inn. One row per setting, typed by what it holds (a switch, a + * number, a choice of the enum it is authored as). A setting the engine + * reads with a default is offered even where the file leaves it out. + * ProjectConfig owns the file and the rules a value must meet, such as + * the field zoom's range; Terms shares the same owner. + * + * @return RecordSchema + */ + private static function configuration(bool $graphical = false): RecordSchema + { + return new RecordSchema( + key: 'configuration', + entryNoun: 'setting', + storage: RecordStorage::CONFIG_SUBTREE, + relativePath: 'config.php', + fields: [ + new RecordField('path', 'Setting', isReadOnly: true), + new RecordField('value', 'Value'), + ], + labelKey: 'path', + identityKey: 'path', + configPath: ['save', 'accessibility', 'ui', 'field', 'graphics', 'audio', 'inn'], + subLists: $graphical ? InnPresentationFields::getBindingLists('value') : [], + subListsFor: static fn(array $row): array => ($row['path'] ?? null) === ProjectConfig::INN_PRESENTATION + && ($list = InnPresentationFields::getBindingList($row['value'] ?? null, 'value')) !== null ? [$list->key] : [], + fieldsFor: static function (array $row) use ($graphical): array { + if (($row['path'] ?? null) === ProjectConfig::INN_PRESENTATION) { + return [new RecordField('path', 'Setting', isReadOnly: true), + ...InnPresentationFields::getFields($row['value'] ?? null, 'value', 'Rest Presentation', $graphical)]; + } + // A setting the engine knows is the type of its default, so an + // authored value of another type is repaired on edit. + $value = array_key_exists('default', $row) ? $row['default'] : ($row['value'] ?? null); + $default = array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null; + + $reference = ProjectConfig::getEngineReference(strval($row['path'] ?? '')); + + return [ + new RecordField('path', 'Setting', isReadOnly: true), + match (true) { + // A setting naming another resource is chosen, never spelled. + $reference !== null => RecordField::reference('value', 'Value', $reference, allowsNone: true, noneLabel: 'None'), + // An enum setting is a choice of its cases by name: some + // enums' values (a colour's terminal code) are not text + // an author can read. + $value instanceof \UnitEnum => new RecordField( + 'value', + 'Value', + options: array_map(static fn(\UnitEnum $case): string => $case->name, $value::cases()), + enumClass: $value::class, + ), + is_bool($value) => RecordField::boolean('value', 'Value', removeWhenEmpty: false, displayDefault: $default), + is_int($value) => new RecordField('value', 'Value', InputControlType::INTEGER, displayDefault: $default), + is_float($value) => new RecordField('value', 'Value', InputControlType::FLOAT, displayDefault: $default), + default => new RecordField('value', 'Value', displayDefault: $default), + }, + ]; + }, + ); + } + /** * UI vocabulary — the `vocab` and `messages` trees of the project's * `config.php`, flattened to one editable row per term. * - * Editable only when the whole config file round-trips (it holds enum - * cases, which export fine; a `new Something()` in there would make the - * category read-only, and say so). + * ProjectConfig shares these records with Configuration's settings and + * patches literal leaves only. Unrelated comments and expressions stay + * untouched; opaque term values are individually read-only. * * @return RecordSchema */ @@ -919,17 +1835,21 @@ private static function terms(): RecordSchema labelKey: 'path', identityKey: 'path', configPath: ['vocab', 'messages'], + // A message the Engine says reads as its default until the project rewords it. + fieldsFor: static fn(array $row): array => [ + new RecordField('path', 'Term', isReadOnly: true), + new RecordField('value', 'Value', displayDefault: array_key_exists('default', $row) ? ProjectRecord::stringify($row['default']) : null), + ], ); } /** - * Element/equipment type tables — `assets/Data/Types`. - * - * These are PHP enum *declarations*, not data: the engine reads its own - * `Entities\Enumerations` enums and never loads this directory. The - * editor lists the files so they are discoverable, and does not evaluate - * them (requiring a class declaration into the editor's process would - * risk a redeclaration fatal). + * Types: the project's elements, the `elements` list of + * `assets/Data/system.php` that the Engine's element registry reads (its + * own defaults when the list is empty). Weapon, armor and equipment + * types are the Engine's own enums, not project data, and nothing reads + * `assets/Data/Types`. The category shares `system.php` with System, + * each saving only what it changed. * * @return RecordSchema */ @@ -938,53 +1858,417 @@ private static function types(): RecordSchema return new RecordSchema( key: 'types', entryNoun: 'type table', - storage: RecordStorage::FILE_LISTING, - relativePath: 'assets/Data/Types', - fields: [ - new RecordField('file', 'File', isReadOnly: true), - new RecordField('kind', 'Kind', isReadOnly: true), - new RecordField('lines', 'Lines', InputControlType::INTEGER, isReadOnly: true), - ], - labelKey: 'file', + storage: RecordStorage::LIST_FILE, + relativePath: 'assets/Data/system.php', + fields: [], + labelKey: 'title', identityKey: null, - isAlwaysReadOnly: true, - readOnlyNote: 'element and equipment types are PHP enum declarations, not data the engine loads — edit them in your IDE', + projection: new WholeFileProjection(['elements']), + subLists: [ + // An element is one name, as the file authors it. A list the + // game would refuse (a name twice, an empty one) is reported + // by validation; an empty list means the Engine's defaults. + new RecordSubList( + key: 'elements', + prefix: 'element', + singular: 'element', + fields: [new RecordField('name', 'Element')], + blank: ['name' => 'New Element'], + heading: 'Elements', + scalarKey: 'name', + removeWhenEmpty: true, + ), + ], + labelFor: static fn(array $payload): string => 'Elements', ); } /** - * Tilesets — no engine system exists yet. - * - * Ichiloto maps store their glyphs directly in the map files and the - * canvas paints them; there is no tileset/terrain table to edit. The - * category stays visible and says so rather than pretending. + * Tilesets: one file per tileset under `assets/Data/Tilesets`, in the + * form the Engine's Tileset reads. A map names its tileset by the file's + * name, so the file keeps its name when the tileset is renamed. A + * tileset names its RPG Maker sheets, the tiles drawn above characters + * or as tables, the shadow its raised tiles cast, the tile that marks + * missing art, the pieces maps are built from, keyed by piece id: + * a stamped piece's glyph rows with its tile rows on each tile layer, or + * a connected piece's glyph and tile for each shape of a line, and the + * terrain kinds world maps are painted in ({@see terrainKindList()}) and + * the tile layers whose tiles stack ({@see tilesetStackList()}). The + * graphical editor also edits what tiles show at ground level, which + * validation alone reads, and the thickets characters walk through hidden + * in the graphical field ({@see tilesetThicketList()}). * * @return RecordSchema */ - private static function tilesets(): RecordSchema + private static function tilesets(bool $graphical = false): RecordSchema { + $layer = new RecordField('layer', 'Tile Layer'); + $tilesFor = static fn(?string $valueField, RecordField $value, array $blank): RecordSubList => new RecordSubList( + key: 'tiles', + prefix: 'tiles', + singular: 'tile layer', + fields: [$layer, $value], + blank: $blank, + keyField: 'layer', + valueField: $valueField, + ); + return new RecordSchema( key: 'tilesets', entryNoun: 'tileset', - storage: RecordStorage::FILE_LISTING, - relativePath: 'assets/Data/Tilesets', + storage: RecordStorage::DIRECTORY, + relativePath: 'assets/' . Tileset::DIRECTORY, fields: [ - new RecordField('file', 'File', isReadOnly: true), - new RecordField('kind', 'Kind', isReadOnly: true), + new RecordField('name', 'Name'), + ...array_map( + static fn(TilesetSheet $sheet): RecordField => new RecordField( + 'sheets.' . $sheet->value, + 'Sheet ' . $sheet->value, + reference: 'png_assets', + removeWhenEmpty: true, + allowsNone: true, + displayDefault: '(none)', + ), + TilesetSheet::cases(), + ), + new RecordField('missingArt', 'Missing Art Tile', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(none)'), + new RecordField('above', 'Above Characters', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), + new RecordField('tables', 'Tables', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), + // A shadow is all three or none; validation names what is missing. + new RecordField('shadows.casters', 'Shadow Casters', codec: RecordFieldCodec::CSV_TOKENS, removeWhenEmpty: true), + new RecordField('shadows.width', 'Shadow Width', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('shadows.opacity', 'Shadow Opacity', InputControlType::FLOAT, removeWhenEmpty: true), + // What tiles show at ground level, for validation alone: graphical authoring, so the GUI editor's. + ...($graphical ? array_map(static fn(TileAppearance $class): RecordField => new RecordField( + TilesetAppearance::DATA_KEY . '.' . $class->value, + 'Appearance: ' . ucfirst($class->value), + codec: RecordFieldCodec::CSV_TOKENS, + removeWhenEmpty: true, + ), TileAppearance::cases()) : []), ], - labelKey: 'file', + labelKey: 'name', identityKey: null, - isAlwaysReadOnly: true, - readOnlyNote: 'the engine has no tileset system yet — map tiles are authored directly on the canvas', + blank: ['name' => 'New Tileset', 'sheets' => []], + subList: new RecordSubList( + key: 'pieces', + prefix: 'piece', + singular: 'piece', + fields: [ + new RecordField('id', 'Id'), + new RecordField('name', 'Name'), + new RecordField('layer', 'Glyph Layer'), + ...($graphical ? [new RecordField('occupancy', 'Physical Footprint', codec: RecordFieldCodec::PHYSICAL_FOOTPRINT, + removeWhenEmpty: true)] : []), + new RecordField( + 'connects', + 'Connects', + reference: 'piece_connections', + removeWhenEmpty: true, + allowsNone: true, + displayDefault: '(stamped whole)', + ), + ], + blank: ['id' => 'new-piece', 'name' => 'New piece', 'layer' => 'fixtures', 'glyphs' => ['#']], + variants: [ + '' => [ + new RecordField('glyphs', 'Glyph Rows', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES), + new RecordField('effect', 'Effect', reference: 'effects', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + // Where its art paints doors, which each map's entrances must meet: graphical authoring, so the GUI editor's. + ...($graphical ? [new RecordField('doorway', 'Painted Doors', codec: RecordFieldCodec::PIECE_DOORWAY, + removeWhenEmpty: true, displayDefault: '(none)')] : []), + ], + TilesetPiece::LINES => [ + new RecordField('glyphs.horizontal', 'Across Glyph'), + new RecordField('glyphs.vertical', 'Down Glyph'), + new RecordField('glyphs.corner', 'Corner Glyph'), + ], + ], + variantKey: 'connects', + nestedLists: [ + '' => $tilesFor('rows', new RecordField('rows', 'Tile Rows', InputControlType::MULTILINE, codec: RecordFieldCodec::LINES), ['layer' => 'tiles', 'rows' => ['0']]), + TilesetPiece::LINES => $tilesFor('tile', new RecordField('tile', 'Tile', codec: RecordFieldCodec::SHAPE_TILES), ['layer' => 'tiles', 'tile' => '0']), + ], + keyField: 'id', + ), + subLists: [self::terrainKindList($graphical), self::tilesetStackList(), self::placeIconList(), + ...($graphical ? [self::tilesetThicketList()] : [])], + ); + } + + /** + * A tileset's place icons, the Engine's Tileset::$placeIcons, keyed by + * place type: the piece a map drawn with the tileset shows over an + * entrance into a place of that type, chosen among its pieces stamped + * whole, so the type a map's Place Type row names decides the icon every + * entrance into it should show ({@see \Ichiloto\Editor\Validation\PlaceIconCheck}). + */ + private static function placeIconList(): RecordSubList + { + return new RecordSubList( + key: Tileset::PLACE_ICONS, + prefix: 'placeIcon', + singular: 'place icon', + fields: [ + new RecordField('type', 'Place Type', reference: \Ichiloto\Editor\Field\PlaceTypeFields::CATEGORY), + RecordField::entryChoice('piece', 'Icon Piece', 'tileset_whole_pieces', '(a piece)'), + ], + blank: ['type' => 'new-type', 'piece' => ''], + heading: 'Place Icons', + removeWhenEmpty: true, + keyField: 'type', + valueField: 'piece', ); } + /** + * A tileset's thickets, the Engine's TilesetThicket, keyed by thicket id: + * foliage a character walks through hidden in the graphical field, like + * RPG Maker's bush flag per foliage type. Its depth is the field pixels + * hidden up from a character's feet, its opacity how the hidden part is + * drawn (0 masks it), whether it rustles around a walker, and the tiles, + * other than terrain kinds' own autotiles, that are it; a kind names its + * own ({@see terrainKindList()}). Graphical authoring, so the GUI + * editor's; the terminal never draws it. + */ + private static function tilesetThicketList(): RecordSubList + { + return new RecordSubList( + key: TilesetThicket::DATA_KEY, + prefix: 'thicket', + singular: 'thicket', + fields: [ + new RecordField('id', 'Id'), + new RecordField('name', 'Name'), + new RecordField('depth', 'Depth (field pixels up from the feet)', InputControlType::INTEGER), + new RecordField('opacity', 'Hidden Part Opacity (0 masks it)', InputControlType::FLOAT), + new RecordField('rustle', 'Rustles Around a Walker', InputControlType::BOOLEAN, displayDefault: 'true'), + new RecordField('tiles', 'Tiles', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true, + displayDefault: '(only the kinds naming it)'), + ], + blank: ['id' => 'new-thicket', 'name' => 'New thicket', 'depth' => 20, 'opacity' => 0.0, 'rustle' => true], + heading: 'Thickets', + removeWhenEmpty: true, + keyField: 'id', + ); + } + + /** + * A tileset's stacks, the Engine's TilesetStack, keyed by stack id: the + * tile layers, bottom to top, whose tiles a stacked piece is laid across + * in depth order, so overlapping decorations such as mountain peaks draw + * as the field orders them. A piece stacks by putting its tiles on the + * stack's first layer. + */ + private static function tilesetStackList(): RecordSubList + { + return new RecordSubList( + key: 'stacks', + prefix: 'stack', + singular: 'stack', + fields: [ + new RecordField('id', 'Id'), + new RecordField('layers', 'Tile Layers', codec: RecordFieldCodec::CSV_TOKENS), + ], + blank: ['id' => 'new-stack', 'layers' => ['stack-1', 'stack-2']], + heading: 'Stacks', + removeWhenEmpty: true, + keyField: 'id', + ); + } + + /** + * A tileset's terrain kinds, the Engine's TerrainKind, keyed by kind id: + * the glyph and colour it paints, its passage on foot, its encounter + * terrain, its atlas role, the ground kind a feature stands on (chosen + * among the tileset's own ground kinds), and its autotile, or that its + * art is still to be drawn. A corner sheet, chosen among the project's + * PNG assets, draws the kind's edges on the corners between cells, and + * corner-edged kinds naming one join group share one edge where they meet. + */ + private static function terrainKindList(bool $graphical = false): RecordSubList + { + return new RecordSubList( + key: 'kinds', + prefix: 'kind', + singular: 'terrain kind', + fields: [ + new RecordField('id', 'Id'), + new RecordField('name', 'Name'), + new RecordField('layer', 'Glyph Layer'), + new RecordField('glyph', 'Glyph'), + new RecordField('passage', 'Passage', options: array_column(TerrainPassage::cases(), 'value')), + new RecordField('encounters', 'Encounter Terrain', removeWhenEmpty: true, displayDefault: '(no encounters)'), + new RecordField('atlas', 'Atlas Role', options: MapAtlasTerrain::ROLES), + RecordField::entryChoice('ground', 'Stands On', 'terrain_grounds', '(a ground kind)'), + new RecordField('art.' . TerrainKind::AWAITING_ART, 'Awaiting Art', InputControlType::BOOLEAN, removeWhenEmpty: true, + displayDefault: 'false'), + new RecordField('art.layer', 'Art Tile Layer', removeWhenEmpty: true, displayDefault: '(awaiting art)'), + new RecordField('art.tile', 'Art Autotile', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '(awaiting art)'), + // Choosing a corner sheet draws the kind's edges on the corners between cells; none keeps them in each cell. + new RecordField('art.' . TerrainKind::CORNERS, 'Corner Sheet', reference: 'png_assets', removeWhenEmpty: true, allowsNone: true, + displayDefault: '(edges in each cell)'), + // Corner-edged kinds naming one group, such as a river and a lake, meet with no bank between them. + new RecordField('art.' . TerrainKind::JOINS, 'Joins', removeWhenEmpty: true, displayDefault: '(edges against every kind)'), + new RecordField('keeps', 'Keeps Tile Layers', codec: RecordFieldCodec::CSV_TOKENS, removeWhenEmpty: true), + // The thicket its autotile is, chosen among the tileset's thickets: graphical authoring, so the GUI editor's. + ...($graphical ? [RecordField::entryChoice('thicket', 'Thicket', 'tileset_thickets', '(hides nobody)')] : []), + ], + blank: ['id' => 'new-kind', 'name' => 'New kind', 'layer' => 'terrain', 'glyph' => '.', + 'art' => [TerrainKind::AWAITING_ART => true], 'passage' => TerrainPassage::WALK->value, 'atlas' => 'ground'], + heading: 'Terrain Kinds', + removeWhenEmpty: true, + keyField: 'id', + ); + } + + /** + * A counter attack: the skill a battler responds with when a physical + * hit lands on it, after the attacker returns, never chaining. It is off + * unless chosen, and choosing none removes it, as the Engine reads an + * omitted counterAttack. The picker offers only skills the Engine's + * CounterAttackRule accepts. + */ + private static function counterAttackField(): RecordField + { + return new RecordField('counterAttack.skill', 'Counter Attack', reference: 'counter_skills', removeWhenEmpty: true, + allowsNone: true, displayDefault: '(no counter)'); + } + + /** Where battle art is bound to battlers as data, the Engine's {@see BattlerBindings::FILE}. */ + public const string BATTLERS_PATH = 'assets/' . BattlerBindings::FILE; + + /** + * One side of the battler bindings: which art an actor or enemy fights + * with in a graphical battle, edited from that actor's or enemy's own + * page. Each record is one identity (an actor's definition id, an + * enemy's name, as the battle catalog keys them) with its base artwork, + * its pose roles and its body profile against the scale reference. Image + * sizes are the files', never stored. The terminal battle never reads it. + * + * @param 'actors'|'enemies' $side + */ + private static function battlerArt(string $side): RecordSchema + { + $actors = $side === 'actors'; + + return new RecordSchema( + key: $actors ? 'battler_actors' : 'battler_enemies', + entryNoun: $actors ? 'actor battle art' : 'enemy battle art', + storage: RecordStorage::LIST_FILE, + relativePath: self::BATTLERS_PATH, + fields: [ + // Set when the art is made for its battler, and never moved to another. + new RecordField(BattlerBindingProjection::IDENTITY, $actors ? 'Actor' : 'Enemy', isReadOnly: true), + RecordField::reference('artwork.image', 'Image', 'png_assets', allowsNone: true), + new RecordField('artwork.pivot', 'Ground Point', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 1 (bottom centre)'), + new RecordField('scale.relativeSize', 'Size', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('scale.sourceSpan', 'Body Span', InputControlType::FLOAT, removeWhenEmpty: true), + new RecordField('scale.horizontal', 'Measured Across', InputControlType::BOOLEAN, removeWhenEmpty: true, displayDefault: 'false'), + // How it meets the surface: its elevation in reference heights (positive rises, negative sinks below the + // surface line), what is drawn where it meets it, and whether its art already paints its own shadow. + new RecordField('grounding.elevation', 'Elevation', InputControlType::FLOAT, removeWhenEmpty: true, + displayDefault: $actors ? '0 (standing)' : '(the theme\'s for air enemies, else 0)'), + new RecordField('grounding.contact', 'Surface Contact', options: array_column(BattlerContact::cases(), 'value'), + removeWhenEmpty: true, displayDefault: '(from habitat and elevation)'), + new RecordField('grounding.bakedShadow', 'Art Paints Its Shadow', InputControlType::BOOLEAN, removeWhenEmpty: true, + displayDefault: 'false'), + new RecordField('grounding.contactWidth', 'Contact Width', InputControlType::FLOAT, removeWhenEmpty: true, + displayDefault: '1 (the theme\'s)'), + new RecordField('grounding.contactOpacity', 'Contact Strength', InputControlType::FLOAT, removeWhenEmpty: true, + displayDefault: '1 (the theme\'s)'), + ], + labelKey: BattlerBindingProjection::IDENTITY, + identityKey: BattlerBindingProjection::IDENTITY, + blank: [BattlerBindingProjection::IDENTITY => ''], + projection: new BattlerBindingProjection($side), + subLists: [ + // Each role the battle shows the battler in, a still or a + // sheet of frames, keyed by role as the file keys it. + new RecordSubList( + key: 'poses', + prefix: 'pose', + singular: 'pose', + fields: [ + new RecordField('role', 'Role', options: array_map(static fn(BattlePoseRole $role): string => $role->value, BattlePoseRole::cases())), + RecordField::reference('image', 'Image', 'png_assets'), + new RecordField('pivot', 'Ground Point', codec: RecordFieldCodec::NORMALIZED_POINT, removeWhenEmpty: true, displayDefault: '0.5, 1 (bottom centre)'), + new RecordField('columns', 'Columns', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('rows', 'Rows', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '1'), + new RecordField('frames', 'Frames', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('fps', 'Frames per Second', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '8'), + new RecordField('loop', 'Loops', InputControlType::BOOLEAN, removeWhenEmpty: true, displayDefault: 'true'), + new RecordField('restFrame', 'Rest Frame', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField('scaleSpan', 'Body Span', InputControlType::FLOAT, removeWhenEmpty: true), + // Whether this pose's art paints its own shadow, where the battler's other art does not. + new RecordField('bakedShadow', 'Art Paints Its Shadow', InputControlType::BOOLEAN, removeWhenEmpty: true, + displayDefault: '(the battler\'s)'), + ], + blank: ['role' => BattlePoseRole::IDLE->value, 'image' => ''], + heading: 'Poses', + removeWhenEmpty: true, + keyField: 'role', + ), + ], + saveCheck: self::checkBattlerBindings(...), + identityGiven: true, + ); + } + + /** + * The battle scale's reference: the actor every battler's size is + * measured against, and how tall that actor stands in arena units. + */ + private static function battleScaleReference(): RecordSchema + { + return new RecordSchema( + key: 'battle_scale', + entryNoun: 'battle scale', + storage: RecordStorage::LIST_FILE, + relativePath: self::BATTLERS_PATH, + fields: [ + RecordField::reference('reference.actor', 'Reference Actor', 'actor_ids', allowsNone: true), + new RecordField('reference.height', 'Reference Height', InputControlType::FLOAT, removeWhenEmpty: true), + ], + labelKey: 'reference.actor', + identityKey: null, + projection: new WholeFileProjection(['reference']), + saveCheck: self::checkBattlerBindings(...), + ); + } + + /** + * Why the battle could not read battler bindings as they would be + * saved, or null when it can: the Engine reads them, and binds them + * beside the battlers the project's battle presentation code registers, + * refusing an identity both own. + * + * @param array $whole The bindings file as it would be written. + */ + public static function checkBattlerBindings(array $whole, string $projectRoot): ?string + { + $assets = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'assets'; + try { + $bindings = BattlerBindings::getFromArray($whole, $assets); + BattlePresentationCatalog::loadCode($assets)?->bindBattlers($bindings); + } catch (\InvalidArgumentException|\RuntimeException $error) { + return sprintf('The battle could not read %s as it would be saved: %s', BattlerBindings::FILE, $error->getMessage()); + } + + return null; + } + /** * The shared read-only fields every inventory entry displays. * + * Equipment is shown everywhere by the one icon of its type (one for all + * swords, one for all daggers), from the theme; an equipment entry's own + * `icon` is legacy compatibility data, shown and kept exactly as written + * but no longer a presentation choice to edit. A consumable keeps its own. + * + * @param bool $isEquipment Whether the entry is equipment. * @return RecordField[] */ - private static function inventoryFields(): array + private static function inventoryFields(bool $isEquipment = false): array { return [ // Identity. The id is what a save, an alias and every reference @@ -993,7 +2277,9 @@ private static function inventoryFields(): array new RecordField('id', 'Id', isReadOnly: true), new RecordField('name', 'Name'), new RecordField('description', 'Description'), - new RecordField('icon', 'Icon'), + $isEquipment + ? new RecordField('icon', 'Legacy Icon (type icon shown)', isReadOnly: true) + : new RecordField('icon', 'Icon'), // Compatibility references an older save may still name. new RecordField('aliases', 'Aliases', codec: RecordFieldCodec::CSV_LIST, removeWhenEmpty: true), // Policy @@ -1001,16 +2287,18 @@ private static function inventoryFields(): array 'userType', 'Who May Use It', options: array_map(static fn(ItemUserType $type): string => $type->value, ItemUserType::cases()), - enumClass: ItemUserType::class, + removeWhenEmpty: true, + displayDefault: ItemUserType::ALL->value, ), - RecordField::boolean('isKeyItem', 'Key Item'), - RecordField::boolean('consumable', 'Consumable'), + // What a record leaves out reads as the Engine's default for its kind. + RecordField::boolean('isKeyItem', 'Key Item', displayDefault: 'false'), + RecordField::boolean('consumable', 'Consumable', displayDefault: $isEquipment ? 'false' : 'true'), // Trade new RecordField('price', 'Price', InputControlType::INTEGER), - RecordField::boolean('sellable', 'Sellable', removeWhenEmpty: false), - new RecordField('sellRateBasisPoints', 'Sell Rate (basis points)', InputControlType::INTEGER, step: 500), + RecordField::boolean('sellable', 'Sellable', removeWhenEmpty: false, displayDefault: 'true'), + new RecordField('sellRateBasisPoints', 'Sell Rate (basis points)', InputControlType::INTEGER, step: 500, displayDefault: '5000'), // Stock - new RecordField('quantity', 'Quantity', InputControlType::INTEGER), + new RecordField('quantity', 'Quantity', InputControlType::INTEGER, displayDefault: '1'), // Project-owned vocabularies: the engine reads these as plain // strings a project gives meaning to, so they are typed rather // than chosen from a list this code would have to invent. @@ -1043,22 +2331,24 @@ private static function inventoryDefinitionId(string $prefix, string $name): str * * The engine's `ParameterChanges` is one shape for weapons and armor * alike, so both author all of it: a shield that grants attack and a - * sword that grants defence are both things a project may want. + * sword that grants defence are both things a project may want. A + * parameter the record leaves out changes nothing, so clearing a row + * removes it rather than writing a zero. * * @return RecordField[] */ private static function parameterChangeFields(): array { return [ - new RecordField('parameterChanges.attack', 'Attack', InputControlType::INTEGER), - new RecordField('parameterChanges.defence', 'Defence', InputControlType::INTEGER), - new RecordField('parameterChanges.magicAttack', 'Magic Attack', InputControlType::INTEGER), - new RecordField('parameterChanges.magicDefence', 'Magic Defence', InputControlType::INTEGER), - new RecordField('parameterChanges.speed', 'Speed', InputControlType::INTEGER), - new RecordField('parameterChanges.grace', 'Grace', InputControlType::INTEGER), - new RecordField('parameterChanges.evasion', 'Evasion', InputControlType::INTEGER), - new RecordField('parameterChanges.totalHp', 'Max HP', InputControlType::INTEGER), - new RecordField('parameterChanges.totalMp', 'Max MP', InputControlType::INTEGER), + new RecordField('parameterChanges.attack', 'Attack', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.defence', 'Defence', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.magicAttack', 'Magic Attack', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.magicDefence', 'Magic Defence', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.speed', 'Speed', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.grace', 'Grace', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.evasion', 'Evasion', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.totalHp', 'Max HP', InputControlType::INTEGER, removeWhenEmpty: true), + new RecordField('parameterChanges.totalMp', 'Max MP', InputControlType::INTEGER, removeWhenEmpty: true), ]; } @@ -1069,19 +2359,21 @@ private static function parameterChangeFields(): array * enough, which is why a shield has to say so. Form, size and material * are project-owned words the engine only stores. * + * @param EquipmentSlotType $defaultSlot The slot this kind of equipment is equipped to unless it says otherwise. * @param RecordField[] $typeAndStats The type row and the stat rows this kind of equipment has. * @return RecordField[] */ - private static function equipmentFields(array $typeAndStats): array + private static function equipmentFields(EquipmentSlotType $defaultSlot, array $typeAndStats): array { return [ - ...self::inventoryFields(), - // Slot and kind + ...self::inventoryFields(isEquipment: true), + // Slot and kind; a record leaves out the slot its kind defaults to. new RecordField( 'semanticSlot', 'Slot', options: array_map(static fn(EquipmentSlotType $slot): string => $slot->value, EquipmentSlotType::cases()), - enumClass: EquipmentSlotType::class, + removeWhenEmpty: true, + displayDefault: $defaultSlot->value, ), ...$typeAndStats, // Shape @@ -1113,16 +2405,17 @@ enumClass: EquipmentSlotType::class, * The schema for a map's NPCs, edited in the map's Inspector. * * Every field the engine's `NpcManager::configure()` reads, in the groups - * an author thinks in. The id is shown but not editable: it is what - * routes and diagnostics name, and nothing that names it would follow a - * rename. Dialogue is edited as conditional variants -- the runtime's + * an author thinks in. The id is what routes and diagnostics name; it is + * edited through {@see \Ichiloto\Editor\Field\NpcAuthoring::setId()}, + * which refuses while anything names it, since nothing that names it + * follows a rename. Dialogue is edited as conditional variants -- the runtime's * own model, of which plain pages are the one-variant case with no * conditions -- so the settings pane and the game agree about what a * given entry means. Scripts are the shared event-command list. * * @return RecordSchema The schema. */ - public static function mapNpcs(): RecordSchema + public static function mapNpcs(bool $graphical = false): RecordSchema { return new RecordSchema( key: 'map_npcs', @@ -1131,7 +2424,7 @@ public static function mapNpcs(): RecordSchema relativePath: '', fields: [ // Identity - new RecordField('id', 'Id', isReadOnly: true), + new RecordField('id', 'Id'), new RecordField('name', 'Name'), // Placement new RecordField('x', 'X', InputControlType::INTEGER), @@ -1144,12 +2437,20 @@ public static function mapNpcs(): RecordSchema new RecordField('sprites.west', 'Facing West', removeWhenEmpty: true), // Movement new RecordField('movement', 'Movement', options: ProjectNpc::MOVEMENTS, displayDefault: 'fixed'), + // RPG Maker's Direction Fix: false (the default) is not written. + RecordField::boolean('directionFix', 'Direction Fix', displayDefault: 'false'), + // The heading it stands with when its map loads; cleared, it is absent and stands facing south. + new RecordField('facing', 'Stands Facing', options: ProjectNpc::DIRECTIONS, removeWhenEmpty: true, displayDefault: 'south'), new RecordField('wanderArea.x', 'Wander X', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('wanderArea.y', 'Wander Y', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('wanderArea.width', 'Wander Width', InputControlType::INTEGER, removeWhenEmpty: true), new RecordField('wanderArea.height', 'Wander Height', InputControlType::INTEGER, removeWhenEmpty: true), // Visibility new RecordField('conditions', 'Visible When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + // Cue of an NPC whose only page is its top-level script: the + // same guidance an event shows, drawn above the NPC. Pages + // otherwise carry their own cue. No symbol, no cue. + ...self::cueFields(), // Completion writes new RecordField('sets', 'After Talking', removeWhenEmpty: true, codec: RecordFieldCodec::WORLD_WRITES), ], @@ -1163,47 +2464,145 @@ public static function mapNpcs(): RecordSchema 'movement' => 'fixed', 'dialogue' => [['text' => 'Hello.']], ], - subList: new RecordSubList( - key: 'dialogue', - prefix: 'variant', - singular: 'dialogue variant', - fields: [ - new RecordField('conditions', 'When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), - new RecordField('sets', 'Then Set', removeWhenEmpty: true, codec: RecordFieldCodec::WORLD_WRITES), - ], - blank: ['lines' => [['text' => 'Something to say.']]], - nestedLists: [ - '*' => new RecordSubList( - key: 'lines', - prefix: 'line', - singular: 'line', - fields: [ - // The runtime titles a page with its `name`, the - // NPC's own when the key is absent, and no title - // at all for ''. Both are choices, by meaning, - // never a name retyped that goes stale on rename. - RecordField::reference( - 'name', - 'Speaker', - 'actors', - allowsNone: true, - noneLabel: "(the NPC's name)", - blankLabel: '(No speaker)', - ), - new RecordField('text', 'Text'), - ], - blank: ['text' => 'Something to say.'], - ), + subList: self::dialogueVariantList( + // The runtime titles a page with its `name`, the NPC's own + // when the key is absent, and no title at all for ''. Both + // are choices, by meaning, never a name retyped that goes + // stale on rename. + RecordField::reference( + 'name', + 'Speaker', + 'actors', + allowsNone: true, + noneLabel: "(the NPC's name)", + blankLabel: '(No speaker)', + ), + DialogueLineSpeaker::forNpcLine(), + ['text' => 'Something to say.'], + // A page's own script, edited as a frame like a branch arm. + ['script' => 'Script'], + // An NPC's dialogue variants are its interaction pages ({@see \Ichiloto\Engine\Field\NpcPages}): + // a page may instead run a shared event script or a Cinematic, + // run once, stage other speakers and show its own cue. + singular: 'page', + pageFields: [ + ...self::cueFields(), + new RecordField('scriptId', 'Event Script', removeWhenEmpty: true, reference: 'common_events', allowsNone: true), + new RecordField('cinematicId', 'Cinematic', removeWhenEmpty: true, reference: 'cinematics', allowsNone: true), + // Absent reads as the Engine's default: the page repeats. + new RecordField('reusable', 'Repeats', InputControlType::BOOLEAN, ['false', 'true'], displayDefault: 'true'), + // Names a one-shot page's saved completion. + new RecordField('id', 'Page Id', removeWhenEmpty: true), + new RecordField('speakers', 'Staged Speakers', removeWhenEmpty: true, codec: RecordFieldCodec::CSV_TOKENS), ], - // A variant's own script, edited as a frame like a branch arm. - commandArms: ['script' => 'Script'], ), // The NPC's inline script: the shared command vocabulary, in a // frame. The runtime runs it INSTEAD of dialogue when non-empty. - commandLists: ['script' => self::eventCommandList('script')], + commandLists: ['script' => self::eventCommandList('script', $graphical)], + ); + } + + /** + * Returns one map event as a record pane ({@see \Ichiloto\Editor\Events\EventRecordPane}). + * + * Every map event shares one envelope ({@see \Ichiloto\Engine\Events\MapEventDefinition}): + * the conditions that make it available, the message shown while they + * do not hold, the writes made when it completes, and the conditions + * that delay its cue. They are offered on every event and written only + * when set. A Dialogue event also says who is talked to and speaks + * conditional pages, the same variants an NPC speaks; a Story Script + * runs an inline script with the shared command vocabulary. The pane + * lifts those two out of `data` so they edit as an NPC's do. + * + * @param string|null $className The event's trigger class. + */ + public static function mapEvent(?string $className, bool $graphical = false): RecordSchema + { + $className = $className === null ? null : ltrim($className, '\\'); + $dialogue = $className === DialogueEventTrigger::class; + + return new RecordSchema( + key: 'map_event', + entryNoun: 'event', + storage: RecordStorage::MAP_OWNED, + relativePath: '', + fields: [ + new RecordField('conditions', 'Available When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + new RecordField('whenBlocked', 'When Blocked', removeWhenEmpty: true), + new RecordField('sets', 'Then Set', removeWhenEmpty: true, codec: RecordFieldCodec::WORLD_WRITES), + new RecordField('cue.conditions', 'Cue When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + // Who a talk-to quest objective counts as talked to; the + // runtime falls back to the first speaker's name. + // Empty, it is not written. + ...($dialogue ? [new RecordField('npcId', 'Talk-To Id', removeWhenEmpty: true)] : []), + ], + labelKey: 'class', + identityKey: null, + // A map Dialogue event's page names its speaker as a text command does. + subList: $dialogue ? self::dialogueVariantList( + new RecordField('name', 'Speaker'), + DialogueLineSpeaker::forText(), + ['name' => '', 'text' => 'Something to say.'], + ) : null, + commandLists: $className === ScriptEventTrigger::class + ? ['script' => self::eventCommandList('script', $graphical)] + : [], + ); + } + + /** + * Returns conditional dialogue as the Engine's ConditionalDialogue reads + * it: variants, the first whose conditions hold spoken, each with the + * writes it makes and its lines. One mechanism for every speaker that + * has them; only how a line names its speaker, and which command lists a + * variant may run, differ. + * + * @param RecordField $speaker The line field naming who speaks. + * @param DialogueLineSpeaker $speakerRule How a line's emotion finds its speaker. + * @param array $blankLine A new line. + * @param array $commandArms The command lists a variant runs, key => label. + */ + private static function dialogueVariantList(RecordField $speaker, DialogueLineSpeaker $speakerRule, array $blankLine, array $commandArms = [], + string $singular = 'dialogue variant', array $pageFields = []): RecordSubList + { + return new RecordSubList( + key: 'dialogue', + prefix: 'variant', + singular: $singular, + fields: [ + new RecordField('conditions', 'When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + new RecordField('sets', 'Then Set', removeWhenEmpty: true, codec: RecordFieldCodec::WORLD_WRITES), + ...$pageFields, + ], + blank: ['lines' => [$blankLine]], + nestedLists: [ + '*' => new RecordSubList( + key: 'lines', + prefix: 'line', + singular: 'line', + fields: [$speaker, RecordField::emotion($speakerRule), new RecordField('text', 'Text')], + blank: $blankLine, + ), + ], + commandArms: $commandArms, ); } + /** + * Returns the rows of a cue, the Engine's EventCue contract: symbol, + * colour, the conditions delaying it and its authored kind. + * + * @return list + */ + private static function cueFields(): array + { + return [ + new RecordField('cue.symbol', 'Cue Symbol', removeWhenEmpty: true), + new RecordField('cue.color', 'Cue Colour', options: FieldCueFields::COLORS, removeWhenEmpty: true, displayDefault: 'bright-yellow'), + new RecordField('cue.conditions', 'Cue When', removeWhenEmpty: true, codec: RecordFieldCodec::CONDITIONS), + ]; + } + /** * Returns the event-command list under a payload key. * @@ -1215,21 +2614,28 @@ public static function mapNpcs(): RecordSchema * @param string $key The payload key holding the list. * @return RecordSubList The command list. */ - public static function eventCommandList(string $key): RecordSubList + public static function eventCommandList(string $key, bool $graphical = false, EventExecutionScope $scope = EventExecutionScope::FIELD): RecordSubList { + $types = self::getEventCommandTypes($scope); + $offered = static fn(array $byType): array => array_intersect_key($byType, array_flip($types)); + return new RecordSubList( key: $key, prefix: 'command', singular: 'command', fields: [ - new RecordField('type', 'Type', options: self::EVENT_COMMAND_TYPES), + new RecordField('type', 'Type', options: $types), ], blank: ['type' => 'text', 'name' => '', 'text' => 'Something happens.'], - variants: self::eventCommandVariants(), + // A scope offers only the commands its scripts may run: a battle page never shows a transfer. + variants: $offered(self::eventCommandVariants($graphical)), variantKey: 'type', - nestedLists: [ - 'move_route' => self::routeStepList(), - ], + nestedLists: $offered([ + 'move_route' => static fn(array $entry): ?RecordSubList => MovementRouteFields::getPointList($entry, graphical: $graphical), + ...self::getRegisteredCommandLists($graphical), + ]), + prepareEdit: static fn(array $entry, string $field): array => MovementRouteFields::prepareEdit( + InnPresentationFields::prepareEdit($entry, $field), $field), ); } @@ -1271,6 +2677,15 @@ public static function summons(): RecordSchema return CutsceneSchemas::summons(); } + /** + * Returns the effect timeline category the Cutscenes workspace edits. + * Not a Database category: it is not listed by all(). + */ + public static function effects(): RecordSchema + { + return CutsceneSchemas::effects(); + } + /** * Returns the per-type field sets for event-script commands. * @@ -1280,11 +2695,12 @@ public static function summons(): RecordSchema * * @return array): RecordField[]> */ - public static function eventCommandVariants(): array + public static function eventCommandVariants(bool $graphical = false): array { return [ 'text' => [ new RecordField('name', 'Speaker', removeWhenEmpty: false), + RecordField::emotion(DialogueLineSpeaker::forText()), new RecordField('text', 'Text'), ], 'choice' => [ @@ -1327,6 +2743,10 @@ public static function eventCommandVariants(): array 'accept_quest' => [ RecordField::reference('id', 'Quest', 'quests'), ], + // Runs another event script, chosen as cinematics choose theirs. + 'common_event' => [ + RecordField::reference('id', 'Event Script', 'common_events'), + ], 'move_player' => [ new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER), @@ -1344,22 +2764,7 @@ public static function eventCommandVariants(): array ), ...KnowledgeCommandShape::fieldsFor(strval($entry['operation'] ?? '')), ], - 'move_route' => [ - new RecordField('subject', 'Subject', options: ['player', 'npc']), - // The stable ids of the map an author is working in; the - // picker reads the live collection, so a just-created NPC - // is offered at once. - new RecordField('npcId', 'NPC Id', reference: 'map_npcs', removeWhenEmpty: true, allowsNone: true), - new RecordField('secondsPerStep', 'Seconds Per Step', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField('speed', 'Steps Per Second', InputControlType::FLOAT, removeWhenEmpty: true), - new RecordField( - 'wait', - 'Wait For Completion', - InputControlType::BOOLEAN, - ['true'], - removeWhenEmpty: false, - ), - ], + 'move_route' => MovementRouteFields::getFields(...), 'transfer' => [ RecordField::reference('map', 'Map', 'maps'), new RecordField('x', 'X', InputControlType::INTEGER), @@ -1370,11 +2775,191 @@ public static function eventCommandVariants(): array new RecordField('resultVariable', 'Result Variable', removeWhenEmpty: true), new RecordField('defeatPolicy', 'Defeat Policy', options: ['game_over', 'continue'], removeWhenEmpty: true), new RecordField('escapePolicy', 'Escape Policy', options: ['allowed', 'forbidden'], removeWhenEmpty: true), + // Reserves replace a wiped-out frontline only where an encounter opts in. + new RecordField('reservePolicy', 'Reserve Policy', options: ['none', 'replace_after_wipeout'], removeWhenEmpty: true, displayDefault: 'none'), ], 'branch' => [ new RecordField('conditions', 'Conditions', codec: RecordFieldCodec::CONDITIONS), // The arms become frames; see describeSubEntryFields. ], + ...self::getRegisteredCommandVariants($graphical), + ]; + } + + /** + * Returns every command type a script may hold: the interpreter's + * built-in vocabulary, then the commands the Engine and the open project + * register. + * + * @return list + */ + public static function getEventCommandTypes(EventExecutionScope $scope = EventExecutionScope::FIELD): array + { + return [ + ...array_values(array_filter(self::EVENT_COMMAND_TYPES, $scope->allowsBuiltInCommand(...))), + ...self::getRegisteredCommandTypes($scope), ]; } + + /** + * Returns the registered command types scripts in the scope may use: a + * battle page's `concede` is never offered to a field script. + * + * @return list + */ + public static function getRegisteredCommandTypes(EventExecutionScope $scope = EventExecutionScope::FIELD): array + { + return array_values(array_keys(array_filter(ScriptCommandRegistry::getCatalog()->definitions, + static fn(ScriptCommandDefinition $definition): bool => $definition->runsIn($scope)))); + } + + /** + * Returns the field sets of the registered commands, read from their + * declarations. + * + * A command's first list field is edited as its entries, like a route's + * steps; any further list field is shown read-only and kept as written. + * + * @return array> + */ + public static function getRegisteredCommandVariants(bool $graphical = false): array + { + $variants = []; + + foreach (ScriptCommandRegistry::getCatalog()->definitions as $type => $definition) { + $listField = self::findRegisteredListField($definition); + $getFields = static function (array $entry) use ($definition, $listField, $graphical): array { + $fields = []; + foreach ($definition->fields as $field) { + if ($field === $listField) { continue; } + if ($field->reference === ScriptCommandReference::STAGE_TIMELINE) { + $value = $entry; + foreach (explode('.', $field->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + array_push($fields, ...InnPresentationFields::getFields($value, $field->key, $field->label, $graphical)); + } else { + array_push($fields, ...($field->kind === ScriptCommandFieldKind::LIST + ? [new RecordField($field->key, $field->label, isReadOnly: true)] : self::describeRegisteredField($field))); + } + } + return $fields; + }; + $variants[$type] = array_any($definition->fields, static fn(ScriptCommandField $field): bool => $field->reference === ScriptCommandReference::STAGE_TIMELINE) + ? $getFields : $getFields([]); + } + + return $variants; + } + + /** + * Returns the entries list of each registered command that has one. + * + * @return array + */ + public static function getRegisteredCommandLists(bool $graphical = false): array + { + $lists = []; + + foreach (ScriptCommandRegistry::getCatalog()->definitions as $type => $definition) { + $listField = self::findRegisteredListField($definition); + + if ($listField === null) { + $stageField = array_find($definition->fields, static fn(ScriptCommandField $field): bool => $field->reference === ScriptCommandReference::STAGE_TIMELINE); + if ($graphical && $stageField !== null) { + $lists[$type] = static function (array $entry) use ($stageField): ?RecordSubList { + $value = $entry; + foreach (explode('.', $stageField->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + return InnPresentationFields::getBindingList($value, $stageField->key); + }; + } + continue; + } + + $lists[$type] = new RecordSubList( + key: $listField->key, + // Settings ids read the prefix up to the entry number, so it + // holds letters alone. + prefix: lcfirst(implode('', array_map(ucfirst(...), preg_split('/[^A-Za-z]+/', $listField->key, flags: PREG_SPLIT_NO_EMPTY) ?: ['entry']))), + singular: strtolower($listField->label) . ' entry', + fields: array_merge(...array_map(self::describeRegisteredField(...), $listField->fields)), + blank: self::getRegisteredBlankEntry($listField->fields[0]), + ); + } + + return $lists; + } + + /** The list field a registered command's entries are edited from: its first. */ + private static function findRegisteredListField(ScriptCommandDefinition $definition): ?ScriptCommandField + { + foreach ($definition->fields as $field) { + if ($field->kind === ScriptCommandFieldKind::LIST) { + return $field; + } + } + + return null; + } + + /** + * Describes one declared field as the settings it is edited through. An + * optional text or reference drops out when cleared; numbers and flags + * keep what the author sets, since a project's handler decides what an + * absent one means. + * + * @return RecordField[] + */ + private static function describeRegisteredField(ScriptCommandField $field): array + { + return match ($field->kind) { + ScriptCommandFieldKind::TEXT => [new RecordField($field->key, $field->label, removeWhenEmpty: ! $field->required)], + ScriptCommandFieldKind::INTEGER => [new RecordField($field->key, $field->label, InputControlType::INTEGER)], + ScriptCommandFieldKind::NUMBER => [new RecordField($field->key, $field->label, InputControlType::FLOAT)], + ScriptCommandFieldKind::BOOLEAN => [RecordField::boolean($field->key, $field->label, removeWhenEmpty: false)], + ScriptCommandFieldKind::OPTION => [new RecordField($field->key, $field->label, options: $field->options, removeWhenEmpty: ! $field->required)], + // A troop member is the owning troop's own, never a project resource. + ScriptCommandFieldKind::REFERENCE => [$field->reference === ScriptCommandReference::TROOP_MEMBER + ? RecordField::troopMember($field->key, $field->label, $field->required) : RecordField::reference( + $field->key, + $field->label, + ProjectScriptCommands::getReferenceCategory($field->reference + ?? throw new LogicException("Reference field {$field->key} names no resource.")) + ?? throw new LogicException("Reference field {$field->key} names no project resource."), + allowsNone: ! $field->required, + )], + ScriptCommandFieldKind::POSITION => [ + new RecordField("{$field->key}.x", "{$field->label} X", InputControlType::INTEGER), + new RecordField("{$field->key}.y", "{$field->label} Y", InputControlType::INTEGER), + ], + ScriptCommandFieldKind::LIST => [new RecordField($field->key, $field->label, isReadOnly: true)], + }; + } + + /** + * Returns a fresh list entry: its first field, empty, so the entry is a + * keyed record from the start and validation names what it still needs. + * + * @return array + */ + private static function getRegisteredBlankEntry(ScriptCommandField $field): array + { + $value = match ($field->kind) { + ScriptCommandFieldKind::INTEGER => 0, + ScriptCommandFieldKind::NUMBER => 0.0, + ScriptCommandFieldKind::BOOLEAN => false, + ScriptCommandFieldKind::OPTION => $field->options[0] ?? '', + ScriptCommandFieldKind::POSITION => ['x' => 0, 'y' => 0], + default => '', + }; + $entry = []; + $target = &$entry; + + foreach (explode('.', $field->key) as $segment) { + $target[$segment] = []; + $target = &$target[$segment]; + } + + $target = $value; + + return $entry; + } } diff --git a/src/Database/RecordStorage.php b/src/Database/RecordStorage.php index 02fff020..4be7aed9 100644 --- a/src/Database/RecordStorage.php +++ b/src/Database/RecordStorage.php @@ -18,15 +18,6 @@ enum RecordStorage: string /** A flattened subtree of the project's config.php (vocab, messages). */ case CONFIG_SUBTREE = 'config_subtree'; - /** - * A read-only inventory of the files in a directory, listed without being - * evaluated. Used where the "data" is really PHP source (the project's - * `Data/Types/*.php` enum declarations), which the editor must never - * `require` — doing so would declare classes into the editor's own - * process and can fatal on redeclaration. - */ - case FILE_LISTING = 'file_listing'; - /** * Records that live inside another asset's data (a map's `npcs`), read * from and written back through that asset rather than a file of their diff --git a/src/Database/RecordSubList.php b/src/Database/RecordSubList.php index 348ec3b3..0482b175 100644 --- a/src/Database/RecordSubList.php +++ b/src/Database/RecordSubList.php @@ -35,6 +35,26 @@ * @param array> $variantArms Command lists * only some variants carry, variant => (key => label): a cinematic * `sequence` owns its `commands`, a `choice` its `cancel` arm. + * @param list> $exclusiveFields Field groups where authoring one removes the others. + * @param string|null $scalarKey The field an entry authored as a bare value is (a + * reward item's `item`): read as that field, and written back bare while it holds nothing else. + * @param bool $removeWhenEmpty Whether the list's key is dropped when its last entry goes, + * as files that omit an empty list author it (a quest's reward items). + * @param string $heading What a list beside the record's own is headed as ("Reward Items"): its + * heading row names it, counts it, and is where an entry is added to it. A record-level command + * list is named by it on the row that opens it ("Stage Camera"), rather than by its key. + * @param string|null $keyField For a list stored as a map (a tileset's pieces, keyed by piece + * id): the field each entry carries its key as. Keys are unique and never empty. + * @param string|null $valueField For a keyed list whose values are not entries of their own (a + * piece's tiles: a layer name keyed to its rows): the field each entry carries its value as. + * @param Closure(array, string): array|null $prepareEdit Normalizes dependent fields after a field edit, before mutation. + * @param RecordSubList|null $armCommandList The command list this list's command arms hold, at + * every depth; the shared field-script command list when null. A troop's event pages hold the + * battle-scoped one. + * @param Closure(array, Closure(int): ?int): array|null $remapIndexReferences + * Rewrites the record's references to this list's entries by index when an entry is removed or + * inserted (a troop's event pages naming members), through the map from old index to new; null + * from the map is a removed entry, which the closure refuses with a RecordRefusal while it is named. */ public function __construct( public string $key, @@ -47,9 +67,123 @@ public function __construct( public array $nestedLists = [], public array $commandArms = [], public array $variantArms = [], + public array $exclusiveFields = [], + public string $heading = '', + public ?string $scalarKey = null, + public bool $removeWhenEmpty = false, + public ?string $keyField = null, + public ?string $valueField = null, + public ?Closure $prepareEdit = null, + /** Dynamic entry fields independent of a persisted variant discriminator. */ + public ?Closure $getFields = null, + public ?RecordSubList $armCommandList = null, + public ?Closure $remapIndexReferences = null, + /** The variant an entry that names none has, as its runtime reads it. */ + public ?string $variantDefault = null, ) { } + /** The variant an entry is: the one it names, or the list's default when it names none. */ + public function getVariant(array $entry): string + { + return $this->variantKey === null ? '' : strval($entry[$this->variantKey] ?? $this->variantDefault ?? ''); + } + + /** Where a keyed list's entry keeps a value the editor does not read, to write it back as it was. */ + public const string RAW_VALUE = '__value'; + + /** + * The entries a list holds, as the editor edits them. An entry authored + * as a bare value is its scalar field's value; in a list keyed by + * `keyField` each entry carries its key as that field. A value an + * unkeyed list holds that is neither stays as it is; a keyed list keeps + * it under its key, so writing the list back keeps it. + * + * @param mixed $stored The list as the file holds it. + * @return list + */ + public function readEntries(mixed $stored): array + { + if (! is_array($stored)) { + return []; + } + + $entries = []; + + foreach ($stored as $key => $value) { + $entry = match (true) { + $this->valueField !== null => [$this->valueField => $value], + is_array($value) => $value, + $this->scalarKey !== null && is_scalar($value) => [$this->scalarKey => $value], + default => null, + }; + + $entries[] = $this->keyField === null + ? $entry ?? $value + : [$this->keyField => (string) $key] + ($entry ?? [self::RAW_VALUE => $value]); + } + + return $entries; + } + + /** + * The form a list's entries are stored in: a map keyed by `keyField` + * where the list is one, and an entry holding nothing but its scalar + * field written bare. + * + * @param list $entries The entries, as the editor edits them. + * @return array + */ + public function writeEntries(array $entries): array + { + $stored = []; + + foreach (array_values($entries) as $entry) { + if (! is_array($entry)) { + $stored[] = $entry; + continue; + } + + $key = null; + + if ($this->keyField !== null) { + $key = (string) ($entry[$this->keyField] ?? ''); + unset($entry[$this->keyField]); + } + + $value = match (true) { + $this->valueField !== null => $entry[$this->valueField] ?? null, + array_key_exists(self::RAW_VALUE, $entry) => $entry[self::RAW_VALUE], + $this->scalarKey !== null && array_keys($entry) === [$this->scalarKey] => $entry[$this->scalarKey], + default => $entry, + }; + + if ($key === null) { + $stored[] = $value; + } else { + $stored[$key] = $value; + } + } + + return $stored; + } + + /** @param array $entry @return array */ + public function removeConflictingFields(array $entry, string $editedField): array + { + if ($this->prepareEdit !== null) { + $entry = ($this->prepareEdit)($entry, $editedField); + } + if (! array_key_exists($editedField, $entry)) { return $entry; } + foreach ($this->exclusiveFields as $fields) { + if (! in_array($editedField, $fields, true)) { continue; } + foreach ($fields as $field) { + if ($field !== $editedField) { unset($entry[$field]); } + } + } + return $entry; + } + /** * Returns the command arms one entry carries: the arms every entry has, * and those its variant adds. @@ -62,7 +196,7 @@ public function armsFor(array $entry): array $arms = $this->commandArms; if ($this->variantKey !== null) { - $variant = strval($entry[$this->variantKey] ?? ''); + $variant = $this->getVariant($entry); foreach ($this->variantArms[$variant] ?? [] as $key => $label) { $arms[$key] = $label; @@ -83,11 +217,12 @@ public function armsFor(array $entry): array */ public function fieldsFor(array $entry): array { + if ($this->getFields !== null) { return ($this->getFields)($entry); } if ($this->variantKey === null) { return $this->fields; } - $variant = strval($entry[$this->variantKey] ?? ''); + $variant = $this->getVariant($entry); $fields = $this->variants[$variant] ?? []; if ($fields instanceof Closure) { @@ -117,7 +252,7 @@ public function nestedListFor(array $entry): ?self return null; } - $nested = $this->nestedLists[strval($entry[$this->variantKey] ?? '')] ?? null; + $nested = $this->nestedLists[$this->getVariant($entry)] ?? null; // A list some shapes of a variant carry and others do not -- a // camera route's points, but not a camera detach's -- is a closure diff --git a/src/Database/ReferenceCatalog.php b/src/Database/ReferenceCatalog.php index 52000c9b..df68b31a 100644 --- a/src/Database/ReferenceCatalog.php +++ b/src/Database/ReferenceCatalog.php @@ -6,13 +6,36 @@ use Ichiloto\Editor\PermanentGrowthCatalog; use Ichiloto\Editor\ProjectActor; -use Ichiloto\Editor\ProjectClass; +use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectQuest; -use Ichiloto\Editor\ProjectSkill; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\Cutscenes\CutsceneAsset; use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Dialogue\DialogueCatalogFile; +use Ichiloto\Editor\Dialogue\DialogueEmotionFields; +use Ichiloto\Editor\Dialogue\DialogueLineSpeaker; +use Ichiloto\Editor\Dialogue\DialoguePortraitFields; +use Ichiloto\Editor\Dialogue\DialogueSpeakerFields; +use Ichiloto\Engine\Messaging\Dialogue\Presentation\DialoguePresentationCatalog; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Animations\Field\FieldPresentationCatalog; +use Ichiloto\Engine\Progress\Knowledge\KnowledgeRecordType; +use Ichiloto\Engine\Rendering\Presentation\Text\ProjectTextCatalog; +use Ichiloto\Engine\Animations\Timelines\EffectTimelineLibrary; +use Ichiloto\Engine\Battle\CounterAttackRule; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Entities\Enumerations\ArmorType; +use Ichiloto\Engine\Entities\Enumerations\Occasion; +use Ichiloto\Engine\Entities\Skills\BasicSkill; +use Ichiloto\Engine\Entities\Skills\Skill; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use Ichiloto\Engine\Battle\Resolution\ResolutionKind; +use Ichiloto\Engine\Entities\Magic\MagicEffectType; +use Ichiloto\Engine\Battle\Insight\RevealFact; +use Ichiloto\Engine\Entities\Skills\SkillResolutionScope; +use InvalidArgumentException; +use Throwable; /** * What a field pointing at another resource may point at. @@ -29,12 +52,33 @@ final class ReferenceCatalog /** * The kinds of reference a field may declare. */ + /** The encounter terrains the project's terrain kinds meet ({@see \Ichiloto\Engine\Rendering\Tilesets\TerrainKind::$encounters}). */ + public const string ENCOUNTER_TERRAINS = 'encounter_terrains'; + public const array CATEGORIES = [ 'actors', 'actor_ids', 'classes', + 'attack_skills', + 'counter_skills', + 'resolution_kinds', + 'piece_connections', + 'resolution_scopes', + 'magic_effect_types', + // What a reveal effect can uncover about a foe: the Engine's own facts. + 'reveal_facts', + 'weapon_types', + 'armor_types', 'skills', + // The regions every Mastery Map shares, by id. + 'mastery_regions', + // The mastery tree's branches, by id: what a branch may grow within. + MasteryFields::BRANCHES, + // Every node of the mastery tree, by id: what a character's map gives terms for. + MasteryFields::NODES, 'quests', + // Income schedules, by stable id, for the income script commands. + 'income_schedules', 'maps', 'items', 'weapons', @@ -43,14 +87,22 @@ final class ReferenceCatalog 'troops', 'states', 'animations', + 'animation_ids', 'skits', 'common_events', 'inventory', 'bgm', 'sfx', 'enemy_sprites', + 'png_assets', 'elements', 'map_npcs', + 'map_world_objects', + // A map's minimap landmarks, by stable id. + 'map_landmarks', + 'world_object_tile_layers', + // A map event condition's marker, listed for the map that condition names. + 'map_event_markers', 'knowledge_subjects', 'knowledge_reports', 'knowledge_record_types', @@ -65,8 +117,42 @@ final class ReferenceCatalog 'cinematic_cast', 'cinematic_subjects', 'cinematic_checkpoints', + 'cinematic_movement_routes', 'summon_cues', + 'effect_cues', + 'stage_subjects', + 'stage_attachments', 'event_markers', + 'tilesets', + 'effects', + 'stage_timelines', + 'field_resources', + 'text_roles', + 'font_assets', + 'map_regions', + // A map's role, by stable id, from the Map Roles page. + 'map_roles', + // A map's place type, by stable id, from the Place Types page. + 'place_types', + 'animation_roles', + 'battle_arenas', + self::ENCOUNTER_TERRAINS, + // The dialogue catalogue's identities, actor and non-actor, whose artwork lines show. + 'dialogue_identities', + ]; + + /** + * The reference kinds whose values are pictures an author looks at or + * sounds an author listens to: how an interface shows or plays each + * value, and the folder under the project root its values are found in. + * An interface knows a value is a picture or a sound from this, never + * from the kind's name. + */ + private const array MEDIA = [ + 'png_assets' => ['kind' => 'image', 'root' => 'assets'], + // Music and sound are named without their extension; the game's players find the file and play it. + 'bgm' => ['kind' => 'audio', 'root' => 'assets/Audio/BGM'], + 'sfx' => ['kind' => 'audio', 'root' => 'assets/Audio/SFX'], ]; /** @@ -77,6 +163,30 @@ final class ReferenceCatalog 'sfx' => 'SFX', ]; + /** + * How an interface shows or plays the values of a reference kind, when + * they are pictures or sounds: `kind` (`image` or `audio`) and `root`, + * the folder under the project root the values are found in. Null for + * any other kind. + * + * @return array{kind: string, root: string}|null + */ + public static function describeMedia(string $category): ?array + { + return self::MEDIA[$category] ?? null; + } + + /** The same live workspace root that supplies the project image picker. */ + public function getAssetRoot(): string + { + return rtrim($this->workspace->projectRoot, '/') . '/assets'; + } + + public function describeWorldObjectReferences(ProjectMap $map, string $id): array + { + return new \Ichiloto\Editor\Field\WorldObjectReferences($this->workspace)->describeReferences($map, $id); + } + /** * @param ProjectWorkspace $workspace The project. * @param ProjectMap|null $currentMap The map the author is working in, @@ -87,10 +197,14 @@ final class ReferenceCatalog */ private ?InventoryCatalog $inventoryCatalog = null; + /** @var array|null The battle presentation's arenas, read once. */ + private ?array $arenaNames = null; + public function __construct( private readonly ProjectWorkspace $workspace, private readonly ?ProjectMap $currentMap = null, private readonly ?CutsceneAsset $currentCutscene = null, + private readonly ?array $movementCommands = null, ) { } @@ -116,20 +230,35 @@ public function valuesFor(string $category): array // definition id, which is the one thing a rename never changes. 'actor_ids' => array_map( static fn(ProjectActor $actor): string => $actor->getDefinitionId(), - $this->workspace->actorDatabase->getActors() - ), - 'classes' => array_map( - static fn(ProjectClass $class): string => $class->getName(), - $this->workspace->classDatabase->getClasses() - ), - 'skills' => array_map( - static fn(ProjectSkill $skill): string => $skill->getName(), - $this->workspace->skillDatabase->getSkills() - ), - 'quests' => array_map( - static fn(ProjectQuest $quest): string => $quest->getId(), - $this->workspace->questDatabase->getQuests() + array_values(array_filter($this->workspace->actorDatabase->getActors(), + static fn(ProjectActor $actor): bool => $actor->hasDefinitionId())) ), + // What an actor's Attack command may use: a basic skill from the + // catalogue that can be used in battle. + 'attack_skills' => $this->attackSkillNames(), + // What a counter attack may respond with, by the Engine's own rule. + 'counter_skills' => $this->counterSkillNames(), + // A class restricts what its members equip by the engine's type names. + // How a skill effect resolves, how often a skill rolls, and what + // kind of spell it is: the Engine's own vocabularies. + 'resolution_kinds' => array_map(static fn(ResolutionKind $kind): string => $kind->value, ResolutionKind::cases()), + // How a tileset piece joins the cells beside it. + 'piece_connections' => [\Ichiloto\Engine\Rendering\Tilesets\TilesetPiece::LINES], + 'resolution_scopes' => array_map(static fn(SkillResolutionScope $scope): string => $scope->value, SkillResolutionScope::cases()), + 'magic_effect_types' => array_map(static fn(MagicEffectType $type): string => $type->value, MagicEffectType::cases()), + 'reveal_facts' => array_map(static fn(RevealFact $fact): string => $fact->value, RevealFact::cases()), + 'weapon_types' => array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases()), + 'armor_types' => array_map(static fn(ArmorType $type): string => $type->value, ArmorType::cases()), + // Spells and abilities may be authored in any of the Engine's + // skill files; a reference names the skill wherever it lives. + 'skills' => $this->workspace->getSkillNames(), + 'mastery_regions' => array_map(static fn(ProjectRecord $record): string => strval($record->get('id')), + $this->workspace->getRecordDatabase(MasteryFields::REGIONS)?->getRecords() ?? []), + MasteryFields::NODES => $this->masteryNodeIds(), + MasteryFields::BRANCHES => array_map(static fn(ProjectRecord $record): string => strval($record->get('id')), + $this->workspace->getRecordDatabase(MasteryFields::BRANCHES)?->getRecords() ?? []), + 'quests' => array_map(static fn(ProjectQuest $quest): string => $quest->getId(), $this->workspace->getQuests()), + 'income_schedules' => array_keys($this->incomeScheduleNames()), 'maps' => $this->workspace->mapIds, // A shop's stock is whatever the engine's ItemStore holds, and // that is everything in items.php: items, weapons and armors @@ -137,15 +266,16 @@ public function valuesFor(string $category): array // entitled to sell. What is offered -- and stored -- is the // stable definition id, which is the identity the runtime // resolves and the one thing a rename does not change. - 'inventory' => $this->inventoryCatalog()->ids(), - 'items' => $this->inventoryCatalog()->idsIn('items'), - 'weapons' => $this->inventoryCatalog()->idsIn('weapons'), - 'armors' => $this->inventoryCatalog()->idsIn('armors'), + 'inventory' => $this->getInventoryCatalog()->ids(), + 'items' => $this->getInventoryCatalog()->idsIn('items'), + 'weapons' => $this->getInventoryCatalog()->idsIn('weapons'), + 'armors' => $this->getInventoryCatalog()->idsIn('armors'), 'bgm', 'sfx' => $this->audioValues($category), // The engine loads an enemy's image as // Graphics/Enemies/.txt, appending the extension itself, // so the file stems are the values. 'enemy_sprites' => $this->fileValues('assets/Graphics/Enemies'), + 'png_assets' => self::getPngAssets($this->workspace->projectRoot), 'elements' => $this->elementValues(), // An Optimize weight may apply to one element or to whichever // element an outcome happened to be, which the runtime spells @@ -154,7 +284,7 @@ public function valuesFor(string $category): array 'equipment_availabilities' => $this->equipmentVocabulary('availability'), 'equipment_acquisition_policies' => $this->equipmentVocabulary('acquisitionPolicy'), 'equipment_special_properties' => $this->equipmentVocabulary('specialProperty'), - 'permanent_growth' => PermanentGrowthCatalog::fromProject($this->workspace->projectRoot)->ids(), + 'permanent_growth' => $this->workspace->catalogs->loadPermanentGrowth()->ids(), 'knowledge_record_types' => $this->knowledgeRecordTypes(), 'knowledge_observations' => $this->knowledgeObservations(), 'knowledge_subjects' => $this->knowledgeIds('subjects'), @@ -162,10 +292,20 @@ public function valuesFor(string $category): array // NPC ids are map-local, so the choices are the current map's: // read live from the collection, a just-created NPC is offered // at once and a deleted one is gone. - 'map_npcs' => $this->currentMap?->getNpcs()->ids() ?? [], + 'map_npcs' => $this->getSubjectMap()?->getNpcs()->ids() ?? [], + 'map_world_objects' => $this->getSubjectMap() === null ? [] : array_column(\Ichiloto\Editor\Field\WorldObjectAuthoring::readEntries($this->getSubjectMap()), 'id'), + 'map_landmarks' => array_values(array_filter(array_column($this->readSubjectMapLandmarks(), 'id'), is_string(...))), + 'world_object_tile_layers' => $this->currentMap === null ? [] : array_column($this->currentMap->describeTileLayers()['layers'], 'name'), + // Only meaningful for a named map; the session lists them for the map a condition names. + 'map_event_markers' => [], + // Older references name an animation; current ones store its id. 'animations' => array_map( - static fn(object $animation): string => $animation->name ?? '', - $this->workspace->animationDatabase->getAnimations() + static fn(ProjectRecord $animation): string => strval($animation->get('name')), + $this->animationRecords(), + ), + 'animation_ids' => array_map( + static fn(ProjectRecord $animation): string => strval($animation->get('id')), + $this->animationRecords(), ), // Cutscenes are folders, so the stable id is the folder name. 'cinematics' => $this->workspace->cutscenes?->ids(CutsceneType::CINEMATIC) ?? [], @@ -176,12 +316,196 @@ public function valuesFor(string $category): array 'cinematic_cast' => $this->castIds(['staged_actor']), 'cinematic_subjects' => $this->subjectIds(), 'cinematic_checkpoints' => $this->checkpointIds(), - 'summon_cues' => $this->summonCueIds(), - 'event_markers' => $this->currentMap?->getEventMarkers() ?? [], + 'cinematic_movement_routes' => $this->getMovementRouteIds(), + 'summon_cues' => $this->getTimelineCueIds(CutsceneType::SUMMON), + 'effect_cues' => $this->getTimelineCueIds(CutsceneType::EFFECT), + // A summon sequence's stage: the subjects it registers, and every + // named point on them (each image picks one on its own subject). + 'stage_subjects' => array_keys($this->getStageSubjects()), + 'stage_attachments' => array_keys($this->getStageAttachments()), + 'event_markers' => $this->getSubjectMap()?->getEventMarkers() ?? [], + // A map's kind is one of the project's tilesets, by file stem. + 'tilesets' => array_keys($this->loadTilesetNames()), + // The scenes a graphical battle can take place in, by the key a + // map's encounters or a start_battle command name one with. + 'battle_arenas' => array_map(strval(...), array_keys($this->loadArenaNames())), + // The encounter terrains the project's tilesets' kinds meet, each an arena may draw a variant for. + self::ENCOUNTER_TERRAINS => $this->loadEncounterTerrains(), + // A region is the display name the game shows for where the party + // is; choosing from the names the maps already use keeps one + // region spelled one way. + 'map_regions' => $this->mapRegions(), + // The battle roles an animation can play, as the Engine resolves them. + 'animation_roles' => \Ichiloto\Engine\Animations\ActionAnimationResolver::getSupportedRoles(), + // Effect timelines are folders the Engine lists by stable id. + 'effects' => $this->workspace->catalogs->listEffectTimelineIds(), + 'stage_timelines' => $this->stageTimelineIds(), + 'field_resources' => array_map(strval(...), array_keys($this->loadFieldResourceNames())), + 'text_roles' => $this->loadTextRoleIds(), + 'font_assets' => self::getFontAssets($this->workspace->projectRoot), + 'dialogue_identities' => array_column($this->listDialogueIdentities(), 'id'), default => $this->recordValues($category), }; } + /** + * The effect timelines the Engine admits as a stage of their own, such + * as an inn's rest, by stable id: those its stage loading accepts (fixed, + * once, a stage descriptor, stage images, no cues). Admission is the + * Engine's; a timeline it refuses is not offered. + * + * @return list + */ + /** + * The project's named field resources (assets/Data/Presentation/field.php), by stable id, with their display + * names. A catalogue that cannot be read offers none; project validation reports why. + * + * @return array + */ + private function loadFieldResourceNames(): array + { + try { + $catalog = $this->getFieldPresentationCatalog(); + } catch (Throwable) { + return []; + } + + return array_map(static fn($resource): string => $resource->name, $catalog->resources); + } + + /** + * The project's field presentation (assets/Data/Presentation/field.php) as the Engine reads it, with the + * Field Resources page's records as it holds them, unsaved edits included: what saving would leave. Every + * reader of field resources (pickers, world objects, validation) reads it, so a resource added in this + * session is usable at once. + * + * @throws \InvalidArgumentException When the catalogue is read-only or the Engine refuses it. + */ + public function getFieldPresentationCatalog(): FieldPresentationCatalog + { + return $this->workspace->catalogs->loadFieldPresentation(); + } + + /** + * The project's text roles (assets/Data/Presentation/text.php), by stable id. A catalogue that cannot be read + * offers none; project validation reports why. + * + * @return list + */ + private function loadTextRoleIds(): array + { + try { + $catalog = $this->workspace->catalogs->loadTextCatalog(); + } catch (Throwable) { + return []; + } + + return $catalog === null ? [] : array_map(strval(...), array_keys($catalog->toArray()['roles'])); + } + + /** + * The project's font files under assets, as asset-root-relative paths: what a text role's fonts are chosen + * from. The project owns its fonts; nothing is offered from the machine. + * + * @return list + */ + public static function getFontAssets(string $projectRoot): array + { + $root = realpath(rtrim($projectRoot, '/') . '/assets'); + if ($root === false) { + return []; + } + $paths = []; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $resolved = $file->getRealPath(); + if ($file->isFile() && in_array(strtolower($file->getExtension()), ['ttf', 'otf', 'ttc', 'otc'], true) + && $resolved !== false && str_starts_with($resolved, $root . DIRECTORY_SEPARATOR)) { + $paths[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($root) + 1)); + } + } + sort($paths); + + return $paths; + } + + private function stageTimelineIds(): array + { + $catalogs = $this->workspace->catalogs; + + return array_values(array_filter( + $catalogs->listEffectTimelineIds(), + static function (string $id) use ($catalogs): bool { + try { + $catalogs->loadEffectTimeline($id, forStage: true); + + return true; + } catch (Throwable) { + return false; + } + }, + )); + } + + /** @return string[] The catalogue's basic skills usable in battle, in catalogue order. */ + private function attackSkillNames(): array + { + return array_keys(array_filter( + $this->workspace->loadSkillCatalog()->getSkills(), + static fn(Skill $skill): bool => $skill instanceof BasicSkill + && in_array($skill->occasion, [Occasion::ALWAYS, Occasion::BATTLE_SCREEN], true), + )); + } + + /** + * The skills a counter attack may respond with: those the Engine's + * CounterAttackRule accepts (a battle-usable basic or special skill + * targeting one living opponent, without summon or required weapons). + * + * @return list + */ + private function counterSkillNames(): array + { + $catalog = $this->workspace->loadSkillCatalog(); + $names = []; + foreach (array_keys($catalog->getSkills()) as $name) { + try { + new CounterAttackRule((string) $name)->resolveSkill($catalog); + $names[] = (string) $name; + } catch (\InvalidArgumentException) { + continue; + } + } + + return $names; + } + + /** @return ProjectRecord[] The project's animations, in file order. */ + private function animationRecords(): array + { + return $this->workspace->getRecordDatabase('animations')?->getRecords() ?? []; + } + + /** + * The region names the project's maps use, each once, in name order. + * + * @return string[] + */ + private function mapRegions(): array + { + $regions = []; + foreach ($this->workspace->maps as $map) { + $region = $map->getMapField('region'); + if (is_string($region) && trim($region) !== '') { + $regions[trim($region)] = true; + } + } + $regions = array_keys($regions); + natcasesort($regions); + + return array_values($regions); + } + /** * Returns the ids of the current cinematic's cast members of the given * kinds, staged actors declared in the data file and actors staged by a @@ -267,12 +591,86 @@ private function subjectIds(): array { return array_values(array_unique([ ...$this->castIds(['staged_actor', 'npc', 'party_actor', 'player']), - ...($this->currentMap?->getNpcs()->ids() ?? []), + ...($this->getSubjectMap()?->getNpcs()->ids() ?? []), + ...$this->valuesFor('map_world_objects'), ...$this->valuesFor('actors'), - ...($this->currentMap?->getEventMarkers() ?? []), + ...($this->getSubjectMap()?->getEventMarkers() ?? []), ])); } + /** @return list> The landmark declarations of the map a reference reads, or none when unreadable. */ + private function readSubjectMapLandmarks(): array + { + $map = $this->getSubjectMap(); + if ($map === null) { return []; } + try { + return \Ichiloto\Editor\Field\MapLandmarks::readMap($map)->entries; + } catch (\InvalidArgumentException) { + return []; + } + } + + /** + * The map whose NPCs, world objects and markers a subject reference names: the explicitly selected map, or + * without one a cinematic's declared start map. + */ + /** + * The values a field chooses among that its own entry decides ({@see RecordField::$entryChoices}), read from + * what the field lives in. `world_object_attachments`: the attachments declared by the resource of the world + * object a subject reference (`at`) names, on the subject map; none until it names an object that has some. + * `terrain_grounds`: the ground kinds of the tileset a terrain kind belongs to, those that stand on nothing, + * other than the kind itself. `tileset_whole_pieces`: the pieces of the tileset a place icon belongs to that are + * stamped whole, any of which can show a place type. `tileset_thickets`: the thickets of the tileset a terrain + * kind belongs to. + * + * @param array $holder What the field lives in. + * @param array $owner The record holding the entry. + * @return list + * @throws InvalidArgumentException For a source nothing reads. + */ + public function listEntryChoices(string $source, array $holder, array $owner = []): array + { + if ($source === 'terrain_grounds') { + // A keyed entry carries its key as its id while edited, and not as stored. + $self = array_diff_key($holder, ['id' => true]); + $kinds = is_array($owner['kinds'] ?? null) ? $owner['kinds'] : []; + return array_values(array_map(strval(...), array_keys(array_filter($kinds, static fn(mixed $kind, int|string $id): bool => + is_array($kind) && ($kind['ground'] ?? null) === null && (string) $id !== ($holder['id'] ?? null) + && array_diff_key($kind, ['id' => true]) !== $self, ARRAY_FILTER_USE_BOTH)))); + } + if ($source === 'tileset_whole_pieces') { + $pieces = is_array($owner['pieces'] ?? null) ? $owner['pieces'] : []; + return array_values(array_map(strval(...), array_keys(array_filter($pieces, static fn(mixed $piece): bool + => is_array($piece) && ($piece['connects'] ?? null) === null)))); + } + if ($source === 'tileset_thickets') { + $thickets = is_array($owner['thickets'] ?? null) ? $owner['thickets'] : []; + return array_values(array_map(strval(...), array_keys($thickets))); + } + if ($source !== 'world_object_attachments') { + throw new InvalidArgumentException(sprintf('There is no entry choice source %s.', $source)); + } + $objectId = ($holder['kind'] ?? null) === 'world_object' ? ($holder['id'] ?? null) : null; + $map = $this->getSubjectMap(); + $found = $map === null || !is_string($objectId) ? null : \Ichiloto\Editor\Field\WorldObjectAuthoring::findAttachments($map, $objectId); + return array_map(static fn(\Ichiloto\Engine\Field\WorldObjectAttachment $attachment): string => $attachment->id, $found['attachments'] ?? []); + } + + public function getSubjectMap(): ?ProjectMap + { + if ($this->currentMap !== null) { + return $this->currentMap; + } + if ($this->currentCutscene?->type === CutsceneType::CINEMATIC) { + $id = $this->currentCutscene->data()['startMap'] ?? null; + if (is_string($id) && $id !== '') { + return array_find($this->workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === $id); + } + } + + return null; + } + /** * Returns the checkpoints the current cinematic declares. * @@ -290,14 +688,72 @@ private function checkpointIds(): array ), static fn(string $checkpoint): bool => $checkpoint !== '')); } + /** A cutscene's own references: its cast, and the map-local subjects on the map it declares it starts on. */ + public function createCutsceneContext(CutsceneAsset $cutscene): self + { + return new self($this->workspace, $this->currentMap, $cutscene, $this->movementCommands); + } + /** - * Returns the stable cue ids declared by the current summon's timeline. + * A reusable event script's references: its map-local subjects are on the map its callers run it on, when they + * all run it on one ({@see \Ichiloto\Editor\Events\CommonEventMaps}); otherwise it names none. + */ + public function createCommonEventContext(string $scriptId): self + { + $maps = \Ichiloto\Editor\Events\CommonEventMaps::read($this->workspace)->findMaps($scriptId); + $map = count($maps) === 1 ? array_find($this->workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === $maps[0]) : null; + return new self($this->workspace, $map, null, $this->movementCommands); + } + + /** A projection keeps the actual invocation as reference owner, rather than its one displayed command. */ + public function createMovementContext(array $commands): self + { + return new self($this->workspace, $this->currentMap, $this->currentCutscene, $commands); + } + + /** Live invocation-local declarations; runtime checks completion, subject and one-time consumption. */ + public function getMovementRouteIds(?ProjectRecord $record = null, array $path = []): array + { + $commands = $this->movementCommands ?? ($record === null + ? ($this->currentCutscene?->type === CutsceneType::CINEMATIC ? $this->currentCutscene->commands() : []) + : MovementRouteFields::getOwnerCommands((array) $record->toArray(), $path)); + $ids = $visited = $events = []; + foreach ($this->workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $eventRecord) { + $events[$eventRecord->recordId] = $eventRecord->getSubList('commands'); + } + $walk = static function (array $commands) use (&$walk, &$ids, &$visited, $events): void { + foreach ($commands as $value) { + if (!is_array($value)) { continue; } + if (($value['type'] ?? '') === 'move_route' && is_string($value['remember'] ?? null) + && preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/', $value['remember']) === 1 + && strtolower(trim(strval($value['subject'] ?? 'player'))) !== 'staged_actor') { + $ids[$value['remember']] = $value['remember']; + } + $eventId = is_string($value['id'] ?? null) ? trim($value['id']) : ''; + if (($value['type'] ?? '') === 'common_event' + && isset($events[$eventId]) && !isset($visited[$eventId])) { + $visited[$eventId] = true; + $walk($events[$eventId]); + } + foreach (MovementRouteFields::getChildCommandLists($value) as $child) { + $walk($child); + } + } + }; + $walk($commands); + return array_values($ids); + } + + /** + * Returns the stable cue ids the current summon's or effect's timeline declares. * * @return string[] */ - private function summonCueIds(): array + private function getTimelineCueIds(CutsceneType $type): array { - if ($this->currentCutscene?->type !== CutsceneType::SUMMON) { + // An effect's payload is the sequence being edited, so its cues are + // that sequence's. + if ($this->currentCutscene?->type !== $type) { return []; } @@ -314,6 +770,54 @@ private function summonCueIds(): array return array_values($ids); } + /** + * The subjects of the stage the current timeline sequence declares, by id. + * + * @return array> + */ + private function getStageSubjects(): array + { + if (! in_array($this->currentCutscene?->type, [CutsceneType::SUMMON, CutsceneType::EFFECT], true)) { + return []; + } + + $stage = $this->currentCutscene->payload()['stage'] ?? null; + $subjects = []; + + foreach (is_array($stage) && is_array($stage['subjects'] ?? null) ? $stage['subjects'] : [] as $subject) { + $id = is_array($subject) ? trim(strval($subject['id'] ?? '')) : ''; + + if ($id !== '') { + $subjects[$id] ??= $subject; + } + } + + return $subjects; + } + + /** + * The named points of the current stage's subjects, by id, each with the + * subjects that name it. + * + * @return array> + */ + private function getStageAttachments(): array + { + $points = []; + + foreach ($this->getStageSubjects() as $subject => $data) { + foreach (is_array($data['attachments'] ?? null) ? $data['attachments'] : [] as $point) { + $id = is_array($point) ? trim(strval($point['id'] ?? '')) : ''; + + if ($id !== '') { + $points[$id][] = $subject; + } + } + } + + return $points; + } + /** * Returns how each value of a reference kind should be shown, when the * value stored is not what an author recognises. @@ -323,10 +827,96 @@ private function summonCueIds(): array * identity that will actually be written. * * @param string $category The kind of reference. - * @return array Labels keyed by stored value. + * @return array Labels keyed by stored value; PHP converts numeric ids to integer keys. */ public function labelsFor(string $category): array { + if ($category === 'event_markers') { + // Each event read as what it is ("H · Script tracks"), from the map the picker reads. + $map = $this->getSubjectMap(); + return $map === null ? [] : $this->listMapEventMarkers($map->mapId)['labels']; + } + + if ($category === 'map_landmarks') { + $labels = []; + foreach ($this->readSubjectMapLandmarks() as $entry) { + $name = is_array($entry['name'] ?? null) ? ($entry['name']['default'] ?? null) : ($entry['name'] ?? null); + if (is_string($entry['id'] ?? null) && is_string($name) && $name !== '') { + $labels[$entry['id']] = sprintf('%s (%s)', $name, $entry['id']); + } + } + return $labels; + } + + if ($category === 'tilesets') { + return $this->loadTilesetNames(); + } + + if ($category === 'income_schedules') { + $labels = []; + foreach ($this->incomeScheduleNames() as $id => $name) { + $labels[$id] = sprintf('%s (%s)', $name, $id); + } + return $labels; + } + + if ($category === 'battle_arenas') { + return $this->loadArenaNames(); + } + + if ($category === 'field_resources') { + $labels = []; + foreach ($this->loadFieldResourceNames() as $id => $name) { + $labels[(string) $id] = sprintf('%s (%s)', $name, $id); + } + + return $labels; + } + + if ($category === 'dialogue_identities') { + $labels = []; + foreach ($this->listDialogueIdentities() as $identity) { + $labels[$identity['id']] = $identity['label']; + } + + return $labels; + } + + if ($category === 'animation_ids') { + $labels = []; + foreach ($this->animationRecords() as $animation) { + $labels[strval($animation->get('id'))] = sprintf('%s (%s)', strval($animation->get('name')), strval($animation->get('id'))); + } + return $labels; + } + + if ($category === 'animation_roles') { + // A role plays one animation, so the picker says which holds it. + $database = $this->workspace->getRecordDatabase('animations'); + $owners = []; + foreach ($database?->getRecords() ?? [] as $record) { + foreach ((array) $record->get('roles') as $role) { + $owners[(string) $role] ??= $database->getEntryLabel($record); + } + } + $labels = []; + foreach ($this->valuesFor('animation_roles') as $role) { + $labels[$role] = isset($owners[$role]) ? sprintf('%s (on %s)', $role, $owners[$role]) : $role; + } + + return $labels; + } + + if ($category === 'stage_attachments') { + // A point is picked on the image's own subject: the label says which have it. + $labels = []; + foreach ($this->getStageAttachments() as $id => $subjects) { + $labels[$id] = sprintf('%s (on %s)', $id, implode(', ', $subjects)); + } + + return $labels; + } + if ($category === 'elements_or_any') { return ['*' => '* (whichever element it was)']; } @@ -337,6 +927,7 @@ public function labelsFor(string $category): array foreach ($this->workspace->actorDatabase->getActors() as $actor) { $id = $actor->getDefinitionId(); $name = $actor->getName(); + if ($id === '') { continue; } if ($name !== '' && $name !== $id) { $labels[$id] = sprintf('%s (%s)', $name, $id); @@ -346,8 +937,34 @@ public function labelsFor(string $category): array return $labels; } + if ($category === 'map_roles') { + // Each role read by its name, from the catalogue as its page holds it. + $labels = []; + try { + $roles = $this->getMapRoleCatalog(); + foreach ($roles->getIds() as $id) { $labels[$id] = sprintf('%s (%s)', $roles->getName($id), $id); } + } catch (\InvalidArgumentException) { + // An unreadable catalogue offers its ids as they are; validation names why. + } + + return $labels; + } + + if ($category === 'place_types') { + // Each type read by its name, from the catalogue as its page holds it. + $labels = []; + try { + $types = $this->getPlaceTypeCatalog(); + foreach ($types->getIds() as $id) { $labels[$id] = sprintf('%s (%s)', $types->getName($id), $id); } + } catch (\InvalidArgumentException) { + // An unreadable catalogue offers no types; validation names why. + } + + return $labels; + } + if ($category === 'permanent_growth') { - $catalog = PermanentGrowthCatalog::fromProject($this->workspace->projectRoot); + $catalog = $this->workspace->catalogs->loadPermanentGrowth(); $labels = []; foreach ($catalog->ids() as $id) { @@ -363,8 +980,8 @@ public function labelsFor(string $category): array $labels = []; - foreach ($this->inventoryCatalog()->definitions() as $id => $definition) { - $labels[$id] = $this->inventoryCatalog()->describe($id); + foreach ($this->getInventoryCatalog()->definitions() as $id => $definition) { + $labels[$id] = $this->getInventoryCatalog()->describe($id); } return $labels; @@ -375,7 +992,7 @@ public function labelsFor(string $category): array * * @return InventoryCatalog The catalogue. */ - private function inventoryCatalog(): InventoryCatalog + public function getInventoryCatalog(): InventoryCatalog { return $this->inventoryCatalog ??= InventoryCatalog::fromWorkspace($this->workspace); } @@ -386,6 +1003,27 @@ private function inventoryCatalog(): InventoryCatalog * @param string $category The kind of reference. * @return bool True when it is. */ + /** + * The events of a map a map event condition names, each read as what it is + * ("E · Chest"): what a marker is chosen from in both editors. + * + * @return array{values: list, labels: array} + */ + public function listMapEventMarkers(string $mapId): array + { + $index = array_search($mapId, $this->workspace->mapIds, true); + $map = $index === false ? null : ($this->workspace->maps[$index] ?? null); + $events = $map?->getMapDataField(['events']); + $values = $labels = []; + foreach (is_array($events) ? $events : [] as $marker => $event) { + $kind = preg_replace('/(EventTrigger|Trigger)$/', '', basename(str_replace('\\', '/', strval($event['class'] ?? '')))); + $what = $event['data']['scriptId'] ?? $event['data']['cinematicId'] ?? $event['data']['destinationMap'] ?? null; + $values[] = (string) $marker; + $labels[(string) $marker] = trim(sprintf('%s · %s %s', $marker, $kind, is_string($what) ? $what : '')); + } + return ['values' => $values, 'labels' => $labels]; + } + public static function knows(string $category): bool { return in_array($category, self::CATEGORIES, true); @@ -439,9 +1077,10 @@ private function audioValues(string $category): array * * An availability, an acquisition policy and a special property are all * project words -- the runtime imposes no list of them -- so what a - * picker can offer is what the project has already said somewhere. A - * special property is a shape rather than a string, and it is the `type` - * inside it that Optimize weighs. + * picker can offer is what the project has already said somewhere, + * including what a record says by leaving the engine's default in place. + * A special property is a shape rather than a string, and it is the + * `type` inside it that Optimize weighs. * * @param string $field The equipment field. * @return string[] The distinct values, sorted. @@ -458,7 +1097,8 @@ private function equipmentVocabulary(string $field): array } foreach ($database->getRecords() as $record) { - $value = $record->get($field); + $definition = InventoryCatalog::readDefinition($record); + $value = $definition !== null && property_exists($definition, $field) ? $definition->{$field} : null; if ($field === 'specialProperty') { $value = is_array($value) ? ($value['type'] ?? null) : null; @@ -485,10 +1125,17 @@ private function knowledgeRecordTypes(): array { $catalog = $this->knowledgeCatalog(); - return array_values(array_filter( - array_map(strval(...), (array) ($catalog['recordTypes'] ?? [])), - static fn(string $type): bool => trim($type) !== '', - )); + $types = []; + + foreach ((array) ($catalog['recordTypes'] ?? []) as $entry) { + try { + $types[] = KnowledgeRecordType::fromEntry($entry)->id; + } catch (InvalidArgumentException) { + // Validation reports an entry the runtime cannot read. + } + } + + return $types; } /** @@ -552,52 +1199,26 @@ private function knowledgeIds(string $section): array */ private function knowledgeCatalog(): array { - $path = rtrim($this->workspace->projectRoot, '/') . '/assets/Data/knowledge.php'; - - if (! is_file($path)) { - return []; - } - try { - $catalog = (static fn(): mixed => require $path)(); + return $this->workspace->catalogs->readKnowledgeData(); } catch (\Throwable) { return []; } - - return is_array($catalog) ? $catalog : []; } /** - * Returns the elements the project's own element enum declares. - * - * A project defines its elements as a PHP enum under assets/Data/Types - * (the Types category), and the engine matches them by their string - * values. The cases are read from the source, so the list is available - * whether or not the enum is loaded. + * Returns the elements the game knows ({@see ProjectWorkspace::getElementIdentities()}). + * A list the game would refuse offers nothing; validation says why. * - * @return string[] The element values, in declaration order. + * @return string[] The element identities, in authored order. */ private function elementValues(): array { - $directory = rtrim($this->workspace->projectRoot, '/') . '/assets/Data/Types'; - - if (! is_dir($directory)) { + try { + return $this->workspace->getElementIdentities(); + } catch (InvalidArgumentException) { return []; } - - foreach (glob($directory . '/*.php') ?: [] as $path) { - $source = (string) file_get_contents($path); - - if (preg_match('/^\s*enum\s+\w*Element\w*\s*:/m', $source) !== 1) { - continue; - } - - preg_match_all("/^\\s*case\\s+\\w+\\s*=\\s*'([^']+)'\\s*;/m", $source, $matches); - - return $matches[1]; - } - - return []; } /** @@ -628,6 +1249,283 @@ private function fileValues(string $relativeDirectory): array return $names; } + /** + * The name of every tileset in assets/Data/Tilesets that loads, by id. One + * that cannot load is not offered; validation reports it. + * + * @return array + */ + /** + * The encounter terrains every terrain kind of the project's tilesets + * meets, each once, in tileset then kind order. A tileset that cannot be + * read offers none; project validation reports why. + * + * @return list + */ + private function loadEncounterTerrains(): array + { + $catalogs = $this->workspace->catalogs; + $terrains = []; + foreach ($catalogs->listTilesetIds() as $id) { + try { + foreach ($catalogs->loadTileset($id)->kinds as $kind) { + if ($kind->encounters !== null) { + $terrains[$kind->encounters] = $kind->encounters; + } + } + } catch (\Throwable) { + continue; + } + } + + return array_values($terrains); + } + + private function loadTilesetNames(): array + { + $catalogs = $this->workspace->catalogs; + $names = []; + foreach ($catalogs->listTilesetIds() as $id) { + try { + $names[$id] = $catalogs->loadTileset($id)->name; + } catch (\Throwable) { + continue; + } + } + + return $names; + } + + /** A project map by id, unsaved edits included, as the workspace holds it. */ + public function findMap(string $mapId): ?ProjectMap + { + $index = array_search($mapId, $this->workspace->mapIds, true); + return is_int($index) ? ($this->workspace->maps[$index] ?? null) : null; + } + + /** @return list Every project map, unsaved edits included. */ + public function listMaps(): array + { + return array_values($this->workspace->maps); + } + + /** + * The project's place categories as the Engine reads them: its generic ones and those the project declares, + * as its Place Categories page holds them. + * + * @throws \InvalidArgumentException When the project's catalogue cannot be read. + */ + /** + * The project's dialogue catalogue as the Engine makes it, from its Dialogue Portraits and Speaker Names pages + * with their unsaved edits, or from the file when those pages are not open. + * + * @throws \InvalidArgumentException|\RuntimeException When the Engine would refuse the catalogue. + */ + public function getDialoguePresentationCatalog(): DialoguePresentationCatalog + { + return $this->workspace->catalogs->loadDialoguePresentation(); + } + + /** + * The identities and speaker names of the project's dialogue catalogue as its Dialogue Portraits and Speaker + * Names pages hold them, unsaved edits included, in the catalogue's own shape; null when those pages are not + * open, where the file is the Engine's to read. + * + * @return array|null + */ + public function getDialogueCatalogPayload(): ?array + { + $portraits = $this->workspace->getRecordDatabase(DialoguePortraitFields::CATEGORY); + $speakers = $this->workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY); + if ($portraits?->schema->projection === null || $speakers?->schema->projection === null) { + return null; + } + $whole = $this->workspace->catalogs->readData(DialoguePresentationCatalog::FILE)['payload']; + + return is_array($whole) ? $whole : ['schema' => DialogueCatalogFile::SCHEMA]; + } + + /** + * The catalogue's identities, actors first, each with how a picker shows it. + * + * @return list + */ + private function listDialogueIdentities(): array + { + try { + $catalogue = $this->getDialoguePresentationCatalog(); + } catch (Throwable) { + // Validation reports a catalogue that cannot be read; nothing can be chosen from it. + return []; + } + $identities = []; + foreach (array_keys($catalogue->actors) as $id) { + $identities[] = ['id' => (string) $id, 'label' => sprintf('%s (actor)', $id)]; + } + foreach (array_keys($catalogue->resources) as $id) { + $identities[] = ['id' => (string) $id, 'label' => sprintf('%s (character)', $id)]; + } + + return $identities; + } + + /** + * The emotions one line may show, as its speaker's catalogue identity names them: who speaks, the identity + * whose artwork shows (null when the speaker has none), the emotions offered, and why none beyond Neutral are + * offered when the catalogue cannot be read. + * + * @return array{speaker: string, identity: ?string, emotions: list, issue: ?string} + */ + public function describeDialogueEmotions(DialogueLineSpeaker $rule, array $line, array $owner = []): array + { + try { + $catalogue = $this->getDialoguePresentationCatalog(); + } catch (Throwable $error) { + return DialogueEmotionFields::describeUnreadableCatalogue($rule, $line, $owner, $error); + } + + return DialogueEmotionFields::describeChoices($rule, $line, $owner, $catalogue); + } + + /** + * The project's map roles as the Map Roles page holds them, unsaved edits included. + * + * @throws \InvalidArgumentException With the Engine's reason when they are refused. + */ + public function getMapRoleCatalog(): \Ichiloto\Engine\Field\MapRoleCatalog + { + $database = $this->workspace->getRecordDatabase(\Ichiloto\Editor\Field\MapRoleFields::CATEGORY); + if (($reason = $database?->getReadOnlyReason()) !== null) { + throw new \InvalidArgumentException($reason); + } + + return $this->workspace->catalogs->loadMapRoles(); + } + + /** + * The project's place types as the Place Types page holds them, unsaved edits included. + * + * @throws \InvalidArgumentException With the Engine's reason when they are refused. + */ + public function getPlaceTypeCatalog(): \Ichiloto\Engine\Field\PlaceTypeCatalog + { + $database = $this->workspace->getRecordDatabase(\Ichiloto\Editor\Field\PlaceTypeFields::CATEGORY); + if (($reason = $database?->getReadOnlyReason()) !== null) { + throw new \InvalidArgumentException($reason); + } + + return $this->workspace->catalogs->loadPlaceTypes(); + } + + /** + * The project's regions as the Regions page holds them, unsaved edits included. + * + * @throws \InvalidArgumentException With the Engine's reason when they are refused. + */ + public function getRegionCatalog(): \Ichiloto\Engine\Field\RegionCatalog + { + $database = $this->workspace->getRecordDatabase(\Ichiloto\Editor\Field\RegionFields::CATEGORY); + if (($reason = $database?->getReadOnlyReason()) !== null) { + throw new \InvalidArgumentException($reason); + } + + return $this->workspace->catalogs->loadRegions(); + } + + public function getMapCategoryCatalog(): \Ichiloto\Engine\Field\MapCategoryCatalog + { + // The project's own categories as the Place Categories page holds them, unsaved edits included. + $database = $this->workspace->getRecordDatabase(\Ichiloto\Editor\Field\MapCategoryFields::CATEGORY); + if (($reason = $database?->getReadOnlyReason()) !== null) { + throw new \InvalidArgumentException($reason); + } + + return $this->workspace->catalogs->loadMapCategories(); + } + + /** + * Returns the arenas the project declares, key => display name, in + * authored order: those its battle presentation code declares, then + * those bound as data (unsaved edits included). None for a project + * without either, or whose arenas cannot be read, which validation + * reports. + * + * @return array + */ + private function loadArenaNames(): array + { + try { + // The code never changes in a session; the data is read as it is now. + $this->arenaNames ??= BattlePresentationCatalog::loadCode(rtrim($this->workspace->projectRoot, '/') . '/assets')?->getArenaChoices() ?? []; + $data = array_map(static fn(array $arena): string => $arena['name'], $this->workspace->catalogs->loadArenaBindings()?->arenas ?? []); + + return [...$this->arenaNames, ...$data]; + } catch (\Throwable) { + return []; + } + } + + /** Returns asset-root-relative PNG choices without following paths outside the asset root. */ + public static function getPngAssets(string $projectRoot): array + { + $root = realpath(rtrim($projectRoot, '/') . '/assets'); + if ($root === false) { + return []; + } + $paths = []; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $resolved = $file->getRealPath(); + if ($file->isFile() && strtolower($file->getExtension()) === 'png' && $resolved !== false + && str_starts_with($resolved, $root . DIRECTORY_SEPARATOR)) { + $paths[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($root) + 1)); + } + } + sort($paths); + return $paths; + } + + /** + * Every node of the mastery tree, by id, as the regions and branches are + * being edited: trunk nodes region by region, then each branch's nodes. + * + * @return list + */ + private function masteryNodeIds(): array + { + $ids = []; + foreach ([MasteryFields::REGIONS, MasteryFields::BRANCHES] as $category) { + foreach ($this->workspace->getRecordDatabase($category)?->getRecords() ?? [] as $record) { + foreach ($record->getSubList('nodes') as $node) { + $id = trim(strval($node['id'] ?? '')); + if ($id !== '') { + $ids[] = $id; + } + } + } + } + + return array_values(array_unique($ids)); + } + + /** + * The project's income schedules as the Income page holds them, unsaved + * edits included: name by stable id. + * + * @return array + */ + private function incomeScheduleNames(): array + { + $names = []; + foreach ($this->workspace->getRecordDatabase('income_schedules')?->getRecords() ?? [] as $record) { + $id = trim(strval($record->get('id'))); + if ($id !== '') { + $names[$id] = trim(strval($record->get('name'))) ?: $id; + } + } + return $names; + } + /** * Returns the values a schema-driven category defines. * diff --git a/src/Database/ReferencePicker.php b/src/Database/ReferencePicker.php index eb2abbe6..d5fe250f 100644 --- a/src/Database/ReferencePicker.php +++ b/src/Database/ReferencePicker.php @@ -20,7 +20,7 @@ final class ReferencePicker */ private array $values = []; /** - * @var array How to show a value, when what is stored and + * @var array How to show a value, when what is stored and * what an author recognises are not the same string. An inventory * reference stores a stable id and reads as its display name. */ @@ -46,7 +46,7 @@ final class ReferencePicker * @param string $category The kind of reference. * @param string[] $values What it may be set to. * @param string $current What it is set to now, which is where the cursor starts. - * @param array $labels How to show a value, keyed by the value stored. + * @param array $labels How to show a value, keyed by the value stored. * @return bool True when there was something to choose from. */ public function open(string $fieldId, string $label, string $category, array $values, string $current = '', array $labels = []): bool diff --git a/src/Database/RelativePositionFields.php b/src/Database/RelativePositionFields.php new file mode 100644 index 00000000..d501564f --- /dev/null +++ b/src/Database/RelativePositionFields.php @@ -0,0 +1,187 @@ + ['map_npcs', 'NPC'], + 'world_object' => ['map_world_objects', 'World Object'], + 'staged_actor' => ['cinematic_cast', 'Staged Actor'], + 'party_actor' => ['actors', 'Party Actor'], + 'marker' => ['event_markers', 'Event Marker'], + ]; + + /** Whether a point declares any part of a relative position. */ + public static function isRelative(array $entry): bool + { + return array_intersect(CinematicCommandSchema::RELATIVE_POSITION_FIELDS, array_keys($entry)) !== []; + } + + /** + * The subject kinds an author may choose here. Staged actors exist only on a cinematic's stage, and world + * objects are authored in the graphical editor alone, as every other subject picker offers them. + * + * @return list + */ + public static function getSubjectKinds(bool $cinematic, bool $graphical): array + { + $kinds = CinematicCommandSchema::export()['relativePosition']['subjectKinds']; + return array_values(array_filter($kinds, static fn(string $kind): bool + => ($cinematic || $kind !== 'staged_actor') && ($graphical || $kind !== 'world_object'))); + } + + /** + * The fields of a point: its absolute fields and a subject choice while it is absolute, or the subject, its id + * and the offset once it is relative. Independent coordinates and an offset are never offered together. A + * staged visual relative to a world object may sit on one of its attachments instead, which owns its cell and + * point, so it then takes no offset. + * + * @param list $absoluteFields + * @param bool $attachable Whether the point places a staged visual, the one thing an attachment holds. + * @return list + */ + public static function getFields(array $entry, array $absoluteFields, bool $cinematic, bool $graphical, bool $attachable = false): array + { + $kinds = self::getSubjectKinds($cinematic, $graphical); + $kind = new RecordField(self::KIND_FIELD, 'Relative To', options: ['', ...$kinds], removeWhenEmpty: true, + displayDefault: '(map cell)'); + if (! self::isRelative($entry)) { + return [$kind, ...$absoluteFields]; + } + $selected = is_array($entry['at'] ?? null) ? strval($entry['at']['kind'] ?? '') : ''; + $fields = [$kind]; + $id = self::getSubjectIdField(self::ID_FIELD, $selected); + if ($id !== null) { + $fields[] = $id; + } + if ($attachable && $selected === 'world_object') { + $fields[] = RecordField::entryChoice(self::ATTACHMENT_FIELD, 'Attachment', 'world_object_attachments', '(object anchor)'); + if (is_string($entry['at']['attachment'] ?? null)) { + return $fields; + } + } + return [ + ...$fields, + new RecordField(self::OFFSET_X_FIELD, 'Offset X', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + new RecordField(self::OFFSET_Y_FIELD, 'Offset Y', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + ]; + } + + /** + * The stable id a subject reference of this kind is chosen with, or null when the kind names no id. The + * leading party actor needs no id; every other named subject does. + * + * @param string $labelPrefix Tells the row apart from another subject's id in the same entry. + */ + public static function getSubjectIdField(string $key, string $kind, string $labelPrefix = ''): ?RecordField + { + if (! isset(self::ID_REFERENCES[$kind])) { + return null; + } + [$reference, $label] = self::ID_REFERENCES[$kind]; + $label = $labelPrefix . $label; + return $kind === 'party_actor' + ? RecordField::reference($key, $label, $reference, allowsNone: true, noneLabel: '(leading party actor)') + : RecordField::reference($key, $label, $reference); + } + + /** Whether a subject reference of this kind carries a stable id. */ + public static function namesSubjectId(string $kind): bool + { + return isset(self::ID_REFERENCES[$kind]); + } + + /** + * Keeps a point either absolute or relative after one field edit, then has the consumer's Engine validator judge + * it. Choosing a subject drops independent coordinates, and the id when the subject is the player; clearing it + * drops the offset with it. A subject still waiting for its id is a draft the owner's save boundary completes. + * + * @param Closure(array): void $validate The consumer's Engine validator. + * @return array + * @throws InvalidArgumentException When the edit leaves a point the Engine refuses. + */ + public static function prepareEdit(array $entry, string $field, Closure $validate): array + { + if (! in_array($field, [self::KIND_FIELD, self::ID_FIELD, self::OFFSET_X_FIELD, self::OFFSET_Y_FIELD, self::ATTACHMENT_FIELD], true)) { + return $entry; + } + if (in_array($field, [self::KIND_FIELD, self::ID_FIELD], true) && is_array($entry['at'] ?? null)) { + // An attachment belongs to the object it was chosen on. + unset($entry['at']['attachment']); + } + if ($field === self::ATTACHMENT_FIELD && is_string($entry['at']['attachment'] ?? null)) { + unset($entry['offset']); + } + if ($field === self::KIND_FIELD) { + $kind = is_array($entry['at'] ?? null) ? ($entry['at']['kind'] ?? null) : null; + if ($kind === null) { + unset($entry['at'], $entry['offset']); + } else { + unset($entry['x'], $entry['y']); + if (! isset(self::ID_REFERENCES[$kind])) { + unset($entry['at']['id']); + } + } + } + if (in_array($field, [self::OFFSET_X_FIELD, self::OFFSET_Y_FIELD], true) && ! is_array($entry['at'] ?? null)) { + throw new InvalidArgumentException('Choose what this position is relative to before giving it an offset.'); + } + if (($entry['offset'] ?? null) === []) { + // Omitted axes are zero, and the Engine refuses an empty offset. + unset($entry['offset']); + } + if (! self::isRelative($entry)) { + return $entry; + } + try { + $validate($entry); + } catch (InvalidArgumentException $error) { + if (array_key_exists('id', (array) $entry['at'])) { + throw $error; + } + } + return $entry; + } + + /** How a point reads where it is listed: its subject and offset, in cells. */ + public static function describePosition(array $entry): string + { + $at = is_array($entry['at'] ?? null) ? $entry['at'] : []; + $kind = strval($at['kind'] ?? ''); + $subject = match (true) { + $kind === 'player' => 'the player', + $kind === 'party_actor' && ! isset($at['id']) => 'the leading party actor', + isset(self::ID_REFERENCES[$kind]) => sprintf('%s %s', self::ID_REFERENCES[$kind][1], strval($at['id'] ?? '(no id)')), + default => '(no subject)', + }; + if (is_string($at['attachment'] ?? null)) { + return sprintf('on %s of %s', $at['attachment'], $subject); + } + $offset = is_array($entry['offset'] ?? null) ? $entry['offset'] : []; + $x = intval($offset['x'] ?? 0); + $y = intval($offset['y'] ?? 0); + return $x === 0 && $y === 0 ? 'at ' . $subject : sprintf('%+d, %+d from %s', $x, $y, $subject); + } +} diff --git a/src/Database/SharedFileTransaction.php b/src/Database/SharedFileTransaction.php index 7a00c2b3..4dc82283 100644 --- a/src/Database/SharedFileTransaction.php +++ b/src/Database/SharedFileTransaction.php @@ -4,7 +4,9 @@ namespace Ichiloto\Editor\Database; -use Ichiloto\Editor\IO\AtomicFile; +use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; use RuntimeException; /** @@ -56,67 +58,164 @@ public static function groupByPath(array $databases): array /** * Writes one file for every dirty category that shares it. * + * @param array $databases The categories sharing one file. + * @return bool True when the categories were saved. + * @throws SourceIdentityConflict When an entry cannot be addressed with certainty. + * @throws RuntimeException When the write is refused or fails. + */ + public static function commit(array $databases): bool + { + $prepared = self::prepare($databases); + + if ($prepared === null) { + return false; + } + + SaveSet::commitOne(dirname(self::listDirty($databases)[0]->backingFilePath()), $prepared); + + return true; + } + + /** + * Works out exactly what {@see commit()} would write, refusing what it + * would refuse, and writes nothing: an edit the file's own source cannot + * take is refused when it is made, not when it is saved. + * + * @param array $databases The categories sharing one file. + * @throws SourceIdentityConflict When an entry cannot be addressed with certainty. + * @throws RuntimeException When the write would be refused. + */ + public static function preview(array $databases): void + { + self::compose($databases, forSave: false); + } + + /** + * Works out the one write for every dirty category sharing a file, for + * {@see SaveSet} to install alone or with the rest of a change set. No + * category's baseline moves until the file is installed. Null when no + * category sharing it is dirty. + * * The file is read once. If every dirty category can say what it wants * as edits to the author's own source, those edits are composed against - * that one reading and written in one replacement. Otherwise every dirty - * category folds its records into that one reading's payload, in turn, - * and the result is written in one replacement -- unless a category's - * entries are constructor calls whose list has changed, which is - * refused with the reason rather than regenerated. Either way there is - * one payload, one write, and no baseline moves until it has succeeded. + * that one reading. Otherwise every dirty category folds its records into + * that one reading's payload, in turn -- unless a category's entries are + * constructor calls whose list has changed, which is refused with the + * reason rather than regenerated. Either way there is one payload and + * one write. * * @param array $databases The categories sharing one file. - * @return bool True when the file was written. * @throws SourceIdentityConflict When an entry cannot be addressed with certainty. - * @throws RuntimeException When the write is refused or fails. + * @throws RuntimeException When the write would be refused. */ - public static function commit(array $databases): bool + public static function prepare(array $databases): ?PreparedSave { - $dirty = array_values(array_filter( + return self::compose($databases, forSave: true); + } + + /** + * @param array $databases + * @return list + */ + private static function listDirty(array $databases): array + { + return array_values(array_filter( $databases, static fn(object $database): bool => $database instanceof ProjectRecordDatabase && $database->sharesBackingFile() && $database->isEditable() && $database->isDirty(), )); + } + + /** + * @param array $databases + * @param bool $forSave Whether the result is to be saved, checked as a save is, or only worked out. + */ + private static function compose(array $databases, bool $forSave): ?PreparedSave + { + $dirty = self::listDirty($databases); if ($dirty === []) { - return false; + return null; + } + + foreach ($dirty as $database) { + $database->assertUnchangedSource(); } $first = $dirty[0]; $path = $first->backingFilePath(); $file = PhpDataFile::load($path, $first->projectRoot()); + $label = implode(', ', array_map(static fn(ProjectRecordDatabase $database): string => $database->schema->key, $dirty)); + $adopt = static fn() => self::adopt($dirty); + + if ($forSave) { + self::checkSave($databases, $dirty, $file); + } // One snapshot of the source, and every category's wants resolved // against it before a byte is written. - if (self::commitToSource($dirty, $file)) { - self::adopt($dirty); + $composed = self::composeSource($dirty, $file); - return true; - } + if ($composed === null) { + foreach ($dirty as $database) { + $refusal = $database->regenerationRefusal($file); - foreach ($dirty as $database) { - $refusal = $database->regenerationRefusal($file); + if ($refusal !== null) { + throw new RuntimeException($refusal); + } + } + + // One authoritative payload: the file as it is now, with every dirty + // category's part folded in, in turn, and written once. No category + // describes the whole file from its own older reading of it. + $payload = is_array($file->payload) ? $file->payload : []; - if ($refusal !== null) { - throw new RuntimeException($refusal); + foreach ($dirty as $database) { + $payload = $database->foldInto($payload); } + + $composed = $file->composeContents($payload, checkDisk: $forSave); } - // One authoritative payload: the file as it is now, with every dirty - // category's part folded in, in turn, and written once. No category - // describes the whole file from its own older reading of it. - $payload = is_array($file->payload) ? $file->payload : []; + if ($composed === '') { + // Nothing to write, but the categories are still adopted so a + // save that found no work leaves them clean. + return PreparedSave::adoptOnly($label, $adopt); + } + + return new PreparedSave($label, static function (FileSetTransaction $transaction) use ($file, $path, $composed): void { + $file->expectUnchanged($transaction); + $transaction->write($path, $composed); + }, adopt: $adopt); + } + /** + * Refuses a save the runtime could not read: the file as every dirty + * category would leave it is put to each sharing category's own check. + * + * @param array $databases The categories sharing the file. + * @param ProjectRecordDatabase[] $dirty The dirty ones. + * @throws RecordRefusal With the first check's reason. + */ + private static function checkSave(array $databases, array $dirty, PhpDataFile $file): void + { + $checks = array_filter($databases, static fn(object $database): bool => $database instanceof ProjectRecordDatabase + && $database->schema->saveCheck !== null); + if ($checks === []) { + return; + } + $payload = is_array($file->payload) ? $file->payload : []; foreach ($dirty as $database) { $payload = $database->foldInto($payload); } - - $file->save($payload); - self::adopt($dirty); - - return true; + foreach ($checks as $database) { + $reason = ($database->schema->saveCheck)($payload, (string) $database->projectRoot()); + if ($reason !== null) { + throw new RecordRefusal($reason); + } + } } /** @@ -125,12 +224,13 @@ public static function commit(array $databases): bool * * @param ProjectRecordDatabase[] $dirty The dirty categories. * @param PhpDataFile $file The file, read once. - * @return bool True when the file was written this way. + * @return string|null The file's new contents, an empty string when there is nothing to write, or null when + * a category cannot say what it wants in the source's terms. */ - private static function commitToSource(array $dirty, PhpDataFile $file): bool + private static function composeSource(array $dirty, PhpDataFile $file): ?string { if (! is_file($file->path)) { - return false; + return null; } $document = PhpSourceDocument::parse((string) file_get_contents($file->path)); @@ -142,7 +242,7 @@ private static function commitToSource(array $dirty, PhpDataFile $file): bool $plan = $database->sourcePlan($document, $file); if ($plan === null) { - return false; + return null; } $patches = [...$patches, ...$plan['patches']]; @@ -151,9 +251,7 @@ private static function commitToSource(array $dirty, PhpDataFile $file): bool } if ($patches === [] && $removals === [] && $insertions === []) { - // Nothing to write, but the categories are still adopted so a - // save that found no work leaves them clean. - return true; + return ''; } // Values first, while every position still means what the snapshot @@ -198,9 +296,7 @@ private static function commitToSource(array $dirty, PhpDataFile $file): bool $document = $document->withNewEntry($insertion['class'], $insertion['arguments']); } - AtomicFile::write($file->path, $document->source); - - return true; + return $document->source; } /** diff --git a/src/Database/SkillReferences.php b/src/Database/SkillReferences.php new file mode 100644 index 00000000..fc57187c --- /dev/null +++ b/src/Database/SkillReferences.php @@ -0,0 +1,264 @@ +, skill: string, inList: bool}> Each reference: its path, the skill + * it names, and whether it is a member of a list of skill names (an actor's learned list) rather than a field. + */ + public static function findReferences(mixed $payload): array + { + $found = []; + self::walk($payload, [], $found); + + return $found; + } + + /** + * Describes what names a skill in the given records, in words. + * + * @param iterable $records Each record by a label naming it. + * @return list + */ + public static function describe(iterable $records, string $skill): array + { + $uses = []; + foreach ($records as $label => $payload) { + foreach (self::findReferences($payload) as $reference) { + if ($reference['skill'] === $skill) { + $uses[] = sprintf('%s (%s)', $label, implode('.', $reference['path'])); + } + } + } + + return $uses; + } + + /** + * Plans retiring a skill. With a replacement, every reference moves onto + * it, a list that already holds the replacement drops the old entry + * instead of holding the skill twice, and saves that knew the old skill + * read the replacement. Without one, every reference goes: a list member, + * the list entry it belongs to (a class grant, a learnable, an enemy + * action), a counter, or an attack skill (the built-in attack takes over); + * saves that knew the skill drop it, as the Engine drops any skill it no + * longer has. + * + * @param bool $isMagic Whether the skills are spells (saves keep spells and abilities apart). + * @return SourceSetPlan|null The plan, or null when nothing names the skill and saves need nothing. + * @throws RuntimeException When a reference is not a literal the plan can rewrite, or saves already send the skill elsewhere. + */ + public static function planRetirement(string $projectRoot, string $projectId, string $skill, ?string $replacement, bool $isMagic): ?SourceSetPlan + { + return ProjectDirectoryContext::run($projectRoot, static function (string $root) use ($projectId, $skill, $replacement, $isMagic): ?SourceSetPlan { + $paths = self::findSourcePaths($root); + $payloads = array_map(PhpDataFile::getComparableValue(...), PhpDataFile::evaluateIsolatedFiles($paths, $root)); + $watched = $originals = $proposals = $before = $after = []; + + foreach ($paths as $index => $path) { + $source = (string) file_get_contents($path); + $watched[$path] = $source; + $payload = $payloads[$index] ?? null; + [$edits, $moved] = self::planFile($source, $payload, $skill, $replacement); + if ($edits === []) { + continue; + } + $originals[$path] = $source; + $proposals[$path] = PhpArraySourceDocument::parse($source)->withEdits($edits)->source; + $before[$path] = $payload; + $after[$path] = $moved; + } + + $subject = $replacement === null ? sprintf('retirement of %s', $skill) : sprintf('retirement of %s into %s', $skill, $replacement); + $plans = []; + if ($proposals !== []) { + $plans[] = new SourceSetPlan( + $root, + $watched, + $originals, + $proposals, + $before, + $after, + static fn(): array => self::findSourcePaths($root), + static fn(string $path, string $staged): mixed => PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + $subject, + 'Project class, actor, enemy, skill and state files', + ); + } + $alias = $replacement === null ? null : SaveManifestAlias::plan($root, $projectId, + $isMagic ? ContentReferenceCategory::SPELL : ContentReferenceCategory::ABILITY, $skill, $replacement); + if ($alias !== null) { + $plans[] = $alias; + } + + return match (count($plans)) { + 0 => null, + 1 => $plans[0], + default => SourceSetPlan::combine($subject, 'Project skill references and save compatibility manifest', $plans), + }; + }); + } + + /** + * Plans one file's part of a retirement: the source edits, and the value the file reads back as afterwards. + * + * @return array{0: list, 1: mixed} + */ + private static function planFile(string $source, mixed $payload, string $skill, ?string $replacement): array + { + $references = self::findReferences($payload); + $mine = array_values(array_filter($references, static fn(array $reference): bool => $reference['skill'] === $skill)); + if ($mine === []) { + return [[], $payload]; + } + $document = PhpArraySourceDocument::parse($source); + $edits = []; + $removals = []; + foreach ($mine as $reference) { + $path = $reference['path']; + $removal = self::findRemovalPath($reference); + $list = array_slice($removal, 0, -1); + $position = end($removal); + $holdsReplacement = $replacement !== null && is_int($position) && array_any($references, static fn(array $other): bool => + $other['skill'] === $replacement && count($other['path']) > count($list) + && array_slice($other['path'], 0, count($list)) === $list && $other['path'][count($list)] !== $position); + if ($replacement === null || $holdsReplacement) { + $removals[implode("\0", $removal)] = $removal; + continue; + } + $edits[] = $document->replaceValueEdit($path, var_export($replacement, true)); + $payload = self::withValue($payload, $path, $replacement); + } + // Within a list, later members go first, so each removal leaves the positions the others name in place. + $ordered = array_values($removals); + usort($ordered, static fn(array $a, array $b): int => + [implode("\0", array_slice($a, 0, -1)), end($b)] <=> [implode("\0", array_slice($b, 0, -1)), end($a)]); + foreach ($ordered as $member) { + $edits[] = $document->removeEntryEdit($member); + $payload = self::withoutMember($payload, $member); + } + + return [$edits, $payload]; + } + + /** + * What goes when a reference goes: the list member itself, the list entry + * a `skill` field belongs to, the whole counter a counter skill names, or + * the attack skill field. + * + * @param array{path: list, skill: string, inList: bool} $reference + * @return list + */ + private static function findRemovalPath(array $reference): array + { + $path = $reference['path']; + if ($reference['inList'] || end($path) === 'attackSkill') { + return $path; + } + $owner = array_slice($path, 0, -1); + + return is_int(end($owner)) || end($owner) === 'counterAttack' ? $owner : $path; + } + /** @param list $path */ + private static function withValue(mixed $value, array $path, string $replacement): mixed + { + if ($path === []) { + return $replacement; + } + $key = array_shift($path); + $value[$key] = self::withValue($value[$key], $path, $replacement); + + return $value; + } + + /** @param list $path A list member's path; the list closes up behind it. */ + private static function withoutMember(mixed $value, array $path): mixed + { + $key = array_shift($path); + if ($path === []) { + $wasList = array_is_list($value); + unset($value[$key]); + + return $wasList ? array_values($value) : $value; + } + $value[$key] = self::withoutMember($value[$key], $path); + + return $value; + } + + /** @param list, skill: string, inList: bool}> $found */ + private static function walk(mixed $value, array $path, array &$found): void + { + if (! is_array($value)) { + return; + } + foreach ($value as $key => $child) { + $childPath = [...$path, $key]; + if (($key === 'skill' || $key === 'attackSkill') && is_string($child) && trim($child) !== '') { + $found[] = ['path' => $childPath, 'skill' => $child, 'inList' => false]; + continue; + } + if ($key === 'learned' && in_array(end($path), ['abilities', 'magic'], true) && is_array($child)) { + foreach ($child as $index => $name) { + if (is_string($name) && trim($name) !== '') { + $found[] = ['path' => [...$childPath, $index], 'skill' => $name, 'inList' => true]; + } + } + continue; + } + self::walk($child, $childPath, $found); + } + } + + /** @return list Every file that may name a skill, as it is on disk now. */ + private static function findSourcePaths(string $root): array + { + $paths = []; + foreach (self::SINGLE_FILES as $relative) { + if (is_file($root . '/' . $relative)) { + $paths[] = $root . '/' . $relative; + } + } + foreach (self::FOLDERS as $relative) { + foreach (glob($root . '/' . $relative . '/*.php') ?: [] as $path) { + $paths[] = $path; + } + } + sort($paths, SORT_STRING); + + return $paths; + } +} diff --git a/src/Database/StagedActorFields.php b/src/Database/StagedActorFields.php new file mode 100644 index 00000000..f492d712 --- /dev/null +++ b/src/Database/StagedActorFields.php @@ -0,0 +1,179 @@ +> $fields + * @return list> + */ + public static function describeFields(array $entry, array $fields, ?string $assetRoot): array + { + $wrapped = ($entry['type'] ?? null) === 'stage_actor' && is_array($entry['actor'] ?? null); + $actor = $wrapped ? $entry['actor'] : $entry; + if (! self::isStagedActorEntry($entry)) { + return $fields; + } + $sprites = $actor['sprites2d'] ?? null; + if (! is_array($sprites) || ! array_key_exists('sheet', $sprites)) { + return $fields; + } + $key = ($wrapped ? 'actor.' : '') . 'sprites2d.sheet'; + foreach ($fields as &$field) { + if (($field['sourceKey'] ?? null) === $key) { + $field['imagePreview'] = CharacterSheetPreview::describe($sprites, $assetRoot); + } + } + unset($field); + + return $fields; + } + + /** The runtime accepts inline stage commands and an explicit actor envelope. */ + public static function getCommandFields(array $command): array + { + if (! is_array($command['actor'] ?? null)) { + return CutsceneSchemas::stagedActorFields(true, $command); + } + + return array_map(static fn(RecordField $field): RecordField => new RecordField(...array_replace( + get_object_vars($field), ['key' => 'actor.' . $field->key], + )), CutsceneSchemas::stagedActorFields(true, $command['actor'])); + } + + /** @return RecordField[] */ + public static function getFields(array $entry): array + { + if (isset($entry['appearance'])) { + // The worn NPC look owns the sheet; such an actor never binds a real subject. + return [RecordField::boolean('replace', 'Replace Existing Visual')]; + } + $contract = CinematicCommandSchema::export(); + $fields = [ + new RecordField('subject.kind', 'Bind Visual To', options: ['', ...explode('|', $contract['stagedActorBinding']['subject']['kind'])], + removeWhenEmpty: true, displayDefault: '(independent staged actor)'), + RecordField::boolean('replace', 'Replace Existing Visual'), + ]; + if (($entry['subject']['kind'] ?? null) === 'npc') { + $fields[] = RecordField::reference('subject.id', 'Bound NPC', 'map_npcs'); + } + if (($entry['subject']['kind'] ?? null) === 'world_object') { + $fields[] = RecordField::reference('subject.id', 'Bound World Object', 'map_world_objects'); + } + return [...FieldSpriteFields::getFields($entry, pickSheetIndex: false), ...$fields]; + } + + public static function getSuppressionList(array $entry): ?RecordSubList + { + $wrapped = is_array($entry['actor'] ?? null); + $entry = $wrapped ? $entry['actor'] : $entry; + if (! isset($entry['subject'])) { + return null; + } + $kinds = explode('|', CinematicCommandSchema::export()['stagedActorBinding']['subject']['kind']); + + return new RecordSubList( + key: $wrapped ? 'actor.suppress' : 'suppress', prefix: 'suppress', singular: 'suppressed subject', + fields: [new RecordField('kind', 'Subject Kind', options: $kinds)], + blank: ['kind' => 'player'], + variants: ['npc' => [RecordField::reference('id', 'NPC', 'map_npcs')], + 'world_object' => [RecordField::reference('id', 'World Object', 'map_world_objects')]], + variantKey: 'kind', removeWhenEmpty: true, + ); + } + + /** + * Keeps an independent staged actor at either a map cell or a cell relative to a map subject, in both editors, + * as the Engine's stage binding validator accepts it. + */ + public static function preparePositionEdit(array $entry, string $field): array + { + if (($entry['type'] ?? null) === 'stage_actor' && is_array($entry['actor'] ?? null) && str_starts_with($field, 'actor.')) { + $actor = self::preparePositionEdit(['type' => 'stage_actor', ...$entry['actor']], substr($field, strlen('actor.'))); + if (! array_key_exists('type', $entry['actor'])) { + unset($actor['type']); + } + $entry['actor'] = $actor; + + return $entry; + } + if (! self::isStagedActorEntry($entry)) { + return $entry; + } + return RelativePositionFields::prepareEdit(EndFacingFields::prepareEdit($entry, $field), $field, + static function (array $actor): void { + CinematicStageManager::validateBinding($actor); + }); + } + + /** Wearing a map NPC's look clears the actor's own look, which the Engine refuses beside it; both editors share this. */ + public static function prepareAppearanceEdit(array $entry, string $field): array + { + if (($entry['type'] ?? null) === 'stage_actor' && is_array($entry['actor'] ?? null) && str_starts_with($field, 'actor.')) { + $actor = self::prepareAppearanceEdit(['type' => 'stage_actor', ...$entry['actor']], substr($field, strlen('actor.'))); + if (! array_key_exists('type', $entry['actor'])) { + unset($actor['type']); + } + $entry['actor'] = $actor; + + return $entry; + } + if ($field !== 'appearance.kind' || ! self::isStagedActorEntry($entry)) { + return $entry; + } + if (! isset($entry['appearance']['kind'])) { + unset($entry['appearance']); + } else { + unset($entry['sprite'], $entry['sprites'], $entry['sprites2d'], $entry['asset'], $entry['subject'], $entry['suppress']); + } + + return $entry; + } + + /** Changing a representation clears only the fields owned by the old representation. */ + public static function prepareEdit(array $entry, string $field): array + { + if (($entry['type'] ?? null) === 'stage_actor' && is_array($entry['actor'] ?? null) && str_starts_with($field, 'actor.')) { + $actor = self::prepareEdit(['type' => 'stage_actor', ...$entry['actor']], substr($field, strlen('actor.'))); + if (! array_key_exists('type', $entry['actor'])) { + unset($actor['type']); + } + $entry['actor'] = $actor; + + return $entry; + } + if (! self::isStagedActorEntry($entry)) { + return $entry; + } + if ($field === 'subject.kind') { + $kind = $entry['subject']['kind'] ?? null; + if ($kind === null) { + unset($entry['subject'], $entry['suppress']); + } else { + unset($entry['x'], $entry['y'], $entry['at'], $entry['offset'], $entry['facing'], $entry['collision']); + if ($kind === 'player') { + unset($entry['subject']['id']); + } + } + } + return FieldSpriteFields::prepareEdit($entry, $field); + } +} diff --git a/src/Database/SummonAssignmentDiagnostics.php b/src/Database/SummonAssignmentDiagnostics.php index df1034d8..5cf600ab 100644 --- a/src/Database/SummonAssignmentDiagnostics.php +++ b/src/Database/SummonAssignmentDiagnostics.php @@ -65,7 +65,7 @@ public function ids(): array * @param mixed $assignments The actor's `summons` value as authored. * @return array}> One row per assignment, in order. */ - public function forActor(string $actorName, string $className, mixed $assignments): array + public function forActor(string $actorId, string $className, mixed $assignments): array { if (! is_array($assignments) || ! array_is_list($assignments)) { return [['id' => '', 'problems' => [['message' => 'Its summon assignments are malformed.', 'hint' => 'Use a list of summon ids.']]]]; @@ -99,7 +99,7 @@ public function forActor(string $actorName, string $className, mixed $assignment continue; } - if (! $this->isEligible($definition, $actorName, $className)) { + if (! $this->isEligible($definition, $actorId, $className)) { $problems[] = ['message' => sprintf('It is not eligible to hold summon "%s".', $summonId), 'hint' => 'Change the actor assignment or the generic wielder policy.']; } @@ -118,7 +118,7 @@ public function forActor(string $actorName, string $className, mixed $assignment * * @param array $definition */ - public function isEligible(array $definition, string $actorName, string $className): bool + public function isEligible(array $definition, string $actorId, string $className): bool { $wielders = is_array($definition['wielders'] ?? null) ? $definition['wielders'] : null; @@ -130,7 +130,8 @@ public function isEligible(array $definition, string $actorName, string $classNa $mode = is_string($modeValue) ? strtolower(trim($modeValue)) : ''; return match ($mode) { - 'characters' => in_array($actorName, (array) ($wielders['characters'] ?? []), true), + // By the actor's stable id, as the Engine matches it, never its display name. + 'characters' => in_array(strtolower(trim($actorId)), array_map(static fn(mixed $id): string => is_string($id) ? strtolower(trim($id)) : '', (array) ($wielders['characters'] ?? [])), true), 'roles' => in_array($className, (array) ($wielders['roles'] ?? []), true), 'all' => true, default => false, diff --git a/src/Database/TroopAuthoring.php b/src/Database/TroopAuthoring.php new file mode 100644 index 00000000..0e130be1 --- /dev/null +++ b/src/Database/TroopAuthoring.php @@ -0,0 +1,337 @@ + $enemies The enemies the project has, by name. + * @param array> $sprites Each enemy's terminal sprite rows, by name ({@see EnemySprites}). + * @throws RecordRefusal When the troops cannot be written, there is no such troop, or the project has no such enemy. + */ + public function addMember(ProjectRecordDatabase $troops, int $index, string $enemy, array $enemies, array $sprites = []): RecordChange + { + $enemy = trim($enemy); + if (! in_array($enemy, $enemies, true)) { + throw new RecordRefusal($enemy === '' ? 'Choose the enemy the new member fights.' : sprintf('The project has no enemy named %s.', $enemy)); + } + $list = $this->requireMembers($troops, $index); + $entry = [...$list->blank, 'enemy' => $enemy, 'position' => $this->findTerminalPlace($troops, $index, $list->key, $sprites, $enemy)]; + $at = $troops->addSubItem($index, $entry, listKey: $list->key); + $entry = $at === null ? null : ($troops->getSubItems($index, $list->key)[$at] ?? null); + if ($at === null || ! is_array($entry)) { + throw new RecordRefusal(sprintf('A member cannot be added to %s.', $this->describeTroop($troops, $index))); + } + try { + $troops->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $troops->removeSubItem($index, $at, $list->key); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + sprintf('Add %s', $enemy), + static fn() => $troops->insertSubItem($index, $at, $entry, $list->key), + static fn() => $troops->removeSubItem($index, $at, $list->key), + ), $at); + } + + /** + * Where the Engine stands a new member fighting an enemy on the troop's + * terminal field, beside the members there with readable positions, by + * their sprites as the terminal battle draws them. + * + * @param array> $sprites + * @return list + */ + private function findTerminalPlace(ProjectRecordDatabase $troops, int $index, string $listKey, array $sprites, string $enemy): array + { + $members = []; + foreach ($troops->getSubItems($index, $listKey) as $member) { + $position = is_array($member) && is_array($member['position'] ?? null) ? array_values($member['position']) : []; + if (is_int($position[0] ?? null) && is_int($position[1] ?? null)) { + $members[] = ['position' => new Vector2($position[0], $position[1]), 'sprite' => $sprites[trim(strval($member['enemy'] ?? ''))] ?? []]; + } + } + $place = new TerminalBattleEffects()->findTroopPlace($members, $sprites[$enemy] ?? []); + + return [(int) $place->x, (int) $place->y]; + } + + /** + * Removes one member with everything it carries. + * + * @throws RecordRefusal When the troops cannot be written, or there is no such troop or member. + */ + public function removeMember(ProjectRecordDatabase $troops, int $index, int $member): RecordChange + { + $list = $this->requireMembers($troops, $index); + if (! array_key_exists($member, $troops->getSubItems($index, $list->key))) { + throw new RecordRefusal(sprintf('%s has no member %d.', $this->describeTroop($troops, $index), $member + 1)); + } + + return $this->records->removeEntry($troops, $index, [], $member, $list->key); + } + + /** + * Removes every member at once; nothing changes for a troop with none. + * + * @throws RecordRefusal When the troops cannot be written, or there is no such troop. + */ + public function clearMembers(ProjectRecordDatabase $troops, int $index): RecordChange + { + $list = $this->requireMembers($troops, $index); + $record = $troops->getRecordByIndex($index); + if ($record === null || $troops->getSubItems($index, $list->key) === []) { + return new RecordChange(null, $index); + } + $before = $record->toArray(); + // A page that still names a member keeps the troop's members. + TroopEventPageContract::remapMembers((array) $before, static fn(int $member): ?int => null); + $record->setEntries($list, []); + $after = $record->toArray(); + try { + $troops->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $record->restorePayload($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + 'Clear members', + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + ), $index); + } + + /** + * Places every member of a troop in its graphical formation at once, as + * one step: each member gets the place given, as the Engine's stage + * place array, or none (null), which the battle then arranges beside + * the others. Align, arranging automatically and dragging one member of + * a troop the battle arranges all place every member together, so no + * member moves except the one the author moved. The Engine's place rules + * decide what is accepted. + * + * @param list> $places One per member, in member order. + * @throws RecordRefusal When the troops cannot be written, there is no such troop, the count differs or a place is refused. + */ + public function placeMembers(ProjectRecordDatabase $troops, int $index, array $places, string $label): RecordChange + { + $list = $this->requireMembers($troops, $index); + $record = $troops->getRecordByIndex($index); + $members = $troops->getSubItems($index, $list->key); + if (! array_is_list($places) || count($places) !== count($members)) { + throw new RecordRefusal(sprintf('A troop of %d members needs %d places, one each.', count($members), count($members))); + } + $placed = []; + foreach ($members as $member => $entry) { + $entry = is_array($entry) ? $entry : []; + unset($entry['graphicalPlacement']); + if ($places[$member] !== null) { + try { + $place = BattleStageSlot::fromArray($places[$member], sprintf('member %d\'s battle placement', $member + 1))->toArray(); + // Whole canvas units stay integers, as an author writes them; the rest keep two decimals. + $entry['graphicalPlacement'] = array_map(static fn(float $value): int|float => round($value, 2) === floor($value) + ? (int) $value : round($value, 2), $place); + } catch (\InvalidArgumentException $refused) { + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + } + $placed[] = $entry; + } + if ($placed === $members) { + return new RecordChange(null, $index); + } + $before = $record->toArray(); + $record->setEntries($list, $placed); + $after = $record->toArray(); + try { + $troops->assertSourceAccepts($index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $record->restorePayload($before); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + $label, + static fn() => $record->restorePayload($after), + static fn() => $record->restorePayload($before), + ), $index); + } + + /** + * Adds a troop of the members given, as composed in a battle test, after + * the last troop, named after its members (numbered when another troop + * has that name), as one undo step. Each member names its enemy, its + * terminal position and, when it was placed, its graphical placement; + * one without a placement is arranged by the graphical battle. + * + * @param list> $members + * @param list $enemies The enemies the project has, by name. + * @throws RecordRefusal When the troops cannot be written, there is no member, or a member is refused. + */ + public function addTroop(ProjectRecordDatabase $troops, array $members, array $enemies): RecordChange + { + if (! $troops->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $troops->getReadOnlyReason() ?? 'the troops cannot be written')); + } + $list = $troops->schema->findInlineSubList(self::MEMBERS) ?? throw new RecordRefusal('Troops have no members list to compose.'); + if ($members === [] || ! array_is_list($members)) { + throw new RecordRefusal('A troop needs a member; add an enemy first.'); + } + $entries = []; + foreach ($members as $number => $member) { + $enemy = is_array($member) ? trim(strval($member['enemy'] ?? '')) : ''; + if (! in_array($enemy, $enemies, true)) { + throw new RecordRefusal(sprintf('Member %d fights %s, which the project has no enemy named.', $number + 1, $enemy === '' ? 'nothing' : $enemy)); + } + $position = is_array($member['position'] ?? null) ? array_values($member['position']) : []; + if (count($position) !== 2 || ! is_int($position[0]) || ! is_int($position[1])) { + throw new RecordRefusal(sprintf('Member %d needs a terminal position of two whole numbers.', $number + 1)); + } + $entry = [...$list->blank, 'enemy' => $enemy, 'position' => $position]; + if (isset($member['graphicalPlacement'])) { + try { + $place = BattleStageSlot::fromArray($member['graphicalPlacement'], sprintf('member %d\'s battle placement', $number + 1))->toArray(); + } catch (\InvalidArgumentException $refused) { + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + $entry['graphicalPlacement'] = array_map(static fn(float $value): int|float => round($value, 2) === floor($value) + ? (int) $value : round($value, 2), $place); + } + $entries[] = $entry; + } + $name = self::describeMembers(array_column($entries, 'enemy')); + $taken = array_map(static fn($record): string => strtolower(trim(strval($record->get($troops->schema->labelKey)))), $troops->getRecords()); + $unique = $name; + for ($number = 2; in_array(strtolower($unique), $taken, true); $number++) { + $unique = sprintf('%s %d', $name, $number); + } + $index = $this->records->createRecord($troops)->index; + $record = $troops->getRecordByIndex((int) $index) ?? throw new RecordRefusal('A troop cannot be created from the editor.'); + $record->set($troops->schema->labelKey, $unique); + $record->setEntries($list, $entries); + try { + $troops->assertSourceAccepts((int) $index); + } catch (SourcePreservationRefusal|SourceIdentityConflict|RuntimeException $refused) { + $troops->removeRecord((int) $index); + + throw new RecordRefusal($refused->getMessage(), previous: $refused); + } + + return new RecordChange(new GenericCommand( + sprintf('Add troop %s', $unique), + static fn() => $troops->insertRecord((int) $index, $record), + static fn() => $troops->removeRecord((int) $index), + ), $index); + } + + /** + * Names the troop after its members, through its name row's own rules. + * The name another troop already has gets a number after it, since a + * troop is known by its name. + * + * @throws RecordRefusal When the troops cannot be written, there is no such troop, or it has no member to be named after. + */ + public function nameAfterMembers(ProjectRecordDatabase $troops, int $index): RecordChange + { + $list = $this->requireMembers($troops, $index); + $enemies = array_map(static fn(array $member): string => trim(strval($member['enemy'] ?? '')), $troops->getSubItems($index, $list->key)); + $name = self::describeMembers($enemies); + if ($name === '') { + throw new RecordRefusal('Add a member first; the troop is named after its members.'); + } + $taken = []; + foreach ($troops->getRecords() as $position => $record) { + if ($position !== $index) { + $taken[] = strtolower(trim(strval($record->get($troops->schema->labelKey)))); + } + } + $unique = $name; + for ($number = 2; in_array(strtolower($unique), $taken, true); $number++) { + $unique = sprintf('%s %d', $name, $number); + } + + return $this->records->applyField($troops, $index, [], $troops->schema->labelKey, $unique, 'Auto-name'); + } + + /** + * What a troop of these enemies is called: each enemy once, in the order + * it first appears, with how many there are when more than one. + * + * @param list $enemies The members' enemies, in order. + */ + public static function describeMembers(array $enemies): string + { + $counts = []; + foreach ($enemies as $enemy) { + if ($enemy !== '') { + $counts[$enemy] = ($counts[$enemy] ?? 0) + 1; + } + } + $parts = []; + foreach ($counts as $enemy => $count) { + $parts[] = $count > 1 ? sprintf('%s x %d', $enemy, $count) : $enemy; + } + + return implode(', ', $parts); + } + + /** @throws RecordRefusal When the troops cannot be written, or there is no such troop. */ + private function requireMembers(ProjectRecordDatabase $troops, int $index): RecordSubList + { + if (! $troops->isEditable()) { + throw new RecordRefusal(sprintf('Read-only: %s.', $troops->getReadOnlyReason() ?? 'the troops cannot be written')); + } + $troops->getRecordByIndex($index) ?? throw new RecordRefusal(sprintf('troops has no record %d.', $index)); + + return $troops->schema->findInlineSubList(self::MEMBERS) + ?? throw new RecordRefusal('Troops have no members list to compose.'); + } + + private function describeTroop(ProjectRecordDatabase $troops, int $index): string + { + $record = $troops->getRecordByIndex($index); + + return $record === null ? sprintf('Troop %d', $index + 1) : $troops->getEntryLabel($record); + } +} diff --git a/src/Database/TroopEventPageContract.php b/src/Database/TroopEventPageContract.php new file mode 100644 index 00000000..8a140689 --- /dev/null +++ b/src/Database/TroopEventPageContract.php @@ -0,0 +1,134 @@ + $troop The troop as it would be saved. + * @return string|null Why the Engine would refuse it, or null when it reads. + */ + public static function findProblem(array $troop): ?string + { + $where = sprintf('Data/troops.php troop "%s"', is_scalar($troop['name'] ?? null) ? strval($troop['name']) : '(unnamed)'); + $members = is_array($troop['enemies'] ?? null) ? array_values($troop['enemies']) : []; + + foreach ($members as $index => $member) { + $floor = is_array($member) ? ($member['knockoutFloorPercent'] ?? 0) : 0; + if (! is_int($floor) || $floor < 0 || $floor > 99) { + return sprintf('%s.enemies[%d].knockoutFloorPercent must be a whole number from 0 to 99.', $where, $index); + } + } + + try { + BattleEventPage::readPages($troop['events'] ?? [], $where, count($members), ScriptCommandRegistry::getCatalog()); + } catch (InvalidArgumentException $problem) { + return $problem->getMessage(); + } + + return null; + } + + /** + * Why the Engine would refuse the troops file as it would be saved. + * + * @param array $whole The troops, as they would be saved. + */ + public static function checkTroops(array $whole, string $projectRoot = ''): ?string + { + foreach ($whole as $troop) { + if (is_array($troop) && ($problem = self::findProblem($troop)) !== null) { + return $problem; + } + } + + return null; + } + + /** + * Rewrites what a troop's event pages name its members by when members + * move: a page's enemy condition and every battle command naming a + * member, in every arm. A member a page still names cannot be removed. + * + * @param array $troop The troop. + * @param Closure(int): ?int $map The member's new index, or null when it is removed. + * @return array The troop with its pages renumbered. + * @throws RecordRefusal When a page names a removed member. + */ + public static function remapMembers(array $troop, Closure $map): array + { + if (! is_array($troop['events'] ?? null)) { + return $troop; + } + + foreach ($troop['events'] as $page => $entry) { + if (! is_array($entry)) { + continue; + } + $where = sprintf('Event page %d', intval($page) + 1); + if (is_array($entry['conditions'] ?? null) && is_int($entry['conditions']['enemy'] ?? null)) { + $entry['conditions']['enemy'] = self::mapMember($entry['conditions']['enemy'], $map, $where); + } + if (is_array($entry['commands'] ?? null)) { + $entry['commands'] = self::remapCommands($entry['commands'], $map, $where); + } + $troop['events'][$page] = $entry; + } + + return $troop; + } + + /** @param array $commands */ + private static function remapCommands(array $commands, Closure $map, string $where): array + { + $definitions = ScriptCommandRegistry::getCatalog()->definitions; + + foreach ($commands as $index => $command) { + if (! is_array($command)) { + continue; + } + foreach ($definitions[strval($command['type'] ?? '')]->fields ?? [] as $field) { + $member = $field->reference === ScriptCommandReference::TROOP_MEMBER ? ScriptCommandField::readPath($command, $field->key) : null; + if (is_int($member)) { + $target = &$command; + foreach (explode('.', $field->key) as $segment) { $target = &$target[$segment]; } + $target = self::mapMember($member, $map, $where); + unset($target); + } + } + foreach (['then', 'else', 'commands', 'cancel'] as $arm) { + if (is_array($command[$arm] ?? null)) { + $command[$arm] = self::remapCommands($command[$arm], $map, $where); + } + } + foreach (is_array($command['options'] ?? null) ? $command['options'] : [] as $option => $choice) { + if (is_array($choice) && is_array($choice['then'] ?? null)) { + $command['options'][$option]['then'] = self::remapCommands($choice['then'], $map, $where); + } + } + $commands[$index] = $command; + } + + return $commands; + } + + private static function mapMember(int $member, Closure $map, string $where): int + { + return $map($member) ?? throw new RecordRefusal(sprintf( + '%s names member %d, so it cannot be removed. Change that page first.', $where, $member + 1)); + } +} diff --git a/src/Database/WordFormFields.php b/src/Database/WordFormFields.php new file mode 100644 index 00000000..210a7b25 --- /dev/null +++ b/src/Database/WordFormFields.php @@ -0,0 +1,86 @@ + WordForms::SCHEMA]), + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** A new word keeps its own spelling in both forms until the author says otherwise. */ + public static function makeBlank(string $name, string $projectRoot): array + { + $word = trim($name); + if ($word === '') { + throw new RecordRefusal('Name the word as the game writes it, such as an item\'s name.'); + } + + return ['word' => $word, 'singular' => $word, 'plural' => $word]; + } + + /** @return list */ + public static function getFields(): array + { + return [ + new RecordField('word', 'Word'), + new RecordField('singular', 'Singular'), + new RecordField('plural', 'Plural'), + ]; + } + + /** Refuses a blank or space-padded spelling as it is typed: the inflector matches a word exactly. */ + public static function checkEdit(array $entry, string $field): array + { + $value = $entry[$field] ?? null; + if (is_string($value) && trim($value) !== '' && trim($value) === $value) { + return $entry; + } + + throw new RecordRefusal(sprintf('The %s is written in full, without spaces around it.', $field === 'word' ? 'word' : "$field form")); + } + + /** Why the Engine would not read the word forms as a save would write them, or null when it would. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + WordForms::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/src/Database/WorldStateLine.php b/src/Database/WorldStateLine.php new file mode 100644 index 00000000..d81b7ef9 --- /dev/null +++ b/src/Database/WorldStateLine.php @@ -0,0 +1,98 @@ + + */ + public static function splitEntries(string $line): array + { + return self::splitAt($line, ';'); + } + + /** + * Splits one entry into its parts at every unescaped `:`, each trimmed and + * unescaped. + * + * @return list + * @throws InvalidArgumentException When a backslash escapes nothing it may. + */ + public static function splitParts(string $entry): array + { + return array_map(static fn(string $part): string => self::unescape(trim($part)), self::splitAt($entry, ':')); + } + + /** Writes one part with its `\`, `:` and `;` escaped, so it reads back whole. */ + public static function escape(string $part): string + { + return strtr($part, ['\\' => '\\\\', ':' => '\\:', ';' => '\\;']); + } + + /** + * Joins parts into one entry, each escaped. + * + * @param list $parts + */ + public static function joinParts(array $parts): string + { + return implode(':', array_map(self::escape(...), $parts)); + } + + /** @return list */ + private static function splitAt(string $text, string $separator): array + { + $pieces = []; + $current = ''; + $length = strlen($text); + for ($index = 0; $index < $length; $index++) { + $character = $text[$index]; + if ($character === '\\' && $index + 1 < $length) { + // An escaped character stays escaped for the part to unescape. + $current .= $character . $text[++$index]; + continue; + } + if ($character === $separator) { + $pieces[] = $current; + $current = ''; + continue; + } + $current .= $character; + } + $pieces[] = $current; + + return $pieces; + } + + /** @throws InvalidArgumentException When a backslash escapes nothing it may. */ + private static function unescape(string $part): string + { + return (string) preg_replace_callback('/\\\\(.?)/s', static function (array $match): string { + if (! in_array($match[1], ['\\', ':', ';'], true)) { + throw new InvalidArgumentException(sprintf('"\\%s" escapes nothing; write \\: for a colon, \\; for a semicolon and \\\\ for a backslash.', $match[1])); + } + + return $match[1]; + }, $part); + } +} diff --git a/src/Database/WorldWriteCodec.php b/src/Database/WorldWriteCodec.php index 28c645b2..023cb6ad 100644 --- a/src/Database/WorldWriteCodec.php +++ b/src/Database/WorldWriteCodec.php @@ -19,8 +19,11 @@ * variable:name[:set|add][:v] -> ['type' => 'variable', 'name', 'op', 'value'] * quest:id[:confirm|grant] -> ['type' => 'quest', 'name', 'confirm'] * - * Entries are separated by `;`. Unparseable segments are dropped rather than - * written back as garbage. + * Entries are separated by `;` ({@see WorldStateLine}); a name or value + * escapes its backslash, `:` and `;` with a backslash, so any name the Engine + * accepts round-trips. A line is read only as written: a part the type does + * not take, or a value it cannot be, makes the entry unreadable, never + * rewritten. * * @package Ichiloto\Editor\Database */ @@ -29,6 +32,55 @@ final class WorldWriteCodec /** The write types the engine's writer applies. */ public const array TYPES = ['switch', 'event', 'variable', 'quest']; + /** The keys the line carries per type, beyond type and name. */ + private const array CARRIED_KEYS = [ + 'switch' => ['value'], + 'event' => [], + 'variable' => ['op', 'value'], + 'quest' => ['confirm'], + ]; + + /** + * Why an authored write list cannot go through the write line, or null + * when the line carries it faithfully: a list that is not a list, a write + * type or variable operation the line cannot express, a switch or quest + * flag that is not a boolean, or an authored key outside its vocabulary + * would be lost on commit, so such a list is refused by name instead. + * + * @param mixed $sets The list as authored. + */ + public static function findCarryIssue(mixed $sets): ?string + { + if (! is_array($sets) || ! array_is_list($sets)) { + return sprintf('the writes are %s, not a list', is_array($sets) ? 'a keyed array' : get_debug_type($sets)); + } + + foreach ($sets as $index => $set) { + if (! is_array($set)) { + return sprintf('write %d is %s, not an array', $index + 1, get_debug_type($set)); + } + + $type = strval($set['type'] ?? ''); + $flag = ['switch' => 'value', 'quest' => 'confirm'][$type] ?? null; + $lossy = match (true) { + self::decode(self::encode($set)) === null => 'holds a shape the write editor cannot carry', + $type === 'variable' && ! in_array($set['op'] ?? 'set', ['set', 'add'], true) => 'has a variable operation the write editor would rewrite', + $flag !== null && array_key_exists($flag, $set) && ! is_bool($set[$flag]) => sprintf('has a "%s" that is not true or false', $flag), + default => null, + }; + if ($lossy !== null) { + return sprintf('write %d %s', $index + 1, $lossy); + } + + $extra = array_diff(array_keys($set), ['type', 'name', ...(self::CARRIED_KEYS[$type] ?? [])]); + if ($extra !== []) { + return sprintf('write %d carries "%s", which the write editor would drop', $index + 1, implode('", "', array_map(strval(...), $extra))); + } + } + + return null; + } + /** * Encodes a list of writes into the one-line editable form. * @@ -76,11 +128,13 @@ public static function encode(array $set): string break; } - return implode(':', $parts); + return WorldStateLine::joinParts($parts); } /** - * Decodes the one-line form back into write arrays. + * Decodes the one-line form back into write arrays, leaving out an entry + * it cannot read: for showing a line this codec wrote. A line an author or + * a client submits is read by {@see decodeAllStrictly()}. * * @param string $value The encoded writes. * @return array> The writes. @@ -89,7 +143,7 @@ public static function decodeAll(string $value): array { $sets = []; - foreach (explode(';', $value) as $segment) { + foreach (WorldStateLine::splitEntries($value) as $segment) { $set = self::decode($segment); if ($set !== null) { @@ -101,39 +155,107 @@ public static function decodeAll(string $value): array } /** - * Decodes one segment. + * Decodes the one-line form, refusing it whole when any entry cannot be + * read as written, so a typed write is never dropped or reinterpreted + * without a word. + * + * @param string $value The encoded writes. + * @return array> The writes. + * @throws \InvalidArgumentException Naming the first unreadable entry and why. + */ + public static function decodeAllStrictly(string $value): array + { + $sets = []; + + foreach (WorldStateLine::splitEntries($value) as $segment) { + if (trim($segment) === '') { + continue; + } + + $set = self::parse($segment); + if (is_string($set)) { + throw new \InvalidArgumentException(sprintf('Write "%s" cannot be read: %s Nothing was changed.', trim($segment), $set)); + } + $sets[] = $set; + } + + return $sets; + } + + /** + * Decodes one entry. * - * @param string $segment The segment. - * @return array|null The write, or null when unparseable. + * @param string $segment The entry. + * @return array|null The write, or null when it cannot be read as written. */ public static function decode(string $segment): ?array { - $parts = array_map(trim(...), explode(':', trim($segment))); - $type = strtolower($parts[0] ?? ''); + $set = self::parse($segment); + + return is_array($set) ? $set : null; + } + + /** + * Reads one entry exactly as written. + * + * @return array|string The write, or why it cannot be read. + */ + private static function parse(string $segment): array|string + { + if (trim($segment) === '') { + return 'it is empty.'; + } + try { + $parts = WorldStateLine::splitParts(trim($segment)); + } catch (\InvalidArgumentException $error) { + return $error->getMessage(); + } + $type = strtolower($parts[0]); $name = $parts[1] ?? ''; - if ($name === '' || ! in_array($type, self::TYPES, true)) { - return null; + if (! in_array($type, self::TYPES, true)) { + return sprintf('write type:name[:extras] with a type of %s.', implode(', ', self::TYPES)); + } + if ($name === '') { + return sprintf('the %s write needs a name.', $type); + } + $extras = array_slice($parts, 2); + $allowed = ['switch' => 1, 'event' => 0, 'variable' => 2, 'quest' => 1][$type]; + if (count($extras) > $allowed) { + return sprintf('the %s write takes %s after its name, not "%s"; write a colon or semicolon inside a name as \\: or \\;.', + $type, ['switch' => 'at most true or false', 'event' => 'nothing', 'variable' => 'at most set or add and a value', + 'quest' => 'at most confirm or grant'][$type], implode(':', array_slice($extras, $allowed))); } $set = ['type' => $type, 'name' => $name]; switch ($type) { case 'switch': - if (isset($parts[2])) { - $set['value'] = strtolower($parts[2]) !== 'false'; + if (isset($extras[0])) { + $state = strtolower($extras[0]); + if (! in_array($state, ['true', 'false'], true)) { + return sprintf('a switch is set true or false, not "%s".', $extras[0]); + } + $set['value'] = $state === 'true'; } break; case 'variable': - $op = strtolower($parts[2] ?? 'set'); - $set['op'] = $op === 'add' ? 'add' : 'set'; - $rawValue = $parts[3] ?? ($op === 'add' ? '1' : '0'); + $op = strtolower($extras[0] ?? 'set'); + if (! in_array($op, ['set', 'add'], true)) { + return sprintf('a variable write is set or add, not "%s".', $extras[0]); + } + $set['op'] = $op; + $rawValue = $extras[1] ?? ($op === 'add' ? '1' : '0'); $set['value'] = is_numeric($rawValue) ? $rawValue + 0 : $rawValue; break; case 'quest': - if (isset($parts[2])) { - $set['confirm'] = strtolower($parts[2]) !== 'grant'; + if (isset($extras[0])) { + $mode = strtolower($extras[0]); + if (! in_array($mode, ['confirm', 'grant'], true)) { + return sprintf('a quest write is confirm or grant, not "%s".', $extras[0]); + } + $set['confirm'] = $mode === 'confirm'; } break; diff --git a/src/Database/WorldWriteEditor.php b/src/Database/WorldWriteEditor.php index 61eaed29..2855d451 100644 --- a/src/Database/WorldWriteEditor.php +++ b/src/Database/WorldWriteEditor.php @@ -35,6 +35,40 @@ final class WorldWriteEditor * @var array> The writes being edited. */ private array $sets = []; + + /** + * The write vocabulary for an interface that builds writes a part at a + * time without this editor's cursor: each type, what its name is called + * and which reference category lists names (null for a name the author + * invents), and the extras it carries with their choices or kind and + * default. Restricted to `$types` where the runtime allows only some. + * + * @param list|null $types The write types a surface may author; null for all. + * @return list>}> + */ + public static function getGrammar(?array $types = null): array + { + $choice = static fn(mixed $value, string $label): array => ['value' => $value, 'label' => $label]; + $extras = [ + 'switch' => [['key' => 'value', 'label' => 'Turn', 'kind' => 'options', 'default' => true, + 'options' => [$choice(true, 'on'), $choice(false, 'off')]]], + 'variable' => [ + ['key' => 'op', 'label' => 'Change', 'kind' => 'options', 'default' => 'set', + 'options' => [$choice('set', 'set to'), $choice('add', 'add')]], + ['key' => 'value', 'label' => 'Value', 'kind' => 'text', 'default' => 0], + ], + 'quest' => [['key' => 'confirm', 'label' => 'How', 'kind' => 'options', 'default' => true, + 'options' => [$choice(true, 'offer'), $choice(false, 'grant')]]], + ]; + + return array_values(array_map(static fn(string $type): array => [ + 'type' => $type, + 'label' => self::NAMES[$type][0] ?? ucfirst($type), + 'nameCategory' => self::NAMES[$type][1] ?? null, + 'negatable' => false, + 'extras' => $extras[$type] ?? [], + ], array_values(array_intersect(WorldWriteCodec::TYPES, $types ?? WorldWriteCodec::TYPES)))); + } private bool $isOpen = false; private string $fieldId = ''; private string $label = ''; diff --git a/src/Dialogue/DialogueCatalogFile.php b/src/Dialogue/DialogueCatalogFile.php new file mode 100644 index 00000000..d6b4d603 --- /dev/null +++ b/src/Dialogue/DialogueCatalogFile.php @@ -0,0 +1,93 @@ +getMessage(); + } + + return null; + } + + /** The record an identity has in the catalogue, actor or not, or null when it has none. */ + public static function findIdentity(DialoguePresentationCatalog $catalogue, ?string $identity): ?array + { + if ($identity === null) { + return null; + } + $record = $catalogue->actors[$identity] ?? $catalogue->resources[$identity] ?? null; + + return is_array($record) ? $record : null; + } + + /** + * The emotions a line spoken by an identity may show: Neutral, which the Engine always accepts and falls back + * to, then every emotion the identity names, in the catalogue's order. + * + * @return list + */ + public static function listEmotions(DialoguePresentationCatalog $catalogue, ?string $identity): array + { + $emotions = self::findIdentity($catalogue, $identity)['emotions'] ?? []; + $emotions = is_array($emotions) ? array_keys($emotions) : []; + + return array_values(array_unique([DialogueContext::DEFAULT_EMOTION, ...array_map(strval(...), $emotions)])); + } +} diff --git a/src/Dialogue/DialogueEmotionFields.php b/src/Dialogue/DialogueEmotionFields.php new file mode 100644 index 00000000..d25fe215 --- /dev/null +++ b/src/Dialogue/DialogueEmotionFields.php @@ -0,0 +1,101 @@ +, issue: ?string} + */ + public static function describeChoices(DialogueLineSpeaker $rule, array $line, array $owner, DialoguePresentationCatalog $catalogue): array + { + $identity = $rule->resolveIdentity($line, $owner, $catalogue); + $identity = DialogueCatalogFile::findIdentity($catalogue, $identity) === null ? null : $identity; + + return ['speaker' => $rule->describeSpeaker($line, $owner), 'identity' => $identity, + 'emotions' => DialogueCatalogFile::listEmotions($catalogue, $identity), 'issue' => null]; + } + + /** + * A line's choices while the catalogue cannot be read: Neutral, which the Engine always shows, and why. + * + * @return array{speaker: string, identity: ?string, emotions: list, issue: ?string} + */ + public static function describeUnreadableCatalogue(DialogueLineSpeaker $rule, array $line, array $owner, Throwable $error): array + { + return ['speaker' => $rule->describeSpeaker($line, $owner), 'identity' => null, + 'emotions' => [DialogueContext::DEFAULT_EMOTION], 'issue' => 'The dialogue catalogue cannot be read: ' . $error->getMessage()]; + } + + /** + * The emotion row with its choices, and the warning row after it when the value is not one of them. + * + * @param array $row The emotion row as the record describes it. + * @param mixed $current The line's emotion as authored, null when it has none. + * @param array{speaker: string, identity: ?string, emotions: list, issue: ?string} $emotions + * @return list> + */ + public static function describeRows(array $row, mixed $current, array $emotions): array + { + if (($row['editable'] ?? true) !== false) { + // Chosen, never typed: the speaker's emotions are the choices. + unset($row['control']); + $row['options'] = $emotions['emotions']; + $row['choices'] = array_map(static fn(string $emotion): array => ['value' => $emotion, 'label' => $emotion], $emotions['emotions']); + } + $warning = self::describeWarning($current, $emotions); + if ($warning === null) { + return [$row]; + } + + return [$row, array_filter([ + 'label' => preg_replace('/Emotion$/', 'Emotion Warning', (string) ($row['label'] ?? 'Emotion')), + 'value' => $warning, + 'editable' => false, + 'tone' => 'warning', + 'entry' => $row['entry'] ?? null, + 'entryLabel' => $row['entryLabel'] ?? null, + 'name' => 'Emotion Warning', + ], static fn(mixed $value): bool => $value !== null)]; + } + + /** + * Why a line's emotion is not one its speaker's identity offers, or null when it is. + * + * @param array{speaker: string, identity: ?string, emotions: list, issue: ?string} $emotions + */ + public static function describeWarning(mixed $current, array $emotions): ?string + { + if ($current === null || $current === '' || $current === DialogueContext::DEFAULT_EMOTION || in_array($current, $emotions['emotions'], true)) { + return null; + } + $emotion = is_scalar($current) ? (string) $current : get_debug_type($current); + if ($emotions['issue'] !== null) { + return $emotions['issue']; + } + if ($emotions['identity'] === null) { + return $emotions['speaker'] === '' + ? sprintf('The line has no speaker, so "%s" shows no portrait.', $emotion) + : sprintf('%s has no dialogue portrait, so "%s" shows none. Bind the name under Speaker Names.', $emotions['speaker'], $emotion); + } + + return sprintf('%s has no "%s" emotion; the game shows its Neutral artwork until one is added under Dialogue Portraits.', + $emotions['identity'], $emotion); + } +} diff --git a/src/Dialogue/DialogueLineSpeaker.php b/src/Dialogue/DialogueLineSpeaker.php new file mode 100644 index 00000000..f8849bfe --- /dev/null +++ b/src/Dialogue/DialogueLineSpeaker.php @@ -0,0 +1,77 @@ +actorKey === null ? null : ($line[$this->actorKey] ?? null); + + return is_string($actor) && $actor !== '' ? $actor : null; + } + + /** The label a line is spoken under, as the Engine shows it. */ + public function getSpeakerName(array $line, array $owner = []): string + { + if (array_key_exists($this->nameKey, $line) || $this->ownerNameKey === null) { + return is_scalar($line[$this->nameKey] ?? null) ? (string) $line[$this->nameKey] : ''; + } + + return is_scalar($owner[$this->ownerNameKey] ?? null) ? (string) $owner[$this->ownerNameKey] : ''; + } + + /** Who a line names, for an author: its actor id or its label. */ + public function describeSpeaker(array $line, array $owner = []): string + { + return $this->getActorId($line) ?? $this->getSpeakerName($line, $owner); + } + + /** The catalogue identity whose artwork a line shows, or null when its speaker has none. */ + public function resolveIdentity(array $line, array $owner, DialoguePresentationCatalog $catalogue): ?string + { + $name = $this->getSpeakerName($line, $owner); + + return $catalogue->resolveSpeakerId($this->getActorId($line), $name); + } +} diff --git a/src/Dialogue/DialoguePortraitFields.php b/src/Dialogue/DialoguePortraitFields.php new file mode 100644 index 00000000..357ca3e5 --- /dev/null +++ b/src/Dialogue/DialoguePortraitFields.php @@ -0,0 +1,122 @@ + self::ACTOR, 'resources' => self::CHARACTER], 'id', self::KIND, + fileKeys: ['schema' => DialogueCatalogFile::SCHEMA]), + fieldsFor: self::getFields(...), + labelFor: self::describeLabel(...), + subLists: [self::getEmotionList()], + saveCheck: DialogueCatalogFile::checkCatalog(...), + prepareEdit: self::checkEdit(...), + ); + } + + /** + * A new identity: a character under the given name as its stable id, with no artwork yet for the author to + * choose. Speaks As turns it into an actor's identity, chosen among the project's actors. + */ + public static function makeBlank(string $name, string $projectRoot): array + { + if (trim($name) === '') { + throw new RecordRefusal('A dialogue portrait needs a stable id.'); + } + + return ['id' => trim($name), self::KIND => self::CHARACTER]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + $actor = ($entry[self::KIND] ?? null) === self::ACTOR; + + return [ + new RecordField(self::KIND, 'Speaks As', options: [self::ACTOR, self::CHARACTER]), + // An actor's identity is its stable id, chosen; a character's is typed once and named by speaker names. + $actor ? RecordField::reference('id', 'Actor', 'actor_ids') : new RecordField('id', 'Stable Id'), + ...self::getArtworkFields(), + ]; + } + + /** + * One artwork set: the dialogue box's portrait and the skit stage's bust, each a project image or none. + * + * @return list + */ + public static function getArtworkFields(): array + { + return [ + new RecordField('portrait', 'Dialogue Portrait', reference: 'png_assets', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + new RecordField('bust', 'Skit Bust', reference: 'png_assets', removeWhenEmpty: true, allowsNone: true, displayDefault: '(none)'), + ]; + } + + /** + * An identity's emotions, keyed by name: what a line asks for by its `emotion`. A missing emotion's image falls + * back to Neutral's and then the identity's own, as the Engine draws it. An emotion written as a bare image is + * its portrait. + */ + public static function getEmotionList(): RecordSubList + { + return new RecordSubList(key: 'emotions', prefix: 'emotion', singular: 'emotion', + fields: [new RecordField('name', 'Name'), ...self::getArtworkFields()], + blank: ['name' => 'New Emotion'], heading: 'Emotions', scalarKey: 'portrait', removeWhenEmpty: true, keyField: 'name'); + } + + /** An identity reads as its id and what it is. */ + public static function describeLabel(array $entry): string + { + return sprintf('%s (%s)', (string) ($entry['id'] ?? ''), ($entry[self::KIND] ?? null) === self::ACTOR ? 'actor' : 'character'); + } + + /** Refuses an id the Engine could not key the identity by, as it is typed: it is never empty. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'id' && (! is_string($entry['id'] ?? null) || trim($entry['id']) === '')) { + throw new RecordRefusal('A dialogue portrait needs a stable id.'); + } + + return $entry; + } +} diff --git a/src/Dialogue/DialogueSpeakerFields.php b/src/Dialogue/DialogueSpeakerFields.php new file mode 100644 index 00000000..d2cc0300 --- /dev/null +++ b/src/Dialogue/DialogueSpeakerFields.php @@ -0,0 +1,88 @@ + DialogueCatalogFile::SCHEMA], valueKey: 'identity'), + labelFor: self::describeLabel(...), + saveCheck: DialogueCatalogFile::checkCatalog(...), + prepareEdit: self::checkEdit(...), + ); + } + + /** + * A new label, which saves at once as the Engine admits it: bound to the identity of that id when there is one, + * else to the catalogue's first identity for the author to change, as a new text role starts on the project's + * first font. The identities are the catalogue's as the workspace holds it, so a portrait added earlier in the + * session can be named at once. + */ + public static function makeBlank(string $name, string $projectRoot, ?ReferenceCatalog $references = null): array + { + if (trim($name) === '') { + throw new RecordRefusal('A speaker name is the label lines are spoken under; it cannot be empty.'); + } + try { + $catalogue = $references?->getDialoguePresentationCatalog() ?? DialoguePresentationCatalog::load(rtrim($projectRoot, '/') . '/assets'); + } catch (Throwable $error) { + throw new RecordRefusal('The dialogue catalogue cannot be read: ' . $error->getMessage(), previous: $error); + } + $identities = array_map(strval(...), [...array_keys($catalogue->actors), ...array_keys($catalogue->resources)]); + if ($identities === []) { + throw new RecordRefusal('A speaker name shows a dialogue portrait. Add one under Dialogue Portraits first.'); + } + + return ['name' => $name, 'identity' => in_array($name, $identities, true) ? $name : $identities[0]]; + } + + /** A label reads as itself and whose portrait it shows. */ + public static function describeLabel(array $entry): string + { + return sprintf('%s -> %s', (string) ($entry['name'] ?? ''), (string) ($entry['identity'] ?? '')); + } + + /** A label is matched exactly by the Engine, so it is never empty. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'name' && trim((string) ($entry['name'] ?? '')) === '') { + throw new RecordRefusal('A speaker name is the label lines are spoken under; it cannot be empty.'); + } + + return $entry; + } +} diff --git a/src/Dialogue/DialogueVariants.php b/src/Dialogue/DialogueVariants.php new file mode 100644 index 00000000..2a95b846 --- /dev/null +++ b/src/Dialogue/DialogueVariants.php @@ -0,0 +1,59 @@ + $dialogue The dialogue as authored. + * @return array> The variants. + */ + public static function fold(array $dialogue): array + { + $dialogue = array_values(array_filter($dialogue, is_array(...))); + + if ($dialogue === [] || ConditionalDialogue::isVariantList($dialogue)) { + return $dialogue; + } + + return [['lines' => $dialogue]]; + } + + /** + * The stored form of a variant list: plain pages when the list is one + * unconditioned, write-less, script-less variant. + * + * @param array $variants The variants. + * @return array The dialogue to store. + */ + public static function unfold(array $variants): array + { + $variants = array_values(array_filter($variants, is_array(...))); + + if (count($variants) === 1 && array_keys($variants[0]) === ['lines'] && is_array($variants[0]['lines'])) { + return array_values($variants[0]['lines']); + } + + return $variants; + } +} diff --git a/src/Editor.php b/src/Editor.php index d397257a..102467ff 100644 --- a/src/Editor.php +++ b/src/Editor.php @@ -10,7 +10,11 @@ use Atatusoft\Termutil\IO\Enumerations\Color; use Atatusoft\Termutil\IO\Mouse\Enumerations\MouseButton; use Ichiloto\Editor\Backup\BackupSettings; +use Ichiloto\Editor\Actors\ActorIdentityMigration; +use Ichiloto\Editor\Actors\ActorIdentityMigrationPlan; use Ichiloto\Editor\Backup\BackupWriter; +use Ichiloto\Editor\Canvas\CanvasClipboard; +use Ichiloto\Editor\Canvas\CanvasEditor; use Ichiloto\Editor\Canvas\CanvasTool; use Ichiloto\Editor\Canvas\Clipboard; use Ichiloto\Editor\Canvas\ToolGeometry; @@ -21,21 +25,26 @@ use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\DatabaseCategoryDefinition; use Ichiloto\Editor\Database\InventoryCatalog; -use Ichiloto\Editor\Database\ParameterMapCodec; -use Ichiloto\Editor\Database\ParameterMapSyntaxError; -use Ichiloto\Editor\Database\RecordFieldCodec; use Ichiloto\Editor\Database\ProjectRecord; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordAuthoring; +use Ichiloto\Editor\Database\RecordChange; +use Ichiloto\Editor\Database\RecordItem; +use Ichiloto\Editor\Database\RecordPanes; +use Ichiloto\Editor\Database\RecordRefusal; use Ichiloto\Editor\Database\SharedFileTransaction; use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Editor\Database\QuestReferences; +use Ichiloto\Editor\Animations\AnimationConversionEditor; +use Ichiloto\Editor\Animations\LegacyAnimationConversion; use Ichiloto\Editor\Database\AffinityEditor; use Ichiloto\Editor\Database\ConditionEditor; use Ichiloto\Editor\Field\NpcInspector; use Ichiloto\Editor\Field\MapBgmVariants; use Ichiloto\Editor\Field\MapEncounters; -use Ichiloto\Editor\Field\NpcReferences; -use Ichiloto\Editor\Field\ProjectNpc; +use Ichiloto\Editor\Field\NpcAuthoring; +use Ichiloto\Editor\Field\NpcChange; +use Ichiloto\Editor\Field\NpcRefusal; use Ichiloto\Editor\Database\WorldWriteEditor; use Ichiloto\Editor\Database\WorldWriteCodec; use Ichiloto\Editor\Database\BattleEntryPredicateCodec; @@ -43,22 +52,37 @@ use Ichiloto\Editor\Database\ElementAffinityCodec; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; +use Ichiloto\Editor\Actors\ActorAuthoring; use Ichiloto\Editor\Database\RecordSubList; +use Ichiloto\Editor\Database\EnemySprites; +use Ichiloto\Editor\Database\TroopAuthoring; use Ichiloto\Editor\Database\ReferencePicker; use Ichiloto\Editor\Debug\Debug; +use Ichiloto\Editor\Maps\MapLinePlan; +use Ichiloto\Editor\Maps\SaveManifestStep; +use Ichiloto\Editor\Maps\SaveRelocationPlan; +use Ichiloto\Editor\Maps\SaveRelocationPlanner; +use Ichiloto\Editor\Maps\MapLayers; +use Ichiloto\Editor\Events\EventAuthoring; +use Ichiloto\Editor\Events\EventRecordPane; +use Ichiloto\Editor\Events\EventRefusal; use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\History\Command; use Ichiloto\Editor\History\CommandHistory; use Ichiloto\Editor\History\GenericCommand; use Ichiloto\Editor\History\PaintStrokeCommand; +use Ichiloto\Editor\History\SourceSetCommand; use Ichiloto\Editor\Inspector\InputControl; +use Ichiloto\Editor\Inspector\InspectorListEdit; +use Ichiloto\Editor\Inspector\InspectorRefusal; +use Ichiloto\Editor\Inspector\MapInspector; use Ichiloto\Editor\Inspector\InputControlType; use Ichiloto\Editor\IO\InputDecoder; use Ichiloto\Editor\IO\InputRouter; use Ichiloto\Editor\IO\KeyBinding; use Ichiloto\Editor\Navigation\NavigationEntry; use Ichiloto\Editor\Playtest\PlaytestLauncher; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\Navigation\NavigationStack; use Ichiloto\Editor\Runtime\EditorLoop; use Ichiloto\Editor\Runtime\TerminalHost; @@ -79,33 +103,45 @@ use Ichiloto\Editor\UI\SettingsPaneLayout; use Ichiloto\Editor\UI\TextFieldEditor; use Ichiloto\Editor\UI\TextFieldKeyResult; +use Ichiloto\Editor\Storage\WorkspaceSave; use Ichiloto\Editor\Validation\MapValidator; -use Ichiloto\Engine\Animations\AnimationTargetPosition; +use Ichiloto\Editor\Validation\Severity; use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber; use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide; use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus; use Ichiloto\Engine\Entities\Enumerations\Occasion; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; use Ichiloto\Engine\Entities\Inventory\Accessory; use Ichiloto\Engine\Entities\Inventory\Armor; use Ichiloto\Engine\Entities\Inventory\Items\Item; use Ichiloto\Engine\Entities\Inventory\Weapons\Weapon; use Ichiloto\Engine\Entities\Magic\MagicEffectType; -use Ichiloto\Engine\Entities\Roles\ExperienceCurveGenerator; -use Ichiloto\Engine\Entities\Roles\ParameterCurveGenerator; +use Ichiloto\Engine\Entities\Skills\MagicSkill; +use Ichiloto\Engine\Entities\Skills\Skill; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; +use Ichiloto\Engine\Entities\Skills\SkillRecord; use Ichiloto\Engine\Events\Enumerations\ChestType; use Ichiloto\Engine\Events\Enumerations\LootType; use Ichiloto\Engine\Quests\QuestObjectiveType; +use Ichiloto\Engine\Rendering\Tilesets\TileId; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; use RuntimeException; -if (! class_exists(__NAMESPACE__ . chr(92) . 'Animation', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'Animation', __NAMESPACE__ . chr(92) . 'Animation'); } -if (! class_exists(__NAMESPACE__ . chr(92) . 'AnimationCue', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'AnimationCue', __NAMESPACE__ . chr(92) . 'AnimationCue'); } -if (! class_exists(__NAMESPACE__ . chr(92) . 'AnimationPlayer', false)) { class_alias('Ichiloto' . chr(92) . 'Engine' . chr(92) . 'Animations' . chr(92) . 'AnimationPlayer', __NAMESPACE__ . chr(92) . 'AnimationPlayer'); } use Throwable; +use InvalidArgumentException; /** * Launches the Ichiloto terminal editor shell. */ final class Editor { + use \Ichiloto\Editor\Canvas\LayerCanvas; + use \Ichiloto\Editor\Canvas\AreaCanvas; + use \Ichiloto\Editor\Canvas\PieceCanvas; + use \Ichiloto\Editor\Canvas\TerrainKindCanvas; + use \Ichiloto\Editor\Canvas\MapLineCanvas; + use \Ichiloto\Editor\Canvas\TreeCanvas; + use \Ichiloto\Editor\Canvas\GlyphTileCanvas; + use \Ichiloto\Editor\Inspector\MapKindField; use CutscenesWorkspace; use CutsceneOutlinePane; use CutscenePreviewPane; @@ -120,11 +156,6 @@ final class Editor * subprocess, so it must never run per frame). */ private const float TERMINAL_SIZE_PROBE_INTERVAL_SECONDS = 0.25; - /** - * Seconds each animation preview frame stays on screen (matches the - * engine AnimationPlayer default cadence). - */ - private const float PREVIEW_SECONDS_PER_FRAME = 0.12; private const string FOCUS_ASSETS = 'assets'; private const string FOCUS_CANVAS = 'canvas'; @@ -132,6 +163,9 @@ final class Editor private const string MODE_MAP = 'map'; private const string MODE_EVENT = 'event'; private const string MODE_NPC = 'npc'; + private const string MODE_AREA = 'area'; + /** The layer picker's NPCs entry, which enters NPC mode. */ + private const string NPC_LAYER_ENTRY = 'npcs'; private const string DATABASE_CATEGORY_ACTORS = 'actors'; private const string DATABASE_CATEGORY_CLASSES = 'classes'; private const string DATABASE_CATEGORY_SKILLS = 'skills'; @@ -142,13 +176,13 @@ final class Editor private const string DATABASE_FOCUS_CATEGORIES = 'database_categories'; private const string DATABASE_FOCUS_LIST = 'database_list'; private const string DATABASE_FOCUS_SETTINGS = 'database_settings'; - private const string DATABASE_FOCUS_FRAMES = 'database_frames'; - private const string DATABASE_FOCUS_PREVIEW = 'database_preview'; private const int CHARACTER_MAP_COLUMNS = 8; /** Rows or columns one wheel tick scrolls the canvas viewport. */ private const int WHEEL_SCROLL_ROWS = 3; private const int WINDOW_HORIZONTAL_PADDING = 1; + /** Top and bottom borders surrounding window content. */ + private const int WINDOW_BORDER_ROWS = 2; private const string GUARD_ACTION_QUIT = 'quit'; private const string GUARD_ACTION_RELOAD = 'reload'; /** @@ -162,7 +196,6 @@ final class Editor /** * The brush widths Ctrl+W cycles through. 1 is the historic single cell. */ - private const array BRUSH_SIZES = [1, 2, 3, 5]; private bool $isRunning = false; private ?ProjectWorkspace $workspace = null; @@ -377,6 +410,11 @@ final class Editor } } private ?MouseButton $activeMousePaintButton = null; + /** + * @var array{start: array{x: int, y: int}, symbol: string, piece?: bool}|null A line, rectangle, selection or piece + * area being dragged out with the mouse: the cell it started on and the symbol it draws (a space erases). + */ + private ?array $mouseToolDrag = null; /** * @var array{x: int, y: int}|null */ @@ -425,6 +463,22 @@ final class Editor * @var array|null */ private ?array $mapConditionField = null; + + /** + * The map inspector field whose writes the shared write editor is + * building, or null when it is not hosting one. + * + * @var array|null + */ + private ?array $mapWriteField = null; + + /** + * The frame of an event's inline commands the Inspector shows, by the + * event's marker; [] for the event's own rows. + * + * @var array{marker: string, frame: list}|null + */ + private ?array $eventInspectorFrame = null; /** * @var string[]|null */ @@ -471,6 +525,7 @@ final class Editor private readonly ReferencePicker $referencePicker; private readonly ConditionEditor $conditionEditor; private readonly AffinityEditor $affinityEditor; + private readonly AnimationConversionEditor $animationConversion; private readonly WorldWriteEditor $worldWriteEditor; private readonly BattleEntryPredicateEditor $battleEntryPredicateEditor; private const string WORLD_WRITE_NAME_FIELD = '__world_write_name'; @@ -493,6 +548,7 @@ final class Editor * condition rather than to set a field. */ private const string CONDITION_NAME_FIELD = '__condition_name'; + private const string CONDITION_MARKER_FIELD = '__condition_marker'; /** * The picker field id used when the map's NPC list is opened to select @@ -501,57 +557,23 @@ final class Editor private const string NPC_SELECT_FIELD = '__npc_select'; /** - * The row that chooses which natural variant the actor rows edit. It is - * a view of the pane, not a value the project stores. - */ - private const string ACTOR_VARIANT_FIELD = '__actor_variant'; - - /** - * @var array Which variant each actor's rows are editing. + * The picker field id used when the enemy list is opened to add a troop + * member fighting the one chosen. */ - private array $actorVariantSelections = []; + private const string TROOP_MEMBER_ENEMY_FIELD = '__troop_member_enemy'; - /** - * The row that chooses which permanent growth the preview assumes the - * party has earned. Earned growth lives in a save, not in a project, so - * this is a fixture for looking at and nothing the editor writes. - */ - private const string ACTOR_GROWTH_FIELD = '__actor_growth'; + /** How actors are authored: their rows, their edits, and what their panes show. */ + private readonly ActorAuthoring $actorAuthoring; /** * The picker row that clears a map's background music. */ - private const string MAP_BGM_NONE = '(None)'; - - /** - * The row that chooses which kind of slot the Optimize preview fills. - */ - private const string ACTOR_OPTIMIZE_SLOT_FIELD = '__actor_optimize_slot'; - - /** - * What the growth row reads when the preview assumes nothing was earned. - */ - private const string NO_ASSUMED_GROWTH = '(none earned yet)'; - /** - * What the growth row reads when the preview assumes all of it was. - */ - private const string ALL_ASSUMED_GROWTH = '(everything defined)'; - - /** - * @var array Which growth each actor's preview assumes. - */ - private array $actorGrowthSelections = []; - - /** - * @var array Which slot each actor's Optimize preview fills. - */ - private array $actorOptimizeSlots = []; /** * The Inspector row that assigns a stable id to an NPC authored without * one; the only time an id is ever written after creation. */ - private const string NPC_ASSIGN_ID_FIELD = '__npc_assign_id'; + private const string NPC_ASSIGN_ID_FIELD = NpcInspector::ASSIGN_ID_FIELD; private bool $isConditionNaming = false; private string $conditionNameBuffer = ''; /** @@ -583,10 +605,6 @@ final class Editor private int $databaseCategoryIndex = 9; private string $databaseFocus = self::DATABASE_FOCUS_CATEGORIES; private int $databaseSelectedActorIndex = 0; - private int $databaseSelectedClassIndex = 0; - private int $databaseSelectedSkillIndex = 0; - private int $databaseSelectedQuestIndex = 0; - private int $databaseSelectedAnimationIndex = 0; /** * Selected entry index per schema-driven category, keyed by category key. * @@ -597,9 +615,6 @@ final class Editor */ private array $databaseSelectedRecordIndexes = []; private int $databaseSelectedSettingIndex = 0; - private int $databaseSelectedFrameIndex = 1; - private int $databasePreviewCursorX = 0; - private int $databasePreviewCursorY = 0; /** * Legacy views over the database field editor; see the inspector hooks. */ @@ -625,15 +640,6 @@ final class Editor $this->databaseFieldEditor->caret = $value; } } - private string $databaseSelectedPaintSymbol = '*'; - private ?string $databaseSelectedPaintColor = 'white'; - private bool $isDatabasePreviewPlaying = false; - private int $databasePlaybackFrameIndex = 1; - /** - * When the non-blocking animation preview should advance to its next frame. - */ - private float $databasePlaybackNextFrameAt = 0.0; - private ?string $databasePlaybackFlashColor = null; /** * The idle footer message shown once every queued status expires. */ @@ -749,6 +755,7 @@ final class Editor public function __construct(private readonly string $projectRoot) { + $this->actorAuthoring = new ActorAuthoring(); $this->toasts = new ToastQueue(); $this->commandPalette = new CommandPalette(); $this->clipboard = new Clipboard(); @@ -766,6 +773,7 @@ public function __construct(private readonly string $projectRoot) $this->referencePicker = new ReferencePicker(); $this->conditionEditor = new ConditionEditor(); $this->affinityEditor = new AffinityEditor(); + $this->animationConversion = new AnimationConversionEditor(); $this->worldWriteEditor = new WorldWriteEditor(); $this->battleEntryPredicateEditor = new BattleEntryPredicateEditor(); $this->modals = new ModalStack(); @@ -805,10 +813,7 @@ function (): void { DatabaseScreen::PANE_SETTINGS => fn(array $layout) => $this->createDatabaseSettingsWindow($layout)->render(), DatabaseScreen::PANE_CUE => fn(array $layout) => $this->createDatabaseCueWindow($layout)->render(), DatabaseScreen::PANE_FRAMES => fn(array $layout) => $this->createDatabaseFramesWindow($layout)->render(), - DatabaseScreen::PANE_PREVIEW => function (array $layout): void { - $this->createDatabasePreviewWindow($layout)->render(); - $this->renderDatabasePreview($layout); - }, + DatabaseScreen::PANE_PREVIEW => fn(array $layout) => $this->createDatabasePreviewWindow($layout)->render(), ], $this->renderDatabaseEditCursor(...), fn(): bool => $this->isDatabaseEditing, @@ -912,21 +917,10 @@ private function boot(): void $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ACTORS); $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; - $this->databaseSelectedClassIndex = 0; - $this->databaseSelectedSkillIndex = 0; - $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; - $this->databaseSelectedFrameIndex = 1; - $this->databasePreviewCursorX = 0; - $this->databasePreviewCursorY = 0; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; $this->databaseEditCursorIndex = 0; - $this->databaseSelectedPaintSymbol = '*'; - $this->databaseSelectedPaintColor = 'white'; - $this->isDatabasePreviewPlaying = false; - $this->databasePlaybackFrameIndex = 1; - $this->databasePlaybackFlashColor = null; $this->statusMessage = self::STATUS_IDLE_MESSAGE; $this->statusDetailTitle = ''; $this->statusDetailLines = []; @@ -939,6 +933,7 @@ private function boot(): void $this->isHelpOpen = false; $this->isCommandPaletteOpen = false; $this->commandPalette->close(); + $this->reportRefusedMapsAtBoot(); $this->terminal->enterRawMode(); $this->inputDecoder = new InputDecoder(); $this->inputDecoder->attach(); @@ -952,6 +947,25 @@ private function boot(): void $this->requestFullRender(); } + /** Warns about maps whose authored grids cannot be safely loaded. */ + private function reportRefusedMapsAtBoot(): void + { + $refusals = []; + foreach ($this->workspace->maps as $map) { + if (($issue = $map->getGridSourceIssue()) !== null) { + $refusals[] = $issue; + } + } + + if ($refusals !== []) { + $this->setStatus( + sprintf('%d map(s) are read-only (Ctrl+E for details).', count($refusals)), + StatusLevel::WARN, + $refusals, + ); + } + } + /** * Restores the terminal after the editor exits. * @@ -975,7 +989,6 @@ private function shutdown(): void private function update(): void { $this->syncTerminalSizeIfNeeded(); - $this->tickDatabaseAnimationPreview(); $this->tickCutscenePreview(); $this->tickStatusExpiry(); } @@ -1058,7 +1071,12 @@ private function recordCommand(Command $command): void private function performUndo(): void { $this->finalizeActiveStroke(); - $command = $this->history->undo(); + try { + $command = $this->history->undo(); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Undo'); + return; + } if (! $command instanceof Command) { $this->setStatus('Nothing to undo.'); @@ -1069,7 +1087,7 @@ private function performUndo(): void $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Undid %s.', self::asPhrase($command->label)), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Undid %s.', self::asPhrase($command->label)), StatusLevel::INFO); $this->requestFullRender(); } @@ -1080,7 +1098,12 @@ private function performUndo(): void */ private function performRedo(): void { - $command = $this->history->redo(); + try { + $command = $this->history->redo(); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Redo'); + return; + } if (! $command instanceof Command) { $this->setStatus('Nothing to redo.'); @@ -1091,7 +1114,7 @@ private function performRedo(): void $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Redid %s.', self::asPhrase($command->label)), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Redid %s.', self::asPhrase($command->label)), StatusLevel::INFO); $this->requestFullRender(); } @@ -1121,16 +1144,28 @@ private function handleHistoryShortcut(string $input): bool } /** - * Records the in-flight mouse stroke as one undoable command. + * Records the in-flight mouse stroke as one undoable command, with the + * tiles that followed its glyphs. * * @return void */ private function finalizeActiveStroke(): void { $stroke = $this->activeStrokeCommand; + $tiles = $this->activeStrokeTiles; $this->activeStrokeCommand = null; + $this->activeStrokeTiles = null; + + if (! $stroke instanceof PaintStrokeCommand) { + return; + } + + if ($tiles !== null && $stroke->targets($tiles['map']) && $tiles['map']->getTileLayerSources() !== $tiles['sources']) { + $this->recordStrokeWithTiles($stroke->label, $tiles['map'], $stroke, $tiles['sources'], $tiles['map']->getTileLayerSources()); + return; + } - if ($stroke instanceof PaintStrokeCommand && $stroke->hasChanges()) { + if ($stroke->hasChanges()) { $this->recordCommand($stroke); } } @@ -1217,6 +1252,8 @@ private function buildInputRouter(): InputRouter $router->bindModal(Modal::UNSAVED_CHANGES_GUARD, $this->handleUnsavedChangesGuardInput(...)); $router->bindModal(Modal::DATABASE_ENTRY_DELETE_CONFIRMATION, $this->handleDatabaseEntryDeleteConfirmationInput(...)); $router->bindModal(Modal::RENAME_CONFIRMATION, $this->handleRenameConfirmationInput(...)); + $router->bindModal(Modal::LAYER_EDIT, $this->handleLayerPromptInput(...)); + $router->bindModal(Modal::MAP_LINES, $this->handleMapLinePromptInput(...)); $router->bindModal(Modal::COMMAND_PALETTE, $this->handleCommandPaletteInput(...)); $router->bindModal(Modal::HELP, $this->handleHelpInput(...)); $router->bindModal(Modal::DATABASE, $this->handleDatabaseInput(...)); @@ -1332,9 +1369,18 @@ private function buildInputRouter(): InputRouter // glyph. The predicates keep NPC mode's own letters and the // other panes' typing untouched. KeyBinding::when($this->isNormalModeCommand('i'), $this->enterPaintMode(...), 'i', 'Canvas: enter Paint mode (every key paints; Esc returns to Normal)'), + KeyBinding::when($this->isNormalModeCommand('L'), fn() => $this->openCanvasLayerPicker(), 'L', 'Canvas: choose the layer to edit'), + KeyBinding::when($this->isNormalModeCommand('P'), fn() => $this->openPiecePicker(), 'P', 'Canvas: choose a tileset piece to place (Enter stamps or draws it, Esc when done)'), + KeyBinding::when($this->isNormalModeCommand('K'), fn() => $this->openKindPicker(), 'K', 'Canvas: choose a terrain kind to paint (Enter paints it, Esc when done)'), + KeyBinding::when($this->isNormalModeCommand('T'), fn() => $this->drawTilesForLayerGlyphs(), 'T', 'Canvas: draw the tiles for the glyphs already on this layer'), + KeyBinding::when($this->isNormalModeCommand(']'), fn() => $this->cycleCanvasLayer(), ']', 'Canvas: next layer'), + KeyBinding::when($this->isNormalModeCommand('['), fn() => $this->cycleCanvasLayer(-1), '[', 'Canvas: previous layer'), + KeyBinding::when($this->isNormalModeCommand('v'), fn() => $this->toggleCanvasLayerVisibility(), 'v', 'Canvas: toggle selected layer visibility'), + KeyBinding::when($this->isNormalModeCommand('d'), fn() => $this->toggleCanvasLayerOption('dim'), 'd', 'Canvas: dim inactive layers'), KeyBinding::when($this->isNormalModeCommand('m'), fn() => $this->setEditingMode(self::MODE_MAP), 'm', 'Canvas: Map mode (paint tiles)'), KeyBinding::when($this->isNormalModeCommand('e'), fn() => $this->setEditingMode(self::MODE_EVENT), 'e', 'Canvas: Event mode (paint event markers)'), KeyBinding::when($this->isNormalModeCommand('n'), fn() => $this->setEditingMode(self::MODE_NPC), 'n', 'Canvas: NPC mode (place and edit the map\'s NPCs)'), + KeyBinding::when($this->isNormalModeCommand('a'), fn() => $this->setEditingMode(self::MODE_AREA), 'a', 'Canvas: Area mode (select cells for the map\'s named areas: Space a cell, r a rectangle\'s corners, Enter makes or applies)'), KeyBinding::when($this->isNormalModeCommand('c'), $this->openCharacterMap(...), 'c', 'Canvas: open the character map'), KeyBinding::when($this->isNormalModeCommand('o'), $this->openColorPicker(...), 'o', 'Canvas: colour picker (4-bit ANSI palette; sets the brush colour)'), KeyBinding::when($this->isNormalModeCommand('b'), fn() => $this->selectCanvasTool(CanvasTool::BRUSH), 'b', 'Canvas: Brush tool'), @@ -1476,7 +1522,7 @@ private function isPlainShortcut(string $normalizedInput, string $shortcut): boo private function handleAssetsPaneInput(string $input, string $normalizedInput): void { if ($this->isShiftLetterShortcut($input, 'A')) { - $this->createNewMap(); + $this->openNewMapKindPicker(); return; } @@ -1560,6 +1606,10 @@ private function isCapturingTypedText(): bool return true; } + if ($this->mapWriteField !== null && ($this->worldWriteEditor->isOpen() || $this->referencePicker->isOpen())) { + return true; + } + return $this->isNpcInspectorHosting() && ( $this->isDatabaseEditing || $this->conditionEditor->isOpen() @@ -1706,6 +1756,11 @@ private function handleCanvasPaneInput(string $input, string $normalizedInput): return; } + // Area mode owns its letters, and a typed area id owns every key. + if ($this->editingMode === self::MODE_AREA && $this->handleAreaModeInput($input)) { + return; + } + if ($input === "\n" || $input === "\r") { $this->applyCanvasToolAtCursor(); return; @@ -1721,11 +1776,16 @@ private function handleCanvasPaneInput(string $input, string $normalizedInput): return; } + if ($this->getActivePiecePlacement() !== null) { + $this->backOutOfPiecePlacement(); + return; + } + if ($this->cancelCanvasToolState()) { return; } - if ($this->editingMode === self::MODE_NPC) { + if ($this->editingMode === self::MODE_NPC || $this->editingMode === self::MODE_AREA) { $this->setEditingMode(self::MODE_MAP); return; } @@ -1823,6 +1883,12 @@ private function handleNpcInspectorInput(string $input, string $normalizedInput) return; } + if ($this->animationConversion->isOpen()) { + $this->handleAnimationConversionInput($input); + + return; + } + if ($this->worldWriteEditor->isOpen()) { $this->handleWorldWriteEditorInput($input); @@ -1877,6 +1943,13 @@ private function handleNpcInspectorInput(string $input, string $normalizedInput) return; } + if (($input === '[' || $input === ']') && $this->databaseCommandFramePath === []) { + // Talking runs the first available page: the order is the NPC's priority. + $this->moveDatabaseNpcPage($input === '[' ? -1 : 1); + + return; + } + if (str_contains($input, "\033[A") || str_contains($input, "\033[B")) { // Skip group headings: they are labels, not rows to land on. $fields = $this->getInspectorFields(); @@ -1911,112 +1984,146 @@ private function handleNpcInspectorInput(string $input, string $normalizedInput) */ private function addDatabaseNpcSubItem(): void { - $map = $this->getSelectedMap(); + $this->changeNpcSubItem(true); + } + + /** + * Removes the sub-item the Inspector cursor points at. + * + * @return void + */ + private function removeDatabaseNpcSubItem(): void + { + $this->changeNpcSubItem(false); + } + + /** + * Moves the page at the Inspector cursor one place earlier or later + * through the shared NPC authoring rules, as one undo step, keeping the + * cursor on the moved page. + * + * @param int $step -1 for earlier, 1 for later. + * @return void + */ + private function moveDatabaseNpcPage(int $step): void + { + $authoring = $this->createNpcAuthoring(); $index = $this->selectedNpcIndex; - if (! $map instanceof ProjectMap || $index === null || $this->npcInspector === null) { + if (! $authoring instanceof NpcAuthoring || $index === null || $this->npcInspector === null) { return; } - $records = $this->npcInspector->records(); - $before = $map->getNpcs(); - $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); + $fieldId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - $nested = $this->databaseCommandFramePath !== [] - ? $records->frameNestedContext($index, $this->databaseCommandFramePath, $selectedId) - : null; + try { + $change = $authoring->moveSubItem($this->npcInspector, $index, $fieldId, $step); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); - if ($nested !== null) { - // A route step under a command in the frame. - $records->addFrameNestedItem($index, $this->databaseCommandFramePath, $nested['parentIndex']); - } elseif ($this->databaseCommandFramePath !== []) { - $after = preg_match('/^command(\\d+)/', $selectedId, $m) === 1 ? intval($m[1]) : null; - $records->addFrameCommand($index, $this->databaseCommandFramePath, $after); - } elseif (preg_match('/^variant(\\d+)Line/', $selectedId, $m) === 1) { - $records->addNestedSubItem($index, intval($m[1])); - } else { - $records->addSubItem($index); + return; + } + + $this->recordNpcChange($change, $index, $index); + + if (preg_match('/^variant(\d+)(.*)$/', $fieldId, $matches) === 1) { + $moved = 'variant' . (intval($matches[1]) + $step) . $matches[2]; + $at = array_search($moved, array_map(static fn(array $field): string => (string) ($field['field'] ?? ''), $this->getDatabaseSettingsFields()), true); + $this->databaseSelectedSettingIndex = is_int($at) ? $at : $this->databaseSelectedSettingIndex; } - $this->npcInspector->commit(); - $this->recordNpcCollectionChange($map, $index, $before, 'NPC add'); + $this->clampDatabaseSettingSelection(); + $this->requestFullRender(); } /** - * Removes the sub-item the Inspector cursor points at. + * Adds or removes the sub-item at the Inspector cursor through the + * shared NPC authoring rules ({@see NpcAuthoring::addSubItem()}), as + * one undo step. * + * @param bool $add True to add, false to remove. * @return void */ - private function removeDatabaseNpcSubItem(): void + private function changeNpcSubItem(bool $add): void { - $map = $this->getSelectedMap(); + $authoring = $this->createNpcAuthoring(); $index = $this->selectedNpcIndex; - if (! $map instanceof ProjectMap || $index === null || $this->npcInspector === null) { + if (! $authoring instanceof NpcAuthoring || $index === null || $this->npcInspector === null) { return; } - $records = $this->npcInspector->records(); - $before = $map->getNpcs(); - $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); + $fieldId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - $nested = $this->databaseCommandFramePath !== [] - ? $records->frameNestedContext($index, $this->databaseCommandFramePath, $selectedId) - : null; + try { + $change = $add + ? $authoring->addSubItem($this->npcInspector, $index, $this->databaseCommandFramePath, $fieldId) + : $authoring->removeSubItem($this->npcInspector, $index, $this->databaseCommandFramePath, $fieldId); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); - if ($nested !== null && $nested['nestedIndex'] !== null) { - // The step under the cursor; a command row removes the command. - $records->removeFrameNestedItem($index, $this->databaseCommandFramePath, $nested['parentIndex'], $nested['nestedIndex']); - } elseif ($this->databaseCommandFramePath !== []) { - if (preg_match('/^command(\\d+)/', $selectedId, $m) === 1) { - $records->removeFrameCommand($index, $this->databaseCommandFramePath, intval($m[1])); - } - } elseif (preg_match('/^variant(\\d+)Line(\\d+)/', $selectedId, $m) === 1) { - $records->removeNestedSubItem($index, intval($m[1]), intval($m[2])); - } elseif (preg_match('/^variant(\\d+)/', $selectedId, $m) === 1) { - $records->removeSubItem($index, intval($m[1])); - } else { return; } - $this->npcInspector->commit(); - $this->recordNpcCollectionChange($map, $index, $before, 'NPC remove'); + $this->recordNpcChange($change, $index, $index); + $this->clampDatabaseSettingSelection(); + $this->requestFullRender(); + } + + /** + * Returns the shared NPC authoring rules over the open project. + * + * @return NpcAuthoring|null The rules, or null with no project open. + */ + private function createNpcAuthoring(): ?NpcAuthoring + { + return $this->workspace instanceof ProjectWorkspace ? new NpcAuthoring($this->workspace) : null; } /** - * Records a structural NPC change as one undo step, when it changed - * anything. + * Records an NPC change as one undo step, when it changed anything. * - * @param ProjectMap $map The map. - * @param int $index The NPC. - * @param \Ichiloto\Editor\Field\NpcCollection $before The collection before. - * @param string $label The history label. + * The change's own command restores the map's NPCs; the shell adds + * only what it shows, selecting the NPC the step lands on. + * + * @param NpcChange $change The applied change. + * @param int|null $redoSelection The NPC to select after a redo. + * @param int|null $undoSelection The NPC to select after an undo. * @return void */ - private function recordNpcCollectionChange(ProjectMap $map, int $index, \Ichiloto\Editor\Field\NpcCollection $before, string $label): void + private function recordNpcChange(NpcChange $change, ?int $redoSelection, ?int $undoSelection): void { - $after = $map->getNpcs(); $this->refreshNpcInspector(); + $command = $change->command; - if ($after->toMapData() === $before->toMapData()) { - $this->requestFullRender(); - + if ($command === null) { return; } $this->recordCommand(new GenericCommand( - $label, - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); + $command->label, + function () use ($command, $redoSelection): void { + $command->execute(); + $this->selectNpc($redoSelection); }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); + function () use ($command, $undoSelection): void { + $command->undo(); + $this->selectNpc($undoSelection); }, )); - $this->clampDatabaseSettingSelection(); - $this->requestFullRender(); + } + + /** + * Shows why an NPC change was refused: a warning, or an error listing + * what stands in the way when the refusal names it. + * + * @param NpcRefusal $refusal The refusal. + * @return void + */ + private function reportNpcRefusal(NpcRefusal $refusal): void + { + $this->setStatus($refusal->getMessage(), $refusal->details === [] ? StatusLevel::WARN : StatusLevel::ERROR, $refusal->details); + $this->renderFooter(); } /** @@ -2030,7 +2137,9 @@ private function isNpcInspectorHosting(): bool } /** - * Returns the selected NPC's record-pane fields, grouped and framed. + * Returns the selected NPC's rows, grouped and framed + * ({@see NpcInspector::getFields()}). A frame that no longer resolves + * is left for the NPC's own rows; an empty one still resolves. * * @return array> The field descriptors. */ @@ -2040,166 +2149,21 @@ private function getNpcInspectorFields(): array return []; } - $records = $this->npcInspector->records(); - $fields = $records->getFrameSettingsFields($this->selectedNpcIndex, $this->databaseCommandFramePath); + $fields = $this->npcInspector->getFields($this->selectedNpcIndex, $this->databaseCommandFramePath); - if ( - $this->databaseCommandFramePath !== [] - && $records->getFrameCommands($this->selectedNpcIndex, $this->databaseCommandFramePath) === null - ) { - // The frame no longer resolves; an empty one still does. + if ($fields === null) { $this->databaseCommandFramePath = []; - $fields = $records->getFrameSettingsFields($this->selectedNpcIndex, []); - } - - if ($this->databaseCommandFramePath !== []) { - return $fields; - } - - // Group headings and honest notes, without changing any field id. - $npc = $this->getSelectedMap()?->getNpcs()->get($this->selectedNpcIndex); - $grouped = []; - $group = static fn(string $title): array => ['label' => $title, 'value' => '', 'editable' => false]; - $notes = []; - - if ($npc !== null && $npc->getId() === null) { - // Legacy entry: nothing can name an id it never had, so giving - // it one is the one identity write that is safe after creation. - $notes[] = [ - 'label' => ' ! No stable id', - 'value' => 'move_route cannot target it; Enter assigns one from the name', - 'editable' => true, - 'field' => self::NPC_ASSIGN_ID_FIELD, - ]; - } - - if ($npc !== null && $npc->scriptShadowsDialogue()) { - $notes[] = ['label' => ' ! Script replaces dialogue', 'value' => 'the game runs the script', 'editable' => false]; - } - - if ($npc !== null && $npc->getUnknownFields() !== []) { - $notes[] = ['label' => ' Preserved fields', 'value' => implode(', ', $npc->getUnknownFields()), 'editable' => false]; - } - - $sections = [ - 'Identity' => ['id', 'name'], - 'Placement' => ['x', 'y'], - 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], - 'Movement' => ['movement', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], - 'Visibility' => ['conditions'], - 'Interaction' => ['commandListScript'], - 'Completion Writes' => ['sets'], - ]; - $byId = []; - - foreach ($fields as $field) { - $byId[(string) ($field['field'] ?? '')][] = $field; - } - - foreach ($sections as $title => $ids) { - $rows = []; - - foreach ($ids as $id) { - foreach ($byId[$id] ?? [] as $field) { - // Wander bounds only matter while wandering; loaded - // values are kept, just not shown for a fixed NPC. - if (! (str_starts_with($id, 'wanderArea.') && $npc !== null && ! $npc->wanders())) { - $rows[] = $field; - } - } - - unset($byId[$id]); - } - - if ($rows !== []) { - $grouped[] = $group($title); - $grouped = [...$grouped, ...$rows]; - } - - if ($title === 'Identity') { - $grouped = [...$grouped, ...$notes]; - } - - if ($title === 'Interaction') { - // Everything left is dialogue: variants, their lines, and - // their frames, each variant under its own heading. - [$variantRows, $byId] = $this->groupNpcVariantRows($byId); - $grouped = [...$grouped, ...$variantRows]; - } - } - - foreach ($byId as $rest) { - $grouped = [...$grouped, ...$rest]; - } - - return $grouped; - } - - /** - * Turns the record pane's variant rows into headed groups: one - * `Dialogue variant N` heading per variant (with its condition line - * when it has one), then that variant's rows under short labels -- - * `When`, `Then Set`, `Script Commands`, `Line 1 Speaker`, `Line 1 - * Text` -- so the label no longer eats the pane before the value - * starts. Field ids are untouched; this is the grouped view's - * presentation of the record layer's own rows. - * - * @param array>> $byId The remaining rows, keyed by field id. - * @return array{0: array>, 1: array>>} The headed rows, and what was left. - */ - private function groupNpcVariantRows(array $byId): array - { - $singular = ucfirst(\Ichiloto\Editor\Database\RecordSchemaCatalog::mapNpcs()->subList?->singular ?? 'dialogue variant'); - $variants = []; - - foreach ($byId as $id => $rows) { - if (preg_match('/^variant(\d+)/', $id, $matches) !== 1) { - continue; - } - - $variants[intval($matches[1])] = [...($variants[intval($matches[1])] ?? []), ...$rows]; - unset($byId[$id]); - } - - ksort($variants); - $headed = []; - - foreach ($variants as $number => $rows) { - $prefix = sprintf('%s %d ', $singular, $number + 1); - $when = ''; - - foreach ($rows as $row) { - if (($row['field'] ?? null) === sprintf('variant%dConditions', $number)) { - $when = trim((string) ($row['value'] ?? '')); - } - } - - // The condition line rides as the heading's value, so it reads - // "Dialogue variant 2 · when …" and wraps rather than clips. - $headed[] = [ - 'label' => sprintf('%s %d', $singular, $number + 1), - 'value' => $when === '' ? '' : 'when ' . $when, - 'editable' => false, - ]; - - foreach ($rows as $row) { - $label = (string) ($row['label'] ?? ''); - - if (str_starts_with($label, $prefix)) { - $row['label'] = substr($label, strlen($prefix)); - } - - $headed[] = $row; - } + $fields = $this->npcInspector->getFields($this->selectedNpcIndex) ?? []; } - return [$headed, $byId]; + return $fields; } /** - * Applies an NPC field edit through the record pane and the map, and - * records it: the undo restores the whole previous collection, so list - * position and every other field come back exactly. + * Applies an NPC field edit through the shared authoring rules + * ({@see NpcAuthoring::applyField()}) and records it: the undo restores + * the whole previous collection, so list position and every other field + * come back exactly. A rename says what became of the id. * * @param array $field The field descriptor. * @param string $rawValue The raw value. @@ -2207,38 +2171,28 @@ private function groupNpcVariantRows(array $byId): array */ private function applyNpcFieldValueRecorded(array $field, string $rawValue): void { - $map = $this->getSelectedMap(); + $authoring = $this->createNpcAuthoring(); $index = $this->selectedNpcIndex; - if (! $map instanceof ProjectMap || $index === null || $this->npcInspector === null) { + if (! $authoring instanceof NpcAuthoring || $index === null || $this->npcInspector === null) { return; } - $before = $map->getNpcs(); - $fieldId = (string) ($field['field'] ?? ''); - $this->npcInspector->records()->setFrameField($index, $this->databaseCommandFramePath, $fieldId, $rawValue); - $this->npcInspector->commit(); - $after = $map->getNpcs(); - - if ($after->toMapData() === $before->toMapData()) { - // A same-value edit: no history, no dirt. - $this->refreshNpcInspector(); + try { + $change = $authoring->applyField($this->npcInspector, $index, $this->databaseCommandFramePath, $field, $rawValue); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); return; } - $this->refreshNpcInspector(); - $this->recordCommand(new GenericCommand( - sprintf('NPC %s edit', $field['label'] ?? 'field'), - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); - }, - )); + if ($change->followedId !== null) { + $this->setStatus(sprintf('Renamed. Its id is now %s.', $change->followedId), StatusLevel::INFO); + } elseif ($change->idReferences !== []) { + $this->setStatus(sprintf('Renamed. Its id stays %s: %s names it.', $change->npc?->getId() ?? '', implode(', ', $change->idReferences))); + } + + $this->recordNpcChange($change, $index, $index); } /** @@ -2282,10 +2236,12 @@ private function handleNpcModeInput(string $input): bool return true; } - $under = $map->getNpcs()->indexAt($this->cursorX, $this->cursorY); + $under = $map->getNpcs()->indicesAt($this->cursorX, $this->cursorY); - if ($under !== null) { - $this->selectNpc($under); + if ($under !== []) { + // Phases of one presence share a tile: Enter steps through them. + $current = array_search($this->selectedNpcIndex, $under, true); + $this->selectNpc($under[$current === false ? 0 : ($current + 1) % count($under)]); $this->setStatus(sprintf('Selected %s.', $this->describeSelectedNpc())); $this->renderCanvasArea(); @@ -2365,8 +2321,8 @@ private function selectNpc(?int $index): void * Gives an NPC authored without a stable id one, derived from its name * and unique on its map, so movement routes can target it. * - * Only an id-less NPC is eligible: an existing id is immutable, since - * routes and diagnostics that name it would not follow a change. + * Only an id-less NPC is eligible: an existing id changes through its + * Id row, which refuses while routes or scripts name it. * * @return void */ @@ -2374,24 +2330,24 @@ private function assignIdToSelectedNpc(): void { $map = $this->getSelectedMap(); $index = $this->selectedNpcIndex; - $npc = $index !== null ? $map?->getNpcs()->get($index) : null; + $authoring = $this->createNpcAuthoring(); - if (! $map instanceof ProjectMap || $index === null || $npc === null) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $index === null || $map->getNpcs()->get($index) === null) { return; } - if ($npc->getId() !== null) { - $this->setStatus(sprintf('%s already has the stable id "%s"; ids do not change.', $npc->getName(), $npc->getId()), StatusLevel::WARN); - $this->renderFooter(); + try { + $change = $authoring->assignId($map, $index); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); return; } - $before = $map->getNpcs(); - $id = $before->uniqueIdFor($npc->getName()); - $map->setNpcs($before->withReplaced($index, $npc->asCopyWithId($id))); - $this->recordNpcCollectionChange($map, $index, $before, sprintf('Assign NPC id %s', $id)); - $this->setStatus(sprintf('Assigned the stable id "%s" to %s.', $id, $npc->getName()), StatusLevel::SUCCESS); + $this->recordNpcChange($change, $index, $index); + $this->clampDatabaseSettingSelection(); + $this->requestFullRender(); + $this->setStatus(sprintf('Assigned the stable id "%s" to %s.', $change->npc?->getId() ?? '', $change->npc?->getName() ?? ''), StatusLevel::INFO); } /** @@ -2566,7 +2522,9 @@ private function previewedNpcSprite(): ?string private function refreshNpcInspector(): void { $map = $this->getSelectedMap(); - $this->npcInspector = $map instanceof ProjectMap ? new NpcInspector($map) : null; + // The references the session's NPC pane uses: choices that depend on the project, such as a line's emotions. + $this->npcInspector = $map instanceof ProjectMap && $this->workspace instanceof ProjectWorkspace + ? new NpcInspector($map, references: $this->referenceCatalog()) : null; if ($map instanceof ProjectMap && $this->selectedNpcIndex !== null && $map->getNpcs()->get($this->selectedNpcIndex) === null) { $this->selectedNpcIndex = $map->getNpcs()->count() > 0 ? min($this->selectedNpcIndex, $map->getNpcs()->count() - 1) : null; @@ -2635,7 +2593,8 @@ private function handleNpcNameInput(string $input): void $this->npcNameBuffer = ''; if ($tile !== null) { - $this->createNpcAt($tile['x'], $tile['y'], $name !== '' ? $name : 'New NPC'); + // A blank name takes the shared placeholder rather than refusing. + $this->createNpcAt($tile['x'], $tile['y'], $name); } return; @@ -2671,49 +2630,41 @@ private function buildNpcNamePromptRows(): array sprintf('New NPC at (%d, %d)', $tile['x'], $tile['y']), '', sprintf('Name: %s', $this->npcNameBuffer), - $preview !== '' ? sprintf('Id: %s', $preview) : 'Id: (derived from the name, once)', + $preview !== '' ? sprintf('Id: %s', $preview) : 'Id: (derived from the name)', '', ' Enter creates it, Esc cancels.', ]; } /** - * Creates a fixed NPC at a tile under a stable id derived from its name. + * Creates a fixed NPC at a tile under a stable id derived from its name + * ({@see NpcAuthoring::create()}). * * @param int $x The anchor column. * @param int $y The anchor row. - * @param string $name The display name. + * @param string $name The display name; blank takes the placeholder. * @return void */ private function createNpcAt(int $x, int $y, string $name): void { $map = $this->getSelectedMap(); + $authoring = $this->createNpcAuthoring(); - if (! $map instanceof ProjectMap) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring) { return; } - $collection = $map->getNpcs(); - $id = $collection->uniqueIdFor($name); - $npc = ProjectNpc::createAt($id, $name, $x, $y); - $index = $collection->count(); - $before = $collection; - $after = $collection->withAdded($npc); + try { + $change = $authoring->create($map, $x, $y, $name); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); - $map->setNpcs($after); - $this->selectNpc($index); - $this->recordCommand(new GenericCommand( - 'NPC create', - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before): void { - $map->setNpcs($before); - $this->selectNpc(null); - }, - )); - $this->setStatus(sprintf('Created %s. Its id is %s.', $name, $id), StatusLevel::SUCCESS); + return; + } + + $this->selectNpc($change->index); + $this->recordNpcChange($change, $change->index, null); + $this->setStatus(sprintf('Created %s. Its id is %s.', $change->npc?->getName() ?? '', $change->npc?->getId() ?? ''), StatusLevel::INFO); $this->focusedPane = self::FOCUS_INSPECTOR; $this->requestFullRender(); } @@ -2756,7 +2707,7 @@ private function finishNpcMove(): void } /** - * Moves an NPC to a tile, recorded for undo. + * Moves an NPC to a tile, recorded for undo ({@see NpcAuthoring::move()}). * * @param int $index The NPC's position. * @param int $x The destination column. @@ -2766,154 +2717,96 @@ private function finishNpcMove(): void private function moveNpc(int $index, int $x, int $y): void { $map = $this->getSelectedMap(); - $collection = $map?->getNpcs(); - $npc = $collection?->get($index); + $authoring = $this->createNpcAuthoring(); - if (! $map instanceof ProjectMap || $collection === null || $npc === null) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $map->getNpcs()->get($index) === null) { return; } - if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { - $this->setStatus(sprintf('%d,%d is outside the map.', $x, $y), StatusLevel::WARN); - $this->renderFooter(); + try { + $change = $authoring->move($map, $index, $x, $y); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); return; } - if ($npc->getX() === $x && $npc->getY() === $y) { + if ($change->command === null) { $this->setStatus('Already there.'); $this->renderFooter(); return; } - $occupant = $collection->indexAt($x, $y); - - if ($occupant !== null && $occupant !== $index) { - $this->setStatus(sprintf('%s already stands at %d,%d.', $collection->get($occupant)?->getName() ?? 'An NPC', $x, $y), StatusLevel::WARN); - $this->renderFooter(); - - return; - } - - $before = $collection; - $after = $collection->withReplaced($index, $npc->movedTo($x, $y)); - $map->setNpcs($after); - $this->refreshNpcInspector(); - $this->recordCommand(new GenericCommand( - 'NPC move', - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); - }, - )); - $this->setStatus(sprintf('Moved %s to %d,%d.', $npc->getName(), $x, $y), StatusLevel::SUCCESS); + $this->recordNpcChange($change, $index, $index); + $this->setStatus(sprintf('Moved %s to %d,%d.', $change->npc?->getName() ?? '', $x, $y), StatusLevel::INFO); $this->requestFullRender(); } /** * Duplicates the selected NPC under a fresh unique id, one tile to the - * right when that tile is free. + * right when that tile is free ({@see NpcAuthoring::duplicate()}). * * @return void */ private function duplicateSelectedNpc(): void { $map = $this->getSelectedMap(); - $collection = $map?->getNpcs(); - $npc = $this->selectedNpcIndex !== null ? $collection?->get($this->selectedNpcIndex) : null; + $authoring = $this->createNpcAuthoring(); + $index = $this->selectedNpcIndex; - if (! $map instanceof ProjectMap || $collection === null || $npc === null) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $index === null || $map->getNpcs()->get($index) === null) { $this->setStatus('Select an NPC first (Enter on it).', StatusLevel::WARN); $this->renderFooter(); return; } - $id = $collection->uniqueIdFor($npc->getName()); - $copy = $npc->asCopyWithId($id); - $x = $npc->getX() + $npc->getSpriteWidth(); + try { + $change = $authoring->duplicate($map, $index); + } catch (NpcRefusal $refusal) { + $this->reportNpcRefusal($refusal); - if ($x < $map->getWidth() && $collection->indexAt($x, $npc->getY()) === null) { - $copy = $copy->movedTo($x, $npc->getY()); + return; } - $index = $collection->count(); - $before = $collection; - $after = $collection->withAdded($copy); - $map->setNpcs($after); - $this->selectNpc($index); - $this->recordCommand(new GenericCommand( - 'NPC duplicate', - function () use ($map, $after, $index): void { - $map->setNpcs($after); - $this->selectNpc($index); - }, - function () use ($map, $before): void { - $map->setNpcs($before); - $this->selectNpc(null); - }, - )); - $this->setStatus(sprintf('Duplicated as %s (id %s).', $copy->getName(), $id), StatusLevel::SUCCESS); + $this->selectNpc($change->index); + $this->recordNpcChange($change, $change->index, null); + $this->setStatus(sprintf('Duplicated as %s (id %s).', $change->npc?->getName() ?? '', $change->npc?->getId() ?? ''), StatusLevel::INFO); $this->requestFullRender(); } /** - * Deletes the selected NPC, refusing while anything names its id. + * Deletes the selected NPC, refusing while anything names its id + * ({@see NpcAuthoring::delete()}). * * @return void */ private function deleteSelectedNpc(): void { $map = $this->getSelectedMap(); - $collection = $map?->getNpcs(); + $authoring = $this->createNpcAuthoring(); $index = $this->selectedNpcIndex; - $npc = $index !== null ? $collection?->get($index) : null; - if (! $map instanceof ProjectMap || $collection === null || $index === null || $npc === null || ! $this->workspace instanceof ProjectWorkspace) { + if (! $map instanceof ProjectMap || ! $authoring instanceof NpcAuthoring || $index === null || $map->getNpcs()->get($index) === null) { $this->setStatus('Select an NPC first (Enter on it).', StatusLevel::WARN); $this->renderFooter(); return; } - $references = $npc->getId() !== null - ? new NpcReferences($this->workspace)->describe($map, $npc->getId()) - : []; - - if ($references !== []) { - // Refusing beats a route or script that silently stops - // resolving. The list is what the author needs to go fix. - $this->setStatus( - sprintf('%s is named by %s - resolve those before deleting.', $npc->getName(), implode(', ', $references)), - StatusLevel::ERROR, - array_map(static fn(string $reference): string => '- ' . $reference, $references), - ); - $this->renderFooter(); + try { + $change = $authoring->delete($map, $index); + } catch (NpcRefusal $refusal) { + // The refusal lists what names the NPC: what the author goes to fix. + $this->reportNpcRefusal($refusal); return; } - $before = $collection; - $after = $collection->withRemoved($index); - $map->setNpcs($after); $this->selectNpc(null); - $this->recordCommand(new GenericCommand( - 'NPC delete', - function () use ($map, $after): void { - $map->setNpcs($after); - $this->selectNpc(null); - }, - function () use ($map, $before, $index): void { - $map->setNpcs($before); - $this->selectNpc($index); - }, - )); - $this->setStatus(sprintf('Deleted %s.', $npc->getName()), StatusLevel::SUCCESS); + $this->recordNpcChange($change, null, $index); + $this->setStatus(sprintf('Deleted %s.', $change->npc?->getName() ?? ''), StatusLevel::INFO); $this->requestFullRender(); } @@ -2948,12 +2841,33 @@ private function handleInspectorPaneInput(string $input, string $normalizedInput return; } - if ($input === "\n" || $input === "\r") { - $this->activateInspectorField(); - return; - } + if ($this->mapWriteField !== null && ($this->worldWriteEditor->isOpen() || $this->referencePicker->isOpen())) { + // An event's completion writes, in the shared write editor the NPC pane hosts. + $this->referencePicker->isOpen() + ? $this->handleReferencePickerInput($input) + : $this->handleWorldWriteEditorInput($input); - if ($this->isShiftLetterShortcut($input, 'O')) { + if (! $this->worldWriteEditor->isOpen() && ! $this->referencePicker->isOpen()) { + $this->mapWriteField = null; + } + + $this->renderInspectorArea(); + + return; + } + + if (($input === "\033" || $input === "\x1b") && $this->getOpenEventInspectorFrame() !== []) { + $this->leaveEventInspectorFrame(); + + return; + } + + if ($input === "\n" || $input === "\r") { + $this->activateInspectorField(); + return; + } + + if ($this->isShiftLetterShortcut($input, 'O')) { $this->addInspectorListItem(); return; } @@ -3007,17 +2921,10 @@ private function openDatabaseWindow(): void $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ACTORS); $this->databaseFocus = self::DATABASE_FOCUS_CATEGORIES; $this->databaseSelectedActorIndex = 0; - $this->databaseSelectedClassIndex = 0; - $this->databaseSelectedSkillIndex = 0; - $this->databaseSelectedQuestIndex = 0; $this->databaseSelectedSettingIndex = 0; - $this->databaseSelectedFrameIndex = 1; $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; $this->databaseEditCursorIndex = 0; - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; - $this->databasePlaybackFlashColor = null; - $this->centerDatabasePreviewCursor(); $this->statusMessage = 'Database open.'; $this->renderDatabaseArea(includeRoot: true); } @@ -3033,8 +2940,6 @@ private function closeDatabaseWindow(): void $this->isDatabaseEditing = false; $this->databaseEditBuffer = ''; $this->databaseEditCursorIndex = 0; - $this->isDatabasePreviewPlaying = false; - $this->databasePlaybackFlashColor = null; $this->statusMessage = 'Database closed.'; $this->requestFullRender(); } @@ -3068,6 +2973,11 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } + if ($this->animationConversion->isOpen()) { + $this->handleAnimationConversionInput($input); + return; + } + if ($this->worldWriteEditor->isOpen()) { $this->handleWorldWriteEditorInput($input); return; @@ -3194,26 +3104,43 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } - if ($this->isQuestsDatabaseSelected() && $this->isShiftLetterShortcut($input, 'O')) { - $this->addDatabaseQuestObjective(); + // Shift+T converts a legacy cell-frame animation to a timeline. + if ($this->getSelectedDatabaseCategoryDefinition()->key === 'animations' && $this->isShiftLetterShortcut($input, 'T')) { + $this->openAnimationConversion(); return; } - if ($this->isQuestsDatabaseSelected() && $this->isShiftLetterShortcut($input, 'X')) { - $this->removeDatabaseQuestObjective(); - return; + // A troop member is added by choosing its enemy first, as RPG Maker's + // Troops tab adds one; Shift+N names the troop after its members and + // Shift+C clears them. + if ($this->getSelectedDatabaseCategoryDefinition()->key === 'troops') { + // On an event page, or inside one's commands, Shift+O adds there instead. + if ($this->isShiftLetterShortcut($input, 'O') && $this->isTroopMemberContext()) { + $this->openTroopMemberPicker(); + return; + } + + if ($this->isShiftLetterShortcut($input, 'N')) { + $this->nameTroopAfterMembers(); + return; + } + + if ($this->isShiftLetterShortcut($input, 'C')) { + $this->clearTroopMembers(); + return; + } } // Shift+O/Shift+X are the one sub-list idiom: quest objectives, skit // beats, troop members, and event-script commands all use them. - if ($this->getSelectedRecordDatabase()?->schema->subList !== null && $this->isShiftLetterShortcut($input, 'O')) { + if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== [] && $this->isShiftLetterShortcut($input, 'O')) { $this->selectedDatabaseNestedContext() !== null ? $this->addDatabaseNestedSubItem() : $this->addDatabaseRecordSubItem(); return; } - if ($this->getSelectedRecordDatabase()?->schema->subList !== null && $this->isShiftLetterShortcut($input, 'X')) { + if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== [] && $this->isShiftLetterShortcut($input, 'X')) { $this->selectedDatabaseNestedContext() !== null ? $this->removeDatabaseNestedSubItem() : $this->removeDatabaseRecordSubItem(); @@ -3230,15 +3157,6 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo return; } - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW && ($input === "\n" || $input === "\r")) { - $this->paintDatabasePreviewSymbol($this->databaseSelectedPaintSymbol); - return; - } - - if ($this->isShiftLetterShortcut($input, 'P')) { - $this->playDatabaseAnimationPreview(); - return; - } if (str_contains($input, "\033[A")) { $this->moveDatabaseSelection(0, -1); @@ -3269,15 +3187,6 @@ private function handleDatabaseInput(string $input, string $normalizedInput): vo $this->moveDatabaseSelection(1, 0); return; } - - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW) { - if ($input === "\177" || $input === "\010") { - $this->paintDatabasePreviewSymbol(' '); - return; - } - - $this->handleDatabaseTypedSymbolInput($input); - } } /** @@ -3292,8 +3201,6 @@ private function cycleDatabaseFocus(int $step): void self::DATABASE_FOCUS_CATEGORIES, self::DATABASE_FOCUS_LIST, self::DATABASE_FOCUS_SETTINGS, - self::DATABASE_FOCUS_FRAMES, - self::DATABASE_FOCUS_PREVIEW, ]; $currentIndex = array_search($this->databaseFocus, $paneOrder, true); $currentIndex = is_int($currentIndex) ? $currentIndex : 0; @@ -3330,16 +3237,6 @@ private function moveDatabaseSelection(int $deltaX, int $deltaY): void if ($this->databaseFocus === self::DATABASE_FOCUS_SETTINGS) { $this->moveDatabaseSettingsSelection($deltaY); - return; - } - - if ($this->databaseFocus === self::DATABASE_FOCUS_FRAMES) { - $this->moveDatabaseFrameSelection($deltaY); - return; - } - - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW) { - $this->moveDatabasePreviewCursor($deltaX, $deltaY); } } @@ -3377,27 +3274,8 @@ private function moveDatabaseListSelection(int $step): void return; } - if ($this->isClassesDatabaseSelected()) { - $this->moveDatabaseClassSelection($step); - return; - } - - if ($this->isSkillsDatabaseSelected()) { - $this->moveDatabaseSkillSelection($step); - return; - } - - if ($this->isQuestsDatabaseSelected()) { - $this->moveDatabaseQuestSelection($step); - return; - } - - if ($this->isAnimationsDatabaseSelected()) { - $this->moveDatabaseAnimationSelection($step); - return; - } - $this->moveDatabaseRecordSelection($step); + } /** @@ -3426,117 +3304,6 @@ private function moveDatabaseActorSelection(int $step): void $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } - /** - * Moves the selected class entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseClassSelection(int $step): void - { - $classes = $this->workspace?->classDatabase->getClasses() ?? []; - - if ($classes === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedClassIndex, $step); - - if ($nextIndex === $this->databaseSelectedClassIndex) { - return; - } - - $this->databaseSelectedClassIndex = $nextIndex; - $this->databaseSelectedSettingIndex = 0; - $this->statusMessage = sprintf('Selected class %s.', $classes[$nextIndex]->getName()); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - /** - * Moves the selected skill entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseSkillSelection(int $step): void - { - $skills = $this->workspace?->skillDatabase->getSkills() ?? []; - - if ($skills === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedSkillIndex, $step); - - if ($nextIndex === $this->databaseSelectedSkillIndex) { - return; - } - - $this->databaseSelectedSkillIndex = $nextIndex; - $this->databaseSelectedSettingIndex = 0; - $this->statusMessage = sprintf("Selected skill %s.", $skills[$nextIndex]->getName()); - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - } - - - /** - * Moves the selected quest entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseQuestSelection(int $step): void - { - $quests = $this->workspace?->questDatabase->getQuests() ?? []; - - if ($quests === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedQuestIndex, $step); - - if ($nextIndex === $this->databaseSelectedQuestIndex) { - return; - } - - $this->databaseSelectedQuestIndex = $nextIndex; - $this->databaseSelectedSettingIndex = 0; - $this->statusMessage = sprintf('Selected quest %s.', $quests[$nextIndex]->getName()); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - - /** - * Moves the selected animation entry. - * - * @param int $step The entry step. - * @return void - */ - private function moveDatabaseAnimationSelection(int $step): void - { - if (! $this->isAnimationsDatabaseSelected()) { - return; - } - - $animations = $this->workspace?->animationDatabase->getAnimations() ?? []; - - if ($animations === []) { - return; - } - - $nextIndex = $this->resolveDatabaseSelectionStep($this->databaseSelectedAnimationIndex, $step); - - if ($nextIndex === $this->databaseSelectedAnimationIndex) { - return; - } - - $this->databaseSelectedAnimationIndex = $nextIndex; - $this->databaseSelectedFrameIndex = 1; - $this->databaseSelectedSettingIndex = 0; - $this->databasePlaybackFrameIndex = 1; - $this->centerDatabasePreviewCursor(); - $this->statusMessage = sprintf('Selected animation %s.', $animations[$nextIndex]->name); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - /** * Moves the selected settings field. * @@ -3561,54 +3328,6 @@ private function moveDatabaseSettingsSelection(int $step): void $this->renderDatabasePanes(['settings']); } - /** - * Moves the selected frame index. - * - * @param int $step The frame step. - * @return void - */ - private function moveDatabaseFrameSelection(int $step): void - { - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $nextIndex = ListNavigation::step($this->databaseSelectedFrameIndex - 1, $step, $animation->maxFrames) + 1; - - if ($nextIndex === $this->databaseSelectedFrameIndex) { - return; - } - - $this->databaseSelectedFrameIndex = $nextIndex; - $this->databasePlaybackFrameIndex = $nextIndex; - $this->statusMessage = sprintf('Frame #%03d selected.', $nextIndex); - $this->renderDatabasePanes(['settings', 'cue', 'frames', 'preview']); - } - - /** - * Moves the animation preview cursor. - * - * @param int $deltaX The horizontal movement amount. - * @param int $deltaY The vertical movement amount. - * @return void - */ - private function moveDatabasePreviewCursor(int $deltaX, int $deltaY): void - { - $previewSize = $this->getDatabasePreviewSize(); - $nextX = max(0, min($previewSize['width'] - 1, $this->databasePreviewCursorX + $deltaX)); - $nextY = max(0, min($previewSize['height'] - 1, $this->databasePreviewCursorY + $deltaY)); - - if ($nextX === $this->databasePreviewCursorX && $nextY === $this->databasePreviewCursorY) { - return; - } - - $this->databasePreviewCursorX = $nextX; - $this->databasePreviewCursorY = $nextY; - $this->renderDatabasePanes(['preview']); - } - /** * Cycles focus between editor panes. * @@ -3727,10 +3446,13 @@ private function moveSelection(int $step): void */ private function selectAsset(int $selectedIndex): void { + $this->facadeBrush = null; if (! $this->workspace instanceof ProjectWorkspace || $selectedIndex === $this->selectedAssetIndex) { return; } + $this->piecePlacement = null; + $this->selectedAssetIndex = $selectedIndex; $this->cursorX = 0; $this->cursorY = 0; @@ -3775,7 +3497,9 @@ private function moveCursor(int $deltaX, int $deltaY): void $this->cursorY = $nextCursorY; $this->syncViewportToCursor(); - if ($previousOffsetX !== $this->canvasOffsetX || $previousOffsetY !== $this->canvasOffsetY) { + // A piece's preview follows the cursor, so the canvas repaints. + if ($previousOffsetX !== $this->canvasOffsetX || $previousOffsetY !== $this->canvasOffsetY + || $this->getActivePiecePlacement() !== null) { $this->renderCanvasArea(); return; } @@ -3804,13 +3528,19 @@ private function moveCursor(int $deltaX, int $deltaY): void */ private function setEditingMode(string $mode): void { + $this->finalizeActiveStroke(); + $this->facadeBrush = null; + if ($mode !== self::MODE_MAP) { + $this->piecePlacement = null; + } $this->editingMode = $mode; $this->showEventOverlay = $mode === self::MODE_EVENT; - if ($mode === self::MODE_NPC) { - // NPC mode owns its own letter commands; Paint cannot persist. + if ($mode === self::MODE_NPC || $mode === self::MODE_AREA) { + // NPC and Area modes own their own letter commands; Paint cannot persist. $this->inputMode = self::INPUT_NORMAL; } + $this->resetAreaSelection(); $this->selectedInspectorFieldIndex = 0; $this->isInspectorEditing = false; $this->inspectorEditBuffer = ''; @@ -3829,6 +3559,7 @@ private function setEditingMode(string $mode): void $this->statusMessage = match ($mode) { self::MODE_EVENT => 'Event mode active.', self::MODE_NPC => 'NPC mode active. Enter selects or creates, M moves, D duplicates, L lists, Del removes, Esc exits.', + self::MODE_AREA => 'Area mode active. Space selects a cell, r marks a rectangle\'s corners, Enter makes an area or picks one up, Del deletes, Esc exits.', default => 'Map mode active.', }; $this->renderCanvasArea(); @@ -3940,6 +3671,7 @@ private function applyCharacterPaletteSelection(): void return; } + $this->paintPieceRole = null; $this->selectedPaintSymbol = $symbol; $this->replaceCurrentSymbol($symbol); $this->isCharacterMapOpen = false; @@ -3971,12 +3703,6 @@ private function openColorPicker(): void return; } - if ($this->editingMode === self::MODE_EVENT) { - $this->statusMessage = 'Colour applies to the Map layer; event markers are authoring geometry.'; - $this->renderFooter(); - return; - } - $this->isColorPickerOpen = true; $this->colorPaletteIndex = $this->currentColorPaletteIndex(); $this->statusMessage = 'Colour picker open.'; @@ -4054,6 +3780,25 @@ private function handleColorPickerInput(string $input, string $normalizedInput): } } + /** + * Lists the cells a canvas selection covers, row by row. + * + * @param array{x: int, y: int, width: int, height: int} $selection The selection. + * @return array + */ + private function getSelectionCells(array $selection): array + { + $cells = []; + + for ($y = $selection['y']; $y < $selection['y'] + $selection['height']; $y++) { + for ($x = $selection['x']; $x < $selection['x'] + $selection['width']; $x++) { + $cells[] = ['x' => $x, 'y' => $y]; + } + } + + return $cells; + } + /** * Applies the selected palette colour to the brush, and under the brush * tool recolours the cell under the cursor in place. @@ -4077,17 +3822,27 @@ private function applyColorPaletteSelection(): void if ( $entry['value'] !== null - && $this->canvasTool === CanvasTool::BRUSH && $selectedMap instanceof ProjectMap - && $this->editingMode !== self::MODE_EVENT + && $this->editingMode !== self::MODE_NPC ) { - // Recolour in place: the cell keeps its glyph and takes the - // brush colour, as one undoable stroke. - $symbol = $selectedMap->getTileSymbol($this->cursorX, $this->cursorY); - $this->paintCanvasCells( + // Recolour in place: cells keep their glyphs and take the brush + // colour, as one undoable stroke. A selection recolours every + // cell in it; otherwise the brush recolours the cursor cell. + $cells = $this->canvasSelection !== null + ? $this->getSelectionCells($this->canvasSelection) + : ($this->canvasTool === CanvasTool::BRUSH ? [['x' => $this->cursorX, 'y' => $this->cursorY]] : []); + + $this->applyCanvasWrites( $selectedMap, - [['x' => $this->cursorX, 'y' => $this->cursorY]], - $symbol, + array_map( + fn(array $cell): array => [ + 'x' => $cell['x'], + 'y' => $cell['y'], + 'symbol' => $this->readCanvasSymbol($selectedMap, $cell['x'], $cell['y']), + 'color' => $this->selectedPaintColor, + ], + $cells, + ), 'Recolour', ); } @@ -4141,6 +3896,8 @@ private function handleTypedSymbolInput(string $input): void */ private function adoptPaintSymbol(string $symbol): void { + // A typed glyph is chosen afresh, so it asks again which piece it is. + $this->paintPieceRole = null; $this->selectedPaintSymbol = $symbol; if ($this->canvasTool !== CanvasTool::BRUSH) { @@ -4165,6 +3922,14 @@ private function adoptPaintSymbol(string $symbol): void */ private function handleEraseInput(string $input): bool { + // A connected piece being drawn erases its own cell, reshaping the + // cells beside it; its hint names the key Del, so Delete works too. + if (($this->getActivePiecePlacement()['piece']->connects ?? null) !== null + && in_array($input, ["\177", "\010", "\033[3~"], true)) { + $this->eraseConnectedPieceCell(); + return true; + } + if ($input === "\177" || $input === "\010") { $this->adoptPaintSymbol(' '); return true; @@ -4177,9 +3942,10 @@ private function handleEraseInput(string $input): bool * Replaces the current symbol on the active editing layer. * * @param string $symbol The replacement symbol. + * @param array|null $choices The pieces chosen for the glyph; null for the brush's own answer. * @return void */ - private function replaceCurrentSymbol(string $symbol): void + private function replaceCurrentSymbol(string $symbol, ?array $choices = null): void { if (! $this->workspace instanceof ProjectWorkspace) { return; @@ -4195,13 +3961,22 @@ private function replaceCurrentSymbol(string $symbol): void // The brush footprint commits as one stroke, so a wide dab is still // a single undo step (width 1 is byte-for-byte the historic path). - $this->paintCanvasCells( + $changed = $this->paintCanvasCells( $selectedMap, ToolGeometry::brush($this->cursorX, $this->cursorY, $this->canvasBrushSize), $symbol, $isEventLayer ? 'Event edit' : 'Tile edit', + function (array $choices) use ($symbol): void { + $this->replaceCurrentSymbol($symbol, $choices); + $this->renderCanvasArea(); + }, + $choices, ); + if ($changed === null) { + return; + } + $this->statusMessage = sprintf( '%s mode updated (%d, %d).', ucfirst($this->editingMode), @@ -4254,6 +4029,12 @@ private function handleMouseInput(string $input): bool $this->finalizeActiveStroke(); $this->activeMousePaintButton = null; $this->lastMousePaintPoint = null; + $this->mouseToolDrag = null; + return true; + } + + if ($event->isRelease && $this->mouseToolDrag !== null) { + $this->completeMouseToolDrag($event); return true; } @@ -4313,8 +4094,8 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool return true; } - $viewportWidth = $bounds['right'] - $bounds['left'] + 1; - $viewportHeight = $bounds['bottom'] - $bounds['top'] + 1; + $viewportWidth = $bounds['width']; + $viewportHeight = $bounds['height']; $maxOffsetX = max(0, $selectedMap->getWidth() - $viewportWidth); $maxOffsetY = max(0, $selectedMap->getHeight() - $viewportHeight); $nextOffsetX = max(0, min($maxOffsetX, $this->canvasOffsetX + $deltaX)); @@ -4331,24 +4112,67 @@ private function handleCanvasWheelScroll(MouseEvent $event): bool return true; } - /** @return array{left: int, top: int, right: int, bottom: int} */ - private function getCanvasPreviewBounds(): array + /** + * Shared geometry for rendering, cursor visibility, hit testing and scrolling. + * @return array{left: int, top: int, right: int, bottom: int, width: int, height: int} + */ + private function getCanvasPreviewBounds(?array $layout = null): array { - $layout = $this->resolveLayout(); + $layout ??= $this->resolveLayout(); $contentWidth = $this->getWindowContentWidth($layout['centerWidth']); - $previewHeight = max(1, $layout['contentHeight'] - 4); + $previewHeight = max(1, $layout['contentHeight'] - self::WINDOW_BORDER_ROWS - ProjectWorkspace::CANVAS_HEADER_ROWS); $canvasLeft = 2 + $layout['leftWidth'] + $layout['gutter']; $canvasTop = 5; $mapLeft = $canvasLeft + 1 + self::WINDOW_HORIZONTAL_PADDING; - $mapTop = $canvasTop + 3; + $mapTop = $canvasTop + 1 + ProjectWorkspace::CANVAS_HEADER_ROWS; return [ 'left' => $mapLeft, 'top' => $mapTop, 'right' => $mapLeft + $contentWidth - 1, 'bottom' => $mapTop + $previewHeight - 1, + 'width' => $contentWidth, + 'height' => $previewHeight, ]; } + /** + * Ends a mouse drag of a line, rectangle or selection where the button + * was released (or at the last cell the drag reached, when released off + * the map). A selection that never left its first cell is a click: it + * moves the cursor there, as a Normal-mode click does. + */ + private function completeMouseToolDrag(MouseEvent $event): void + { + $drag = $this->mouseToolDrag; + $this->mouseToolDrag = null; + $bounds = $this->getCanvasPreviewBounds(); + $selectedMap = $this->getSelectedMap(); + if ($selectedMap instanceof ProjectMap && $event->x >= $bounds['left'] && $event->x <= $bounds['right'] + && $event->y >= $bounds['top'] && $event->y <= $bounds['bottom']) { + $this->cursorX = max(0, min($selectedMap->getWidth() - 1, $this->canvasOffsetX + ($event->x - $bounds['left']))); + $this->cursorY = max(0, min($selectedMap->getHeight() - 1, $this->canvasOffsetY + ($event->y - $bounds['top']))); + } + if ($drag['piece'] ?? false) { + $this->dragPieceWithMouse($this->cursorX, $this->cursorY, false, true); + return; + } + $anchor = $this->canvasToolAnchor ?? $drag['start']; + $this->canvasToolAnchor = null; + if (! $selectedMap instanceof ProjectMap) { + return; + } + if ($this->canvasTool === CanvasTool::SELECT) { + if ($anchor === ['x' => $this->cursorX, 'y' => $this->cursorY]) { + $this->statusMessage = sprintf('Cursor at (%d, %d).', $this->cursorX, $this->cursorY); + $this->renderCanvasArea(); + return; + } + $this->completeCanvasSelection($anchor); + return; + } + $this->drawCanvasToolShape($selectedMap, $anchor, $drag['symbol']); + } + /** Handles mouse editing over the canvas preview. */ private function handleCanvasMouseEdit(MouseEvent $event): bool { @@ -4378,6 +4202,52 @@ private function handleCanvasMouseEdit(MouseEvent $event): bool $focusChanged = $this->focusedPane !== self::FOCUS_CANVAS; $this->setFocusedPane(self::FOCUS_CANVAS, false); + if ($this->facadeBrush !== null && $this->inputMode === self::INPUT_PAINT + && $event->button === MouseButton::LEFT_BUTTON) { + $this->cursorX = $targetX; + $this->cursorY = $targetY; + $this->stampFacadeBrush(); + return true; + } + + // A piece being placed follows a left drag: the press anchors it, the + // drag previews the area (or a connected piece's line) and the release + // draws it. + if ($this->editingMode === self::MODE_MAP && $this->getActivePiecePlacement() !== null && $event->button === MouseButton::LEFT_BUTTON) { + $starting = ! $event->isMotion || $this->mouseToolDrag === null; + if ($starting) { + $this->mouseToolDrag = ['start' => ['x' => $targetX, 'y' => $targetY], 'symbol' => '', 'piece' => true]; + } + $this->dragPieceWithMouse($targetX, $targetY, ! $starting, false); + if ($focusChanged) { + $this->renderFocusDependentArea(); + } + return true; + } + + // A tool that spans an anchor and a corner follows a mouse drag: the + // press anchors it, the drag moves the corner with a live preview, + // and the release draws (or selects). The right button erases. + $dragsShape = $this->editingMode === self::MODE_MAP && $this->getActivePiecePlacement() === null && match ($this->inputMode) { + self::INPUT_PAINT => in_array($this->canvasTool, [CanvasTool::LINE, CanvasTool::RECTANGLE, CanvasTool::FILLED_RECTANGLE], true), + self::INPUT_NORMAL => $this->canvasTool === CanvasTool::SELECT && $event->button === MouseButton::LEFT_BUTTON, + default => false, + }; + if ($dragsShape) { + if (! $event->isMotion || $this->mouseToolDrag === null) { + $this->mouseToolDrag = ['start' => ['x' => $targetX, 'y' => $targetY], + 'symbol' => $event->button === MouseButton::RIGHT_BUTTON ? ' ' : $this->selectedPaintSymbol]; + $this->canvasToolAnchor = ['x' => $targetX, 'y' => $targetY]; + } + $this->cursorX = $targetX; + $this->cursorY = $targetY; + $this->statusMessage = sprintf('%s from (%d, %d) to (%d, %d). Release to %s.', $this->canvasTool->label(), + $this->canvasToolAnchor['x'], $this->canvasToolAnchor['y'], $targetX, $targetY, + $this->canvasTool === CanvasTool::SELECT ? 'select' : 'draw'); + $focusChanged ? $this->renderFocusDependentArea() : $this->renderCanvasArea(); + return true; + } + if ($this->editingMode === self::MODE_MAP && $this->inputMode === self::INPUT_NORMAL) { // The mouse honors the canvas's modality: in Normal mode a click // selects the cell, moving the cursor and reading out its @@ -4488,7 +4358,7 @@ private function paintCanvasStroke( $this->finalizeActiveStroke(); $this->activeStrokeCommand = new PaintStrokeCommand( $selectedMap, - $paintEvents ? PaintStrokeCommand::LAYER_EVENT : PaintStrokeCommand::LAYER_TILE, + $this->getActiveCanvasLayer(), $paintEvents ? 'Event stroke' : 'Paint stroke', ); } @@ -4497,30 +4367,38 @@ private function paintCanvasStroke( $this->interpolatePoints($start['x'], $start['y'], $targetX, $targetY), $this->canvasBrushSize, ); + $retry = function (array $choices) use ($selectedMap, $symbol, $targetX, $targetY, $button, $paintEvents): void { + $this->rememberPaintPieceRole($symbol, $choices); + $this->activeMousePaintButton = null; + $this->lastMousePaintPoint = null; + $this->paintCanvasStroke($selectedMap, $symbol, $targetX, $targetY, $button, $paintEvents); + $this->renderCanvasArea(); + }; - foreach ($points as $point) { - if ( - $point['x'] < 0 || - $point['y'] < 0 || - $point['x'] >= $selectedMap->getWidth() || - $point['y'] >= $selectedMap->getHeight() - ) { - continue; - } + $color = $this->selectedPaintColor; + $writes = $this->followStrokeWithTiles($selectedMap, array_map( + static fn(array $point): array => ['x' => $point['x'], 'y' => $point['y'], 'symbol' => $symbol, 'color' => $color], + $points, + ), $retry); + if ($writes === null) { + $this->activeMousePaintButton = null; + $this->lastMousePaintPoint = null; + return; + } - if ($paintEvents) { - $oldSymbol = $selectedMap->getEventSymbol($point['x'], $point['y']); - $selectedMap->setEventSymbol($point['x'], $point['y'], $symbol); - $newSymbol = $selectedMap->getEventSymbol($point['x'], $point['y']); - $this->activeStrokeCommand->appendCell($point['x'], $point['y'], $oldSymbol, $newSymbol); + // The planned glyphs: an erase may give a cell back to the wall face its window was mounted on. + foreach ($writes as $point) { + if (! $selectedMap->hasLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'])) { continue; } - $oldSymbol = $selectedMap->getTileSymbol($point['x'], $point['y']); - $oldStyle = $selectedMap->getTileCellStyle($point['x'], $point['y']); - [$newPrefix, $newSuffix] = $this->resolvePaintStyle($symbol, $this->selectedPaintColor, $oldStyle); - $selectedMap->setTileCell($point['x'], $point['y'], $symbol, $newPrefix, $newSuffix); - $newSymbol = $selectedMap->getTileSymbol($point['x'], $point['y']); + $oldSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); + $oldStyle = $selectedMap->getLayerCellStyle($this->getActiveCanvasLayer(), $point['x'], $point['y']); + [$newPrefix, $newSuffix] = isset($point['style']) + ? [$point['style']['prefix'], $point['style']['suffix']] + : CanvasEditor::resolvePaintStyle($point['symbol'], $point['color'] ?? null, $oldStyle); + $selectedMap->setLayerCell($this->getActiveCanvasLayer(), $point['x'], $point['y'], $point['symbol'], $newPrefix, $newSuffix); + $newSymbol = $selectedMap->getLayerSymbol($this->getActiveCanvasLayer(), $point['x'], $point['y']); $this->activeStrokeCommand->appendCell( $point['x'], $point['y'], @@ -4619,8 +4497,20 @@ private function enterPaintMode(): void return; } + $this->piecePlacement = null; + // Paint mode paints. Selecting is a Normal-mode job, so a Select tool + // left active from Normal mode must not turn typed glyphs into + // silent brush changes. + if ($this->canvasTool === CanvasTool::SELECT) { + $this->canvasTool = CanvasTool::BRUSH; + $this->canvasToolAnchor = null; + $this->canvasSelection = null; + } $this->inputMode = self::INPUT_PAINT; - $this->statusMessage = 'Paint mode: every key paints its glyph. Esc returns to Normal.'; + $this->statusMessage = $this->canvasTool === CanvasTool::BRUSH + ? 'Paint mode: every key paints its glyph. Esc returns to Normal.' + : sprintf('Paint mode, %s tool: keys choose the glyph. %s Esc returns to Normal.', + $this->canvasTool->label(), $this->describeCanvasToolUsage()); $this->renderFocusDependentArea(); } @@ -4649,11 +4539,16 @@ private function leavePaintMode(string $statusMessage = 'Normal mode.'): void */ private function selectCanvasTool(CanvasTool $tool): void { + $this->facadeBrush = null; + $this->piecePlacement = null; $this->canvasTool = $tool; $this->canvasToolAnchor = null; if ($tool !== CanvasTool::SELECT) { $this->canvasSelection = null; + } elseif ($this->inputMode === self::INPUT_PAINT) { + // Selecting happens in Normal mode, where keys are commands. + $this->leavePaintMode(); } $this->setStatus(sprintf('%s tool. %s', $tool->label(), $this->describeCanvasToolUsage())); @@ -4667,9 +4562,14 @@ private function selectCanvasTool(CanvasTool $tool): void */ private function getActiveCanvasLayer(): string { - return $this->editingMode === self::MODE_EVENT - ? PaintStrokeCommand::LAYER_EVENT - : PaintStrokeCommand::LAYER_TILE; + if ($this->editingMode === self::MODE_EVENT) { + return PaintStrokeCommand::LAYER_EVENT; + } + $map = $this->getSelectedMap(); + $selected = $this->getCanvasLayerState()['selected']; + $ids = array_column($this->getTerminalCanvasLayers(), 'id'); + return $selected !== PaintStrokeCommand::LAYER_EVENT && in_array($selected, $ids, true) + ? $selected : ($map?->getBaseLayerId() ?? PaintStrokeCommand::LAYER_TILE); } /** @@ -4682,9 +4582,7 @@ private function getActiveCanvasLayer(): string */ private function readCanvasSymbol(ProjectMap $map, int $x, int $y): string { - return $this->editingMode === self::MODE_EVENT - ? $map->getEventSymbol($x, $y) - : $map->getTileSymbol($x, $y); + return $map->getLayerSymbol($this->getActiveCanvasLayer(), $x, $y); } /** @@ -4698,12 +4596,8 @@ private function readCanvasSymbol(ProjectMap $map, int $x, int $y): string */ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symbol): void { - if ($this->editingMode === self::MODE_EVENT) { - $map->setEventSymbol($x, $y, $symbol); - return; - } - - $map->setTileSymbol($x, $y, $symbol); + $style = $map->getLayerCellStyle($this->getActiveCanvasLayer(), $x, $y); + $map->setLayerCell($this->getActiveCanvasLayer(), $x, $y, $symbol, $style['prefix'], $style['suffix']); } /** @@ -4711,104 +4605,20 @@ private function writeCanvasSymbol(ProjectMap $map, int $x, int $y, string $symb * * This is the single commit path behind every canvas tool: a brush dab, * a line, a rectangle, a flood fill, a cut, and a paste all land as one - * PaintStrokeCommand, so each undoes in exactly one Ctrl+Z. + * undo step with the tiles that follow their glyphs, so each undoes in + * exactly one Ctrl+Z. * * @param ProjectMap $map The target map. * @param array $writes The cells to write. * @param string $label The undo/status label. - * @return int The number of cells that actually changed. - */ - private function applyCanvasWrites(ProjectMap $map, array $writes, string $label): int - { - if ($writes === []) { - return 0; - } - - $this->finalizeActiveStroke(); - $isTileLayer = $this->getActiveCanvasLayer() === PaintStrokeCommand::LAYER_TILE; - $stroke = new PaintStrokeCommand($map, $this->getActiveCanvasLayer(), $label); - $changed = 0; - - foreach ($writes as $write) { - if ($write['x'] < 0 || $write['y'] < 0 || $write['x'] >= $map->getWidth() || $write['y'] >= $map->getHeight()) { - continue; - } - - $oldSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - - if ($isTileLayer) { - $oldStyle = $map->getTileCellStyle($write['x'], $write['y']); - [$newPrefix, $newSuffix] = isset($write['style']) - ? [$write['style']['prefix'], $write['style']['suffix']] - : $this->resolvePaintStyle( - $write['symbol'], - $write['color'] ?? null, - $oldStyle, - ); - $map->setTileCell($write['x'], $write['y'], $write['symbol'], $newPrefix, $newSuffix); - $newSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - $stroke->appendCell( - $write['x'], - $write['y'], - $oldSymbol, - $newSymbol, - $oldStyle['prefix'], - $oldStyle['suffix'], - $newPrefix, - $newSuffix, - ); - - if ($oldSymbol !== $newSymbol || $oldStyle['prefix'] !== $newPrefix || $oldStyle['suffix'] !== $newSuffix) { - $changed++; - } - - continue; - } - - $this->writeCanvasSymbol($map, $write['x'], $write['y'], $write['symbol']); - $newSymbol = $this->readCanvasSymbol($map, $write['x'], $write['y']); - $stroke->appendCell($write['x'], $write['y'], $oldSymbol, $newSymbol); - - if ($oldSymbol !== $newSymbol) { - $changed++; - } - } - - if ($stroke->hasChanges()) { - $this->recordCommand($stroke); - } - - return $changed; - } - - /** - * Resolves the styling bytes a tile paint writes. - * - * The colour directive follows the brush contract: null keeps the - * cell's existing styling byte-for-byte, an empty string paints without - * colour, and any other value becomes an `fg=` tag. A space is always - * uncoloured, so erasing never leaves invisible styling behind. - * - * @param string $symbol The symbol being painted. - * @param string|null $colorDirective The brush colour directive. - * @param array{prefix: string, suffix: string} $oldStyle The cell's current styling. - * @return array{0: string, 1: string} The prefix and suffix to write. + * @param (Closure(array): void)|null $retry Runs the edit again once the author chooses a glyph's piece. + * @param array $choices The pieces already chosen for glyphs. + * @return int|null The number of cells that actually changed, or null when nothing changed: refused, or waiting on a choice. */ - private function resolvePaintStyle(string $symbol, ?string $colorDirective, array $oldStyle): array + private function applyCanvasWrites(ProjectMap $map, array $writes, string $label, ?Closure $retry = null, array $choices = []): ?int { - if ($symbol === ' ') { - return ['', '']; - } - - if ($colorDirective === null) { - return [$oldStyle['prefix'], $oldStyle['suffix']]; - } - - if ($colorDirective === '') { - return ['', '']; - } - - return [sprintf('', $colorDirective), '']; + // Tiles follow the glyphs (GlyphTileCanvas), in the same undo step. + return $this->commitCanvasWrites($map, $writes, $label, retry: $retry, choices: $choices); } /** @@ -4818,21 +4628,31 @@ private function resolvePaintStyle(string $symbol, ?string $colorDirective, arra * @param array $cells The cells to paint. * @param string $symbol The symbol to paint. * @param string $label The undo/status label. - * @return int The number of cells that actually changed. + * @param (Closure(array): void)|null $retry Paints again once the author chooses the glyph's piece. + * @param array|null $choices The pieces chosen for glyphs; null for the brush's own answer. + * @return int|null The number of cells that actually changed, or null when nothing changed: refused, or waiting on a choice. */ - private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label): int + private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, string $label, ?Closure $retry = null, + ?array $choices = null): ?int { // Glyph paints carry the brush colour; the space rule and the event - // layer's lack of styling are resolved at the commit path. + // layer's lack of styling are resolved at the commit path. A glyph + // painted over itself may draw another piece, so it repaints. $color = $this->selectedPaintColor; - return $this->applyCanvasWrites( + return $this->commitCanvasWrites( $map, array_map( static fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], 'symbol' => $symbol, 'color' => $color], $cells, ), $label, + retry: $retry === null ? null : function (array $choices) use ($symbol, $retry): void { + $this->rememberPaintPieceRole($symbol, $choices); + $retry($choices); + }, + choices: $choices ?? $this->getPaintPieceChoices($symbol), + repaint: true, ); } @@ -4844,6 +4664,7 @@ private function paintCanvasCells(ProjectMap $map, array $cells, string $symbol, */ private function cycleCanvasTool(int $step): void { + $this->piecePlacement = null; $this->canvasTool = $this->canvasTool->cycle($step); $this->canvasToolAnchor = null; @@ -4862,7 +4683,7 @@ private function cycleCanvasTool(int $step): void */ private function cycleCanvasBrushSize(): void { - $sizes = self::BRUSH_SIZES; + $sizes = CanvasTool::BRUSH_SIZES; $index = array_search($this->canvasBrushSize, $sizes, true); $index = is_int($index) ? $index : 0; $this->canvasBrushSize = $sizes[($index + 1) % count($sizes)]; @@ -4921,6 +4742,15 @@ private function describeCanvasToolState(): string */ private function applyCanvasToolAtCursor(): void { + if ($this->getActivePiecePlacement() !== null) { + $this->applyPieceAtCursor(); + return; + } + + if ($this->facadeBrush !== null) { + $this->stampFacadeBrush(); + return; + } $selectedMap = $this->getSelectedMap(); if (! $selectedMap instanceof ProjectMap) { @@ -4952,34 +4782,77 @@ private function applyCanvasToolAtCursor(): void return; } - $cells = match ($this->canvasTool) { - CanvasTool::LINE => ToolGeometry::line($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), - CanvasTool::RECTANGLE => ToolGeometry::rectangleOutline($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), - CanvasTool::FILLED_RECTANGLE => ToolGeometry::rectangleFilled($anchor['x'], $anchor['y'], $this->cursorX, $this->cursorY), - default => [], - }; + $this->drawCanvasToolShape($selectedMap, $anchor, $this->selectedPaintSymbol); + } - // The brush thickens outlines and lines; a filled rectangle is - // already solid, so widening it would only spill past the corners. - if ($this->canvasTool !== CanvasTool::FILLED_RECTANGLE) { - $cells = ToolGeometry::expandByBrush($cells, $this->canvasBrushSize); + /** + * The cells the active line or rectangle tool covers from an anchor to + * the cursor, widened by the brush except for a filled rectangle. + * + * @param array{x: int, y: int} $anchor + * @return array + */ + private function getCanvasToolShapeCells(array $anchor): array + { + if (! in_array($this->canvasTool, [CanvasTool::LINE, CanvasTool::RECTANGLE, CanvasTool::FILLED_RECTANGLE], true)) { + return []; } - $changed = $this->paintCanvasCells( - $selectedMap, - $cells, - $this->selectedPaintSymbol, - $this->canvasTool->commandLabel(), - ); + return $this->canvasTool->getShapeCells($anchor, ['x' => $this->cursorX, 'y' => $this->cursorY], $this->canvasBrushSize); + } - $this->setStatus(sprintf( - '%s: %d cell%s painted with %s.', - $this->canvasTool->label(), - $changed, - $changed === 1 ? '' : 's', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, - )); - $this->renderCanvasArea(); + /** + * What the canvas previews over the map: the piece being placed, or the + * shape a line or rectangle tool will draw from its anchor to the cursor. + * + * @return array> Symbols by row and column. + */ + private function getCanvasPreviewCells(): array + { + if ($this->editingMode === self::MODE_AREA) { + return $this->getAreaPreviewCells(); + } + $cells = $this->getPiecePreviewCells(); + if ($cells !== [] || ! is_array($this->canvasToolAnchor) + || ! in_array($this->canvasTool, [CanvasTool::LINE, CanvasTool::RECTANGLE, CanvasTool::FILLED_RECTANGLE], true)) { + return $cells; + } + $symbol = $this->mouseToolDrag['symbol'] ?? $this->selectedPaintSymbol; + foreach ($this->getCanvasToolShapeCells($this->canvasToolAnchor) as $cell) { + $cells[$cell['y']][$cell['x']] = $symbol; + } + + return $cells; + } + + /** + * Draws the active line or rectangle tool's shape from an anchor to the + * cursor in one undo step, painting the symbol (a space erases). + * + * @param array{x: int, y: int} $anchor + */ + private function drawCanvasToolShape(ProjectMap $selectedMap, array $anchor, string $symbol): void + { + $cells = $this->getCanvasToolShapeCells($anchor); + $tool = $this->canvasTool; + $paint = function (?array $choices = null) use ($selectedMap, $cells, $tool, $symbol, &$paint): void { + $changed = $this->paintCanvasCells($selectedMap, $cells, $symbol, $tool->commandLabel(), $paint, $choices); + + if ($changed === null) { + $this->renderCanvasArea(); + return; + } + + $this->setStatus(sprintf( + '%s: %d cell%s painted with %s.', + $tool->label(), + $changed, + $changed === 1 ? '' : 's', + $symbol === ' ' ? 'space' : $symbol, + )); + $this->renderCanvasArea(); + }; + $paint(); } /** @@ -5059,14 +4932,23 @@ private function floodFillFromCursor(): void $this->cursorX, $this->cursorY, ); - $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, 'flood fill'); - $this->setStatus(sprintf( - 'Flood filled %d cell%s with %s.', - $changed, - $changed === 1 ? '' : 's', - $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, - )); - $this->renderCanvasArea(); + $fill = function (?array $choices = null) use ($selectedMap, $cells, &$fill): void { + $changed = $this->paintCanvasCells($selectedMap, $cells, $this->selectedPaintSymbol, 'flood fill', $fill, $choices); + + if ($changed === null) { + $this->renderCanvasArea(); + return; + } + + $this->setStatus(sprintf( + 'Flood filled %d cell%s with %s.', + $changed, + $changed === 1 ? '' : 's', + $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, + )); + $this->renderCanvasArea(); + }; + $fill(); } /** @@ -5086,17 +4968,22 @@ private function pickSymbolUnderCursor(): void // On the tile layer the eyedropper picks the colour with the glyph: // an uncoloured cell loads an uncoloured brush. + // It picks up the piece the glyph draws there too, so painting it + // again draws the same piece. $pickedColor = null; + $pickedPiece = ''; + $this->paintPieceRole = null; - if ($this->editingMode !== self::MODE_EVENT) { - $pickedColor = $selectedMap->getTileColor($this->cursorX, $this->cursorY); + if ($this->editingMode !== self::MODE_NPC) { + $pickedColor = $selectedMap->getLayerColor($this->getActiveCanvasLayer(), $this->cursorX, $this->cursorY); $this->selectedPaintColor = $pickedColor ?? ''; + $pickedPiece = $this->pickPaintPieceRole($selectedMap, $this->cursorX, $this->cursorY); } $this->setStatus(sprintf( 'Picked up %s%s from (%d, %d).', $this->selectedPaintSymbol === ' ' ? 'space' : $this->selectedPaintSymbol, - $pickedColor === null ? '' : ' (' . $pickedColor . ')', + implode('', array_map(static fn(string $detail): string => " ({$detail})", array_filter([$pickedPiece, $pickedColor ?? '']))), $this->cursorX, $this->cursorY, )); @@ -5132,24 +5019,30 @@ private function cutCanvasSelection(): void { $selectedMap = $this->getSelectedMap(); - if (! $selectedMap instanceof ProjectMap || $this->captureCanvasSelection() === 0) { + if (! $selectedMap instanceof ProjectMap || ! is_array($this->canvasSelection)) { + $this->captureCanvasSelection(); return; } $selection = $this->canvasSelection; - $cells = ToolGeometry::rectangleFilled( - $selection['x'], - $selection['y'], - $selection['x'] + $selection['width'] - 1, - $selection['y'] + $selection['height'] - 1, - ); - $changed = $this->paintCanvasCells($selectedMap, $cells, ' ', 'cut selection'); + $this->finalizeActiveStroke(); + try { + $cut = CanvasClipboard::cut($selectedMap, $this->getActiveCanvasLayer(), $selection['x'], $selection['y'], + $selection['width'], $selection['height'], $this->clipboard, $this->editingMode !== self::MODE_NPC); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderCanvasArea(); + return; + } + if ($cut['command'] !== null) { + $this->recordCommand($cut['command']); + } $this->setStatus(sprintf( 'Cut %d x %d (%d cell%s cleared).', $this->clipboard->getWidth(), $this->clipboard->getHeight(), - $changed, - $changed === 1 ? '' : 's', + $cut['changed'], + $cut['changed'] === 1 ? '' : 's', )); $this->renderCanvasArea(); } @@ -5174,36 +5067,26 @@ private function captureCanvasSelection(): int } $selection = $this->canvasSelection; - $rows = []; - $styles = []; - - for ($rowIndex = 0; $rowIndex < $selection['height']; $rowIndex++) { - $row = []; - $styleRow = []; - - for ($columnIndex = 0; $columnIndex < $selection['width']; $columnIndex++) { - $row[] = $this->readCanvasSymbol($selectedMap, $selection['x'] + $columnIndex, $selection['y'] + $rowIndex); - - if ($this->getActiveCanvasLayer() === PaintStrokeCommand::LAYER_TILE) { - $styleRow[] = $selectedMap->getTileCellStyle($selection['x'] + $columnIndex, $selection['y'] + $rowIndex); - } - } - - $rows[] = $row; - $styles[] = $styleRow; + // The tiles that move with this layer's glyphs travel with the block. + try { + CanvasClipboard::copy($selectedMap, $this->getActiveCanvasLayer(), $selection['x'], $selection['y'], + $selection['width'], $selection['height'], $this->clipboard, $this->editingMode !== self::MODE_NPC); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderFooter(); + return 0; } - $this->clipboard->store($rows, $this->getActiveCanvasLayer(), $styles); - return $selection['width'] * $selection['height']; } /** * Stamps the clipboard at the cursor as one undoable stroke. * + * @param array $choices The pieces the author chose for glyphs the block could draw several ways. * @return void */ - private function pasteCanvasClipboard(): void + private function pasteCanvasClipboard(array $choices = []): void { $selectedMap = $this->getSelectedMap(); @@ -5211,48 +5094,42 @@ private function pasteCanvasClipboard(): void return; } - if ($this->clipboard->isEmpty()) { - $this->setStatus('The clipboard is empty - select a region and press Ctrl+L.', StatusLevel::WARN); + $this->finalizeActiveStroke(); + try { + $pasted = CanvasClipboard::paste($selectedMap, $this->getActiveCanvasLayer(), $this->clipboard, + $this->cursorX, $this->cursorY, $choices); + } catch (MapSourceRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); $this->renderFooter(); return; } - - if ($this->clipboard->layer !== $this->getActiveCanvasLayer()) { - $this->setStatus( - sprintf('The clipboard holds a %s-layer block; switch layers before pasting.', $this->clipboard->layer), - StatusLevel::WARN, - ); - $this->renderFooter(); + if ($pasted['unresolved'] !== []) { + $glyph = (string) array_key_first($pasted['unresolved']); + $this->askForGlyphPiece($glyph, $pasted['unresolved'][$glyph], $choices, $this->pasteCanvasClipboard(...)); return; } - - $writes = $this->clipboard->project( - $this->cursorX, - $this->cursorY, - $selectedMap->getWidth(), - $selectedMap->getHeight(), - ); - $changed = $this->applyCanvasWrites($selectedMap, $writes, 'pasted selection'); + if ($pasted['command'] !== null) { + $this->recordCommand($pasted['command']); + } $this->setStatus(sprintf( 'Stamped %d x %d at (%d, %d): %d cell%s changed.', $this->clipboard->getWidth(), $this->clipboard->getHeight(), $this->cursorX, $this->cursorY, - $changed, - $changed === 1 ? '' : 's', + $pasted['changed'], + $pasted['changed'] === 1 ? '' : 's', )); $this->renderCanvasArea(); } /** - * Launches the game on the selected map, spawning at the cursor. + * Launches a new game on the selected map, starting at the cursor. * - * The author's project is never modified: `PlaytestOverlay` builds a - * temporary root of symlinks with a generated `system.php` and an isolated - * save directory, and the overlay is torn down when the game exits. The - * engine offers no starting-map override today, so this is the honest way - * to do it - see the Phase 6 deferral note in `docs/roadmap.md`. + * The author's project is never modified: the game runs from the project's + * own root, and a {@see PlaytestLaunch} gives the Engine the start cell and + * a data root of its own for settings, saves and logs, removed when the + * game exits. * * @return void */ @@ -5267,17 +5144,17 @@ private function startPlaytest(): void } if ($selectedMap->isDirty()) { - // The playtest reads the map through a symlink, so an unsaved - // edit simply would not appear. Say so rather than confuse. + // The playtest reads the map from disk, so an unsaved edit + // simply would not appear. Say so rather than confuse. $this->setStatus('Save this map (Ctrl+S) before playtesting - the game reads the file on disk.', StatusLevel::WARN); $this->renderFooter(); return; } - $overlay = null; + $launch = null; try { - $overlay = PlaytestOverlay::create( + $launch = PlaytestLaunch::fromCell( $this->workspace->projectRoot, $selectedMap->mapId, $this->cursorX, @@ -5288,19 +5165,19 @@ private function startPlaytest(): void $this->terminal->suspendForChildProcess(); try { - $launcher->run($overlay); + $launcher->run($launch); } finally { $this->terminal->resumeAfterChildProcess($this->lastTerminalSize); } $this->setStatus( sprintf('Playtest finished (%s at %d,%d).', $selectedMap->mapId, $this->cursorX, $this->cursorY), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, 'Playtest'); } finally { - $overlay?->destroy(); + $launch?->destroy(); } $this->requestFullRender(); @@ -5358,7 +5235,8 @@ private function performReload(): void $this->clipboard->clear(); $this->canvasToolAnchor = null; $this->canvasSelection = null; - $this->setStatus('Workspace refreshed.', StatusLevel::SUCCESS); + $this->piecePlacement = null; + $this->setStatus('Workspace refreshed.', StatusLevel::INFO); $this->requestFullRender(); } @@ -5562,8 +5440,17 @@ private function getHelpLines(): array $lines[] = ' [ / ] move the entry up or down; this'; $lines[] = ' category stores its order'; } + if ($this->getSelectedDatabaseCategoryDefinition()->key === 'animations') { + $lines[] = ' Shift+T convert a legacy cell-frame record'; + $lines[] = ' to a timeline, previewed first'; + } $lines[] = ' Shift+O / Shift+X add or remove an objective, beat,'; - $lines[] = ' troop member or script command'; + $lines[] = ' troop member or script command;'; + $lines[] = ' a troop member starts from its enemy'; + $lines[] = ' Shift+C / Shift+N clear a troop\'s members, or name'; + $lines[] = ' the troop after them'; + $lines[] = ' On Event Pages, Shift+O adds a page;'; + $lines[] = ' Enter on its Commands opens them'; $lines[] = ' Del the same, on the settings field'; $lines[] = ' the cursor is on'; $lines[] = ' Enter edit, or open the picker on a field'; @@ -5709,6 +5596,11 @@ private function handleCommandPaletteInput(string $input, string $normalizedInpu private function buildPaletteItems(): array { $items = [ + ...$this->buildLayerPaletteItems(), + ...$this->buildPiecePaletteItems(), + ...$this->buildKindPaletteItems(), + ...$this->buildMapLinePaletteItems(), + ...$this->buildTreePaletteItems(), new PaletteItem('Save Map', 'Ctrl+S', fn() => $this->saveSelectedMap()), new PaletteItem('Move Map to Derived Path', '', fn() => $this->beginExplicitMapMove()), new PaletteItem('Save All', 'Ctrl+A', fn() => $this->saveAllAssets()), @@ -5716,6 +5608,7 @@ private function buildPaletteItems(): array new PaletteItem('Redo', 'Ctrl+Y', fn() => $this->performRedo()), new PaletteItem('Playtest Selected Map', 'Ctrl+T', fn() => $this->startPlaytest()), new PaletteItem('Reload Workspace', 'Ctrl+R', fn() => $this->requestReload()), + new PaletteItem('Repair Actor Identities and References', '', fn() => $this->openActorReferenceMigration()), new PaletteItem('Tool: Map Mode', 'm', function (): void { $this->closeDatabaseIfOpen(); $this->setEditingMode(self::MODE_MAP); @@ -5794,13 +5687,13 @@ function () use ($tool): void { if ($selectedMap instanceof ProjectMap) { foreach ($selectedMap->getPlacedEventMarkers() as $marker) { - $bounds = $selectedMap->getEventBounds($marker); + $first = $selectedMap->getEventArea($marker)?->firstCell; $items[] = new PaletteItem( sprintf( 'Event: %s on %s%s', $marker, $selectedMap->mapId, - $bounds === null ? '' : sprintf(' (%d, %d)', $bounds['x'], $bounds['y']), + $first === null ? '' : sprintf(' (%d, %d)', $first->x, $first->y), ), '', fn() => $this->jumpToEventMarker($marker), @@ -5851,14 +5744,15 @@ private function jumpToEventMarker(string $marker): void return; } - $bounds = $selectedMap->getEventBounds($marker); + // The marker's first cell is always one of its own cells, however it is painted. + $first = $selectedMap->getEventArea($marker)?->firstCell; $this->setEditingMode(self::MODE_EVENT); $this->setFocusedPane(self::FOCUS_CANVAS, false); - if ($bounds !== null) { - $this->cursorX = $bounds['x']; - $this->cursorY = $bounds['y']; + if ($first !== null) { + $this->cursorX = (int) $first->x; + $this->cursorY = (int) $first->y; } $this->clampCursor(); @@ -5903,35 +5797,8 @@ private function getDatabaseEntryLabels(): array ); } - if ($this->isClassesDatabaseSelected()) { - return array_map( - static fn(ProjectClass $class): string => $class->getName(), - $this->workspace?->classDatabase->getClasses() ?? [], - ); - } - - if ($this->isSkillsDatabaseSelected()) { - return array_map( - static fn(ProjectSkill $skill): string => $skill->getName(), - $this->workspace?->skillDatabase->getSkills() ?? [], - ); - } - - if ($this->isQuestsDatabaseSelected()) { - return array_map( - static fn(ProjectQuest $quest): string => $quest->getName(), - $this->workspace?->questDatabase->getQuests() ?? [], - ); - } - - if ($this->isAnimationsDatabaseSelected()) { - return array_map( - static fn(Animation $animation): string => $animation->name, - $this->workspace?->animationDatabase->getAnimations() ?? [], - ); - } - return $this->getSelectedRecordDatabase()?->getEntryLabels() ?? []; + } /** @@ -5983,10 +5850,6 @@ private function getSelectedDatabaseEntryIndex(): int { return match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex, - $this->isClassesDatabaseSelected() => $this->databaseSelectedClassIndex, - $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex, - $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex, - $this->isAnimationsDatabaseSelected() => $this->databaseSelectedAnimationIndex, default => $this->getSelectedRecordIndex(), }; } @@ -6003,10 +5866,6 @@ private function setSelectedDatabaseEntryIndex(int $index): void match (true) { $this->isActorsDatabaseSelected() => $this->databaseSelectedActorIndex = $index, - $this->isClassesDatabaseSelected() => $this->databaseSelectedClassIndex = $index, - $this->isSkillsDatabaseSelected() => $this->databaseSelectedSkillIndex = $index, - $this->isQuestsDatabaseSelected() => $this->databaseSelectedQuestIndex = $index, - $this->isAnimationsDatabaseSelected() => $this->databaseSelectedAnimationIndex = $index, default => $this->setSelectedRecordIndex($index), }; } @@ -6212,32 +6071,8 @@ private function confirmDatabaseEntryDeletion(): void $index = $pending['index']; $label = $pending['label']; $command = match ($pending['category']) { - self::DATABASE_CATEGORY_ACTORS => $this->buildDatabaseDeletionCommand( - sprintf('Delete actor %s', $label), - fn(): ?object => $workspace->actorDatabase->removeActor($index), - static fn(object $entry) => $workspace->actorDatabase->insertActor($index, $entry), - ), - self::DATABASE_CATEGORY_CLASSES => $this->buildDatabaseDeletionCommand( - sprintf('Delete class %s', $label), - fn(): ?object => $workspace->classDatabase->removeClass($index), - static fn(object $entry) => $workspace->classDatabase->insertClass($index, $entry), - ), - self::DATABASE_CATEGORY_SKILLS => $this->buildDatabaseDeletionCommand( - sprintf('Delete skill %s', $label), - fn(): ?object => $workspace->skillDatabase->removeSkill($index), - static fn(object $entry) => $workspace->skillDatabase->insertSkill($index, $entry), - ), - self::DATABASE_CATEGORY_QUESTS => $this->buildDatabaseDeletionCommand( - sprintf('Delete quest %s', $label), - fn(): ?object => $workspace->questDatabase->removeQuest($index), - static fn(object $entry) => $workspace->questDatabase->insertQuest($index, $entry), - ), - self::DATABASE_CATEGORY_ANIMATIONS => $this->buildDatabaseDeletionCommand( - sprintf('Delete animation %s', $label), - fn(): ?object => $workspace->animationDatabase->removeAnimation($index), - static fn(object $entry) => $workspace->animationDatabase->insertAnimation($index, $entry), - ), - default => $this->buildRecordDeletionCommand($pending['category'], $index, $label), + self::DATABASE_CATEGORY_ACTORS => $this->actorAuthoring->deleteActor($workspace, $index)->command, + default => $this->buildRecordDeletionCommand($pending['category'], $index), }; if (! $command instanceof Command) { @@ -6255,26 +6090,27 @@ private function confirmDatabaseEntryDeletion(): void } /** - * Builds the deletion command for a schema-driven category. + * Deletes an entry of a schema-driven category through the shared + * record rules ({@see RecordAuthoring::deleteRecord()}). * * @param string $categoryKey The category key. * @param int $index The entry index. - * @param string $label The entry label. - * @return Command|null + * @return Command|null The deletion's command, or null when it was refused. */ - private function buildRecordDeletionCommand(string $categoryKey, int $index, string $label): ?Command + private function buildRecordDeletionCommand(string $categoryKey, int $index): ?Command { $database = $this->workspace?->getRecordDatabase($categoryKey); - if (! $database instanceof ProjectRecordDatabase || ! $database->isEditable()) { + if (! $database instanceof ProjectRecordDatabase) { return null; } - return $this->buildDatabaseDeletionCommand( - sprintf('Delete %s %s', $database->schema->entryNoun, $label), - static fn(): ?object => $database->removeRecord($index), - static fn(object $entry) => $database->insertRecord($index, $entry), - ); + try { + return (new RecordAuthoring())->deleteRecord($database, $index)->command; + } catch (RecordRefusal) { + // describeUndeletableCategory() says why. + return null; + } } /** @@ -6415,19 +6251,15 @@ private function goToActorClass(): void $this->pushNavigationOrigin(sprintf('actor %s', $actor->getName())); $this->databaseFilter->clear(); $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_CLASSES); - $this->databaseSelectedClassIndex = $classIndex; + $this->setSelectedRecordIndex($classIndex); $this->databaseFocus = self::DATABASE_FOCUS_LIST; $this->databaseSelectedSettingIndex = 0; - $this->setStatus(sprintf('Went to class %s (Ctrl+B goes back).', $className), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Went to class %s (Ctrl+B goes back).', $className), StatusLevel::INFO); $this->renderDatabaseArea(includeRoot: true); } /** - * Jumps from the selected skill to the animation sharing its name. - * - * Skills carry no explicit animation id yet (the engine models a magic - * *effect type*, not a database reference), so the hop resolves by name: - * the skill name first, then its effect type. + * Jumps to the selected skill's animation, preferring its stable id. * * @return void */ @@ -6435,39 +6267,53 @@ private function goToSkillAnimation(): void { $skill = $this->getSelectedSkill(); - if (! $skill instanceof ProjectSkill) { + if (! $skill instanceof Skill) { + $this->setStatus('This skill does not read as one yet; validation says why.', StatusLevel::WARN); + $this->renderFooter(); return; } - $candidates = array_values(array_filter([$skill->getName(), $skill->getEffectType() ?? ''])); + $candidates = array_values(array_filter([$skill->name, $skill instanceof MagicSkill ? ($skill->effectType?->value ?? '') : ''])); $animationIndex = null; $matchedName = ''; - foreach ($candidates as $candidate) { - $animationIndex = $this->findDatabaseAnimationIndex($candidate); + $animations = $this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_ANIMATIONS); - if ($animationIndex !== null) { - $matchedName = $candidate; - break; + if ($skill->animationId !== null) { + foreach ($animations?->getRecords() ?? [] as $index => $animation) { + if ($animation->get('id') === $skill->animationId) { + $animationIndex = $index; + $matchedName = $animations->getEntryLabel($animation); + break; + } + } + } else { + foreach ($candidates as $candidate) { + $animationIndex = $this->findDatabaseAnimationIndex($candidate); + + if ($animationIndex !== null) { + $matchedName = $candidate; + break; + } } } if ($animationIndex === null) { $this->setStatus( - sprintf('No animation named "%s" - skills carry no animation reference yet.', $skill->getName()), + sprintf('No animation found for "%s". Choose its Animation in the settings picker.', $skill->name), StatusLevel::WARN, ); $this->renderFooter(); return; } - $this->pushNavigationOrigin(sprintf('skill %s', $skill->getName())); + $this->pushNavigationOrigin(sprintf('skill %s', $skill->name)); $this->databaseFilter->clear(); $this->databaseCategoryIndex = DatabaseCatalog::indexOf(self::DATABASE_CATEGORY_ANIMATIONS); - $this->databaseSelectedAnimationIndex = $animationIndex; + $this->setSelectedRecordIndex($animationIndex); $this->databaseFocus = self::DATABASE_FOCUS_LIST; $this->databaseSelectedSettingIndex = 0; - $this->setStatus(sprintf('Went to animation %s (Ctrl+B goes back).', $matchedName), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Went to animation %s (Ctrl+B goes back).', $matchedName), StatusLevel::INFO); $this->renderDatabaseArea(includeRoot: true); } @@ -6519,7 +6365,7 @@ private function goToMapDefinition(): void $this->clampCursor(); $this->syncViewportToCursor(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('Went to %s (Ctrl+B goes back).', $destination), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Went to %s (Ctrl+B goes back).', $destination), StatusLevel::INFO); $this->renderSelectionDependentArea(); } @@ -6563,8 +6409,8 @@ private function resolveGoToDestinationMapId(ProjectMap $selectedMap, ?string $m */ private function findDatabaseClassIndex(string $className): ?int { - foreach ($this->workspace?->classDatabase->getClasses() ?? [] as $index => $class) { - if (mb_strtolower($class->getName()) === mb_strtolower($className)) { + foreach ($this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_CLASSES)?->getEntryLabels() ?? [] as $index => $name) { + if (mb_strtolower($name) === mb_strtolower($className)) { return $index; } } @@ -6584,8 +6430,8 @@ private function findDatabaseAnimationIndex(string $animationName): ?int return null; } - foreach ($this->workspace?->animationDatabase->getAnimations() ?? [] as $index => $animation) { - if (mb_strtolower($animation->name) === mb_strtolower($animationName)) { + foreach ($this->workspace?->getRecordDatabase(self::DATABASE_CATEGORY_ANIMATIONS)?->getRecords() ?? [] as $index => $animation) { + if (mb_strtolower(strval($animation->get('name'))) === mb_strtolower($animationName)) { return $index; } } @@ -6616,10 +6462,7 @@ private function captureNavigationRestorer(): Closure 'databaseCategoryIndex' => $this->databaseCategoryIndex, 'databaseFocus' => $this->databaseFocus, 'actor' => $this->databaseSelectedActorIndex, - 'class' => $this->databaseSelectedClassIndex, - 'skill' => $this->databaseSelectedSkillIndex, - 'quest' => $this->databaseSelectedQuestIndex, - 'animation' => $this->databaseSelectedAnimationIndex, + 'records' => $this->databaseSelectedRecordIndexes, 'setting' => $this->databaseSelectedSettingIndex, 'assetIndex' => $this->selectedAssetIndex, 'cursorX' => $this->cursorX, @@ -6636,10 +6479,7 @@ private function captureNavigationRestorer(): Closure $this->databaseCategoryIndex = $snapshot['databaseCategoryIndex']; $this->databaseFocus = $snapshot['databaseFocus']; $this->databaseSelectedActorIndex = $snapshot['actor']; - $this->databaseSelectedClassIndex = $snapshot['class']; - $this->databaseSelectedSkillIndex = $snapshot['skill']; - $this->databaseSelectedQuestIndex = $snapshot['quest']; - $this->databaseSelectedAnimationIndex = $snapshot['animation']; + $this->databaseSelectedRecordIndexes = $snapshot['records']; $this->databaseSelectedSettingIndex = $snapshot['setting']; $this->selectedAssetIndex = $snapshot['assetIndex']; $this->cursorX = $snapshot['cursorX']; @@ -6677,7 +6517,8 @@ private function navigateBack(): void } /** - * Saves every dirty map and database in one pass. + * Saves every dirty map, database and cutscene in one pass, through the + * workspace save both editors share. * * Maps whose save would move their folder are skipped - the rename flow * requires its own explicit confirmation via Ctrl+S on that map. @@ -6690,119 +6531,55 @@ private function saveAllAssets(): void return; } - $savedMaps = 0; - $savedDatabases = 0; - $skippedRenames = []; - $failures = []; - $validationWarnings = []; - $mapsById = $this->getMapsById(); - - foreach ($this->workspace->maps as $map) { - if (! $map->isDirty()) { - continue; - } - - if ($map->willMoveOnSave()) { - $skippedRenames[] = $map->mapId; - continue; - } - - foreach (MapValidator::validate($map, $mapsById) as $warning) { - $validationWarnings[] = sprintf('%s: %s', $map->mapId, $warning); - } + $result = WorkspaceSave::saveAll($this->workspace, $this->backups); + $this->showSavedCutscenes(); - try { - $map->save($this->backupBeforeSave(...)); - $savedMaps++; - } catch (Throwable $throwable) { - Debug::error(sprintf('Save all (%s): %s', $map->mapId, $throwable->getMessage())); - $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); - } + foreach ($result->failures as $failure) { + Debug::error(sprintf('Save all (%s)', $failure)); } - // Categories sharing one file are saved together, so the file is - // written once with every dirty part folded in rather than once per - // category, each rewriting what the last just wrote. - foreach (SharedFileTransaction::groupByPath($this->getSaveableDatabases()) as $group) { - $dirty = array_filter($group, static fn(object $database): bool => $database->isDirty()); - - if ($dirty === []) { - continue; - } - - $label = implode(', ', array_keys($dirty)); - - try { - $backed = []; - - foreach ($dirty as $database) { - foreach ($this->getDatabaseBackupPaths($database) as $backupPath) { - // One file, one backup, however many categories of - // it are being written. - $backed[$backupPath] = $backupPath; - } - } - - $this->backupBeforeSave(...array_values($backed)); - - $shared = reset($dirty); + if ($result->backupFailures !== []) { + Debug::error(sprintf('Backup failed for: %s', implode(', ', $result->backupFailures))); + } - if ($shared instanceof ProjectRecordDatabase && $shared->sharesBackingFile()) { - // One write for the file, with every dirty category's - // edits composed against one snapshot of it first. - SharedFileTransaction::commit(array_values($dirty)); - } else { - foreach ($dirty as $database) { - $database->save(); - } - } + $detailLines = []; - $savedDatabases += count($dirty); - } catch (Throwable $throwable) { - Debug::error(sprintf('Save all (%s): %s', $label, $throwable->getMessage())); - $failures[] = sprintf('%s: %s', $label, $throwable->getMessage()); - } + if ($result->skippedRenames !== []) { + $detailLines[] = 'Skipped - saving would move the map folder (use Ctrl+S on the map to confirm):'; + $detailLines = [...$detailLines, ...array_map(static fn(string $mapId): string => ' ' . $mapId, $result->skippedRenames), '']; } - // Cutscenes: each dirty asset as its own paired transaction. - $cutscenes = $this->saveAllCutscenes(); - $savedCutscenes = count($cutscenes['saved']); - - foreach ($cutscenes['failed'] as $asset => $reason) { - Debug::error(sprintf('Save all (%s): %s', $asset, $reason)); - $failures[] = sprintf('%s: %s', $asset, $reason); + if ($result->skippedRelocations !== []) { + $detailLines[] = 'Skipped - saving would move existing saves off cells now solid or taken by an NPC (use Ctrl+S on the map to review where):'; + $detailLines = [...$detailLines, ...array_map(static fn(string $mapId): string => ' ' . $mapId, $result->skippedRelocations), '']; } - $summary = sprintf( - 'Saved %d map%s, %d database%s and %d cutscene%s.', - $savedMaps, - $savedMaps === 1 ? '' : 's', - $savedDatabases, - $savedDatabases === 1 ? '' : 's', - $savedCutscenes, - $savedCutscenes === 1 ? '' : 's', - ); - $detailLines = []; + if ($result->failures !== []) { + $detailLines = [...$detailLines, 'Failed:', ...array_map(static fn(string $failure): string => ' ' . $failure, $result->failures), '']; + } - if ($skippedRenames !== []) { - $detailLines[] = 'Skipped - saving would move the map folder (use Ctrl+S on the map to confirm):'; - $detailLines = [...$detailLines, ...array_map(static fn(string $mapId): string => ' ' . $mapId, $skippedRenames), '']; + if ($result->backupFailures !== []) { + $detailLines = [...$detailLines, 'Backups failed (the save still ran):', ...array_map(static fn(string $path): string => ' ' . $path, $result->backupFailures), '']; } - if ($failures !== []) { - $detailLines = [...$detailLines, 'Failed:', ...array_map(static fn(string $failure): string => ' ' . $failure, $failures), '']; + if ($result->errors !== []) { + $detailLines = [...$detailLines, 'Errors the player would see (saved as drafts; validation fails until fixed):', + ...array_map(static fn(string $error): string => ' ' . $error, $result->errors), '']; } - if ($validationWarnings !== []) { - $detailLines = [...$detailLines, 'Validation warnings:', ...array_map(static fn(string $warning): string => ' ' . $warning, $validationWarnings)]; + if ($result->warnings !== []) { + $detailLines = [...$detailLines, 'Validation warnings:', ...array_map(static fn(string $warning): string => ' ' . $warning, $result->warnings)]; } - if ($failures !== []) { - $this->setStatus($summary . sprintf(' %d failed (Ctrl+E for details).', count($failures)), StatusLevel::ERROR, $detailLines); - } elseif ($skippedRenames !== [] || $validationWarnings !== []) { - $this->setStatus($summary . ' See Ctrl+E for skipped saves and warnings.', StatusLevel::WARN, $detailLines); + if ($result->failures !== []) { + $this->setStatus($result->summary . sprintf(' %d failed (Ctrl+E for details).', count($result->failures)), StatusLevel::ERROR, $detailLines); + } elseif ($result->errors !== []) { + $this->setStatus($result->summary . sprintf(' %d error%s the player would see (Ctrl+E for details).', count($result->errors), + count($result->errors) === 1 ? '' : 's'), StatusLevel::ERROR, $detailLines); + } elseif ($result->skippedRenames !== [] || $result->skippedRelocations !== [] || $result->warnings !== [] || $result->backupFailures !== []) { + $this->setStatus($result->summary . ' See Ctrl+E for skipped saves and warnings.', StatusLevel::WARN, $detailLines); } else { - $this->setStatus($summary, StatusLevel::SUCCESS); + $this->setStatus($result->summary, StatusLevel::SUCCESS); } $this->renderSelectionDependentArea(); @@ -6838,59 +6615,6 @@ private function backupBeforeSave(string ...$paths): void } - /** - * Returns the files a database save overwrites. - * - * @param object $database The database about to be saved. - * @return string[] - */ - private function getDatabaseBackupPaths(object $database): array - { - if ($database instanceof ProjectActorDatabase) { - return array_map( - static fn(ProjectActor $actor): string => $actor->path, - $database->getActors(), - ); - } - - if ($database instanceof ProjectRecordDatabase) { - return $database->getBackupPaths(); - } - - return property_exists($database, 'path') ? [(string) $database->path] : []; - } - - /** - * Returns every saveable database keyed by display label. - * - * @return array - */ - private function getSaveableDatabases(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return []; - } - - $databases = [ - 'Actors' => $this->workspace->actorDatabase, - 'Classes' => $this->workspace->classDatabase, - 'Skills' => $this->workspace->skillDatabase, - 'Quests' => $this->workspace->questDatabase, - 'Animations' => $this->workspace->animationDatabase, - 'System' => $this->workspace->systemDatabase, - ]; - - // Read-only categories never join Save All: they hold no edits, and - // asking them to save would raise instead of no-op. - foreach ($this->workspace->recordDatabases as $categoryKey => $recordDatabase) { - if ($recordDatabase->isEditable()) { - $databases[DatabaseCatalog::at(DatabaseCatalog::indexOf($categoryKey))->label] = $recordDatabase; - } - } - - return $databases; - } - /** * Returns the workspace maps keyed by map id for validation lookups. * @@ -7033,12 +6757,7 @@ private function performExplicitMapMove(): void */ private function deriveProposedMapId(ProjectMap $map): string { - $name = strtolower(trim((string) preg_replace('/[^A-Za-z0-9]+/', '-', $map->getDisplayName()))); - $name = trim($name, '-') !== '' ? trim($name, '-') : basename($map->directory); - $region = strtolower(trim((string) preg_replace('/[^A-Za-z0-9]+/', '-', $map->getRegion()))); - $region = trim($region, '-'); - - return $region !== '' ? $region . '/' . $name : $name; + return $map->getProposedMapId(); } /** @@ -7047,7 +6766,7 @@ private function deriveProposedMapId(ProjectMap $map): string * * @return void */ - private function performSaveSelectedMap(): void + private function performSaveSelectedMap(?SaveRelocationPlan $reviewed = null): void { if (! $this->workspace instanceof ProjectWorkspace) { return; @@ -7059,14 +6778,39 @@ private function performSaveSelectedMap(): void return; } - $warnings = MapValidator::validate($selectedMap, $this->getMapsById()); + try { + $relocation = SaveRelocationPlanner::plan($selectedMap, $this->workspace->projectRoot); + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, 'Save'); + return; + } + if ($relocation !== null && ($reviewed === null || ! hash_equals($relocation->getFingerprint(), $reviewed->getFingerprint()))) { + $this->openSaveRelocationReview($relocation); + return; + } + + $issues = MapValidator::validate($selectedMap, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); + $errors = MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::ERROR)); + $warnings = MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)); + $step = null; try { $previousMapId = $selectedMap->mapId; + // Saves standing where the map is now solid or taken by an NPC move with it; the step is its own undo. + $step = $relocation?->apply(); // The save backs up, once, exactly the files it is about to // replace -- so a clean map or an untouched member never // produces a backup copy. - $savedMapId = $selectedMap->save($this->backupBeforeSave(...)); + try { + $savedMapId = $selectedMap->save($this->backupBeforeSave(...)); + } catch (Throwable $failure) { + $relocation?->revert(); + $step = null; + throw $failure; + } + if ($step !== null) { + $this->recordCommand($step); + } if ($savedMapId !== $previousMapId) { // The folder moved: swap in a freshly parsed map instance and @@ -7086,8 +6830,18 @@ private function performSaveSelectedMap(): void $this->clampInspectorSelection(); } - if ($warnings === []) { - $this->setStatus(sprintf('Saved %s.', $savedMapId), StatusLevel::SUCCESS); + $moved = $step === null ? '' : sprintf(' Saves on %d cells it makes solid now move to open floor (save content version %d).', + array_sum(array_map(static fn(array $relocation): int => count($relocation['cells']), $relocation->relocations)), $relocation->contentVersion); + if ($errors !== []) { + // Saved as a draft: the player would meet these, so validation fails until they are fixed. + $this->setStatus( + sprintf('Saved %s as a draft with %d error%s the player would see (Ctrl+E for details).%s', $savedMapId, count($errors), + count($errors) === 1 ? '' : 's', $moved), + StatusLevel::ERROR, + [...$errors, ...($warnings === [] ? [] : ['', 'Warnings:', ...$warnings])], + ); + } elseif ($warnings === []) { + $this->setStatus(sprintf('Saved %s.%s', $savedMapId, $moved), StatusLevel::SUCCESS); } else { $this->setStatus( sprintf('Saved %s with %d warning%s (Ctrl+E for details).', $savedMapId, count($warnings), count($warnings) === 1 ? '' : 's'), @@ -7103,21 +6857,49 @@ private function performSaveSelectedMap(): void } /** - * Creates a new blank map and begins editing its display name. - * + * Lists where saving the map moves existing saves, for confirmation: + * nothing is written until Write is chosen. + */ + private function openSaveRelocationReview(SaveRelocationPlan $plan): void + { + $this->optionDialogField = ['saveRelocation' => $plan]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = sprintf('Save %s and move existing saves', $plan->mapId); + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Save nothing.'], + ['label' => 'Save and move saves', 'value' => 'write', + 'description' => sprintf('Saves the map and adds save content version %d to %s. Ctrl+Z removes that step.', $plan->contentVersion, SaveManifestStep::PATH)], + ...array_map(static fn(string $move): array => [ + 'label' => 'Move saves on ' . $move, 'value' => 'preview', + 'description' => 'A save standing there would load inside what is now solid, or on an NPC.', + ], $plan->describeMoves()), + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->setStatus(sprintf('%s now blocks cells where saves could stand. Review where they move before saving.', $plan->mapId), StatusLevel::WARN); + $this->renderSelectionDependentArea(); + } + + /** + * Creates a new blank map and begins editing its display name. + * * @return void */ - private function createNewMap(): void + private function createNewMap(?string $kind = null, ?string $kindLabel = null): void { if (! $this->workspace instanceof ProjectWorkspace) { return; } try { - $mapId = $this->workspace->createMap(); - $this->reloadWorkspaceSelectingMap($mapId); + $mapId = $this->workspace->createMap(kind: $kind); + $this->addWorkspaceMapSelecting($mapId); $this->selectedInspectorFieldIndex = 0; - $this->setStatus(sprintf('Created %s.', $mapId), StatusLevel::SUCCESS); + $this->setStatus($kind === null + ? sprintf('Created %s. It has no kind: the project has no tilesets in assets/%s/.', $mapId, Tileset::DIRECTORY) + : sprintf('Created %s; its kind is %s.', $mapId, $kindLabel ?? $kind), StatusLevel::SUCCESS); $this->setFocusedPane(self::FOCUS_INSPECTOR); $this->beginInspectorEdit(); } catch (Throwable $throwable) { @@ -7144,7 +6926,7 @@ private function duplicateSelectedMap(): void return; } - $this->reloadWorkspaceSelectingMap($mapId); + $this->addWorkspaceMapSelecting($mapId); $this->setStatus(sprintf('Duplicated %s.', $mapId), StatusLevel::SUCCESS); $this->renderSelectionDependentArea(); } catch (Throwable $throwable) { @@ -7650,7 +7432,8 @@ private function handleEventOptionDialogInput(string $input, string $normalizedI } if ($input === chr(27)) { - $this->closeEventOptionDialog('Selection cancelled.'); + $this->closeEventOptionDialog(is_array($this->optionDialogField['glyphPiece'] ?? null) + ? 'Nothing was painted; the map is as it was.' : 'Selection cancelled.'); return; } @@ -7694,9 +7477,80 @@ private function applySelectedEventOption(): void $field = $this->optionDialogField; $title = $this->eventOptionDialogTitle; + if (($field['canvasLayer'] ?? false) === true) { + $this->closeEventOptionDialog(); + if ($selectedEntry['value'] === self::NPC_LAYER_ENTRY) { + $this->setEditingMode(self::MODE_NPC); + return; + } + $this->selectCanvasLayer((string) $selectedEntry['value']); + $this->setStatus(sprintf('Editing the %s layer.', $selectedEntry['label'])); + $this->renderCanvasArea(); + return; + } + + if (($field['canvasPiece'] ?? false) === true) { + $this->closeEventOptionDialog(); + $this->choosePiece((string) $selectedEntry['value']); + return; + } + + if (($field['canvasKind'] ?? false) === true) { + $this->closeEventOptionDialog(); + $this->chooseKind((string) $selectedEntry['value']); + return; + } + + if (($field['newMapKind'] ?? false) === true) { + $this->closeEventOptionDialog(); + $this->createNewMap((string) $selectedEntry['value'], (string) $selectedEntry['label']); + return; + } + + if (($field['target'] ?? null) === 'map-kind') { + $this->closeEventOptionDialog(); + $this->chooseMapKind((string) $selectedEntry['value'], (string) $selectedEntry['label']); + return; + } + + if (is_array($field['mapKindChange'] ?? null)) { + $this->confirmMapKindChange($field['mapKindChange'], $selectedEntry['value'] === 'clear'); + return; + } + + if (is_array($field['glyphPiece'] ?? null)) { + $this->chooseGlyphPiece($field['glyphPiece'], (string) $selectedEntry['value']); + return; + } + + if (($field['saveRelocation'] ?? null) instanceof SaveRelocationPlan) { + if ($selectedEntry['value'] === 'preview') { return; } + $plan = $field['saveRelocation']; + $this->closeEventOptionDialog(); + $selectedEntry['value'] === 'write' ? $this->performSaveSelectedMap($plan) : $this->setStatus('Save cancelled. No files were changed.'); + return; + } + + if (($field['mapLines'] ?? null) instanceof MapLinePlan) { + if ($selectedEntry['value'] === 'preview') { return; } + $this->confirmMapLines($field['mapLines'], $selectedEntry['value'] === 'write'); + return; + } + + if (($field['actorReferenceMigration'] ?? null) instanceof ActorIdentityMigrationPlan) { + if ($selectedEntry['value'] === 'preview') { return; } + $this->confirmActorReferenceMigration($field['actorReferenceMigration'], $selectedEntry['value'] === 'migrate'); + return; + } + + if (($field['actorIdentityMigration'] ?? null) instanceof ProjectActor) { + $this->confirmActorIdentityMigration($field['actorIdentityMigration'], $selectedEntry['value'] === 'freeze'); + return; + } + try { - $this->applyInspectorFieldValue($field, (string) $selectedEntry['value']); - $this->closeEventOptionDialog(sprintf('Set %s to %s.', mb_strtolower($title), $selectedEntry['label'])); + $applied = $this->applyInspectorFieldValue($field, (string) $selectedEntry['value']); + $this->closeEventOptionDialog($applied ? sprintf('Set %s to %s.', mb_strtolower($title), $selectedEntry['label']) : ''); } catch (Throwable $throwable) { $this->closeEventOptionDialog(''); $this->setErrorStatus($throwable, sprintf('%s selection', $title)); @@ -7889,27 +7743,24 @@ private function commitDestinationSpawnSelection(): void return; } - $marker = $context['marker']; - $destinationPath = $context['path']; - $newDestination = $context['destinationMapId']; - $newSpawnX = $this->cursorX; - $newSpawnY = $this->cursorY; - $oldDestination = $sourceMap->getEventField($marker, $destinationPath); - $oldSpawnX = $sourceMap->getEventField($marker, ['data', 'spawnPoint', 'x']); - $oldSpawnY = $sourceMap->getEventField($marker, ['data', 'spawnPoint', 'y']); + $destinationIndex = array_search($context['destinationMapId'], $this->workspace->mapIds, true); + $destinationMap = is_int($destinationIndex) ? $this->workspace->getMapByIndex($destinationIndex) : null; - $applyDestination = static function (mixed $destination, mixed $spawnX, mixed $spawnY) use ($sourceMap, $marker, $destinationPath): void { - $sourceMap->setEventField($marker, $destinationPath, $destination); - $sourceMap->setEventField($marker, ['data', 'spawnPoint', 'x'], $spawnX); - $sourceMap->setEventField($marker, ['data', 'spawnPoint', 'y'], $spawnY); - }; + if (! $destinationMap instanceof ProjectMap) { + $this->restoreDestinationSelectionContext('Unable to save destination selection.'); + return; + } - $applyDestination($newDestination, $newSpawnX, $newSpawnY); - $this->recordCommand(new GenericCommand( - 'Destination change', - static fn() => $applyDestination($newDestination, $newSpawnX, $newSpawnY), - static fn() => $applyDestination($oldDestination, $oldSpawnX, $oldSpawnY), - )); + try { + $command = $this->createMapInspector()->setTransferDestination($sourceMap, $context['marker'], $destinationMap, $this->cursorX, $this->cursorY); + } catch (InspectorRefusal $refusal) { + $this->restoreDestinationSelectionContext($refusal->getMessage()); + return; + } + + if ($command !== null) { + $this->recordCommand($command); + } $this->restoreDestinationSelectionContext( sprintf( @@ -8055,35 +7906,18 @@ private function applySelectedEventType(): void } $definition = EventTypeCatalog::at($this->selectedEventTypeIndex); - $currentDefinition = $selectedMap->getEventDefinition($marker); - $currentClassName = is_array($currentDefinition) ? (string) ($currentDefinition['class'] ?? '') : ''; - $newDefinition = $currentClassName === $definition->className && is_array($currentDefinition) - ? array_replace_recursive( - [ - 'class' => $definition->className, - 'data' => $definition->defaultData, - ...$definition->defaultDefinitionFields, - ], - $currentDefinition, - ) - : [ - 'class' => $definition->className, - 'data' => $definition->defaultData, - ...$definition->defaultDefinitionFields, - ]; - $selectedMap->setEventDefinition($marker, $newDefinition); - $this->recordCommand(new GenericCommand( - 'Event type change', - static fn() => $selectedMap->setEventDefinition($marker, $newDefinition), - static function () use ($selectedMap, $marker, $currentDefinition): void { - if (is_array($currentDefinition)) { - $selectedMap->setEventDefinition($marker, $currentDefinition); - return; - } - $selectedMap->removeEventDefinition($marker); - }, - )); + try { + $command = EventAuthoring::setEventType($selectedMap, $marker, $definition); + } catch (EventRefusal $refusal) { + $this->closeEventTypeDialog($refusal->getMessage()); + return; + } + + if ($command !== null) { + $this->recordCommand($command); + } + $this->clampInspectorSelection(); $this->closeEventTypeDialog(sprintf('%s is now a %s event.', $marker, $definition->label)); } @@ -8151,9 +7985,10 @@ private function deleteSelectedMap(): void return; } + // Undo steps may name the deleted map; every other map keeps its unsaved changes. $this->history->clear(); $this->activeStrokeCommand = null; - $this->workspace = ProjectWorkspace::fromProject($this->projectRoot); + $this->workspace = $this->workspace->withoutMap($deletedMapId); $this->selectedAssetIndex = $this->clampSelection(min($currentIndex, max(0, count($this->workspace->mapIds) - 1))); $this->cursorX = 0; $this->cursorY = 0; @@ -8173,18 +8008,17 @@ private function deleteSelectedMap(): void } /** - * Reloads the workspace and selects the requested map id. + * Adds a map just written to disk to the workspace and selects it. * - * @param string $mapId The map id to select after reload. + * @param string $mapId The created or duplicated map's id. * @return void */ - private function reloadWorkspaceSelectingMap(string $mapId): void + private function addWorkspaceMapSelecting(string $mapId): void { - // A full rescan replaces every loaded map object, so retained undo - // commands would mutate stale instances - drop them. - $this->history->clear(); + // Only the new map is read, so every other map keeps its unsaved + // changes and the undo history stays valid. $this->activeStrokeCommand = null; - $this->workspace = ProjectWorkspace::fromProject($this->projectRoot); + $this->workspace = $this->workspace->withLoadedMap($mapId); $selectedIndex = array_search($mapId, $this->workspace->mapIds, true); $this->selectedAssetIndex = is_int($selectedIndex) ? $selectedIndex : 0; $this->cursorX = 0; @@ -8299,6 +8133,22 @@ private function activateInspectorField(): void return; } + if (($field['target'] ?? null) === EventRecordPane::TARGET && is_array($field['frame'] ?? null)) { + $this->enterEventInspectorFrame((string) ($field['marker'] ?? ''), array_values($field['frame'])); + return; + } + + if (($field['worldWrites'] ?? false) === true) { + $this->openMapWriteEditor($field); + return; + } + + // A short enum also lists its options and is stepped through below; a longer list is picked from. + if (is_array($field['choices'] ?? null) && $field['choices'] !== [] && ! isset($field['options'])) { + $this->openInspectorChoiceDialog($field); + return; + } + if ($this->isChestTypeField($field)) { $this->openEventOptionDialog( (string) ($field['marker'] ?? ''), @@ -8344,7 +8194,7 @@ private function activateInspectorField(): void } if (($field['mapConditions'] ?? false) === true) { - $this->openMapVariantConditionEditor($field); + $this->openMapConditionEditor($field); return; } @@ -8479,11 +8329,11 @@ private function resolveInspectorFieldOptions(array $field): ?array private function applyInspectorAdjustment(array $field, string $rawValue): void { try { - $this->applyInspectorFieldValue($field, $rawValue); + $applied = $this->applyInspectorFieldValue($field, $rawValue); $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::SUCCESS); + if ($applied) { $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); } } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } @@ -8647,11 +8497,11 @@ private function commitInspectorEdit(): void } try { - $this->applyInspectorFieldValue($field, $this->inspectorEditBuffer); + $applied = $this->applyInspectorFieldValue($field, $this->inspectorEditBuffer); $this->clampCursor(); $this->clampCanvasOffsets(); $this->clampInspectorSelection(); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::SUCCESS); + if ($applied) { $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); } } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } @@ -8667,128 +8517,51 @@ private function commitInspectorEdit(): void * * @param array $field The inspector field descriptor. * @param string $rawValue The raw edited value. - * @return void + * @return bool False when the edit was refused, whose reason is then the status; callers announce success only on true. */ - private function applyInspectorFieldValue(array $field, string $rawValue): void + private function applyInspectorFieldValue(array $field, string $rawValue): bool { $selectedMap = $this->getSelectedMap(); if (! $selectedMap instanceof ProjectMap) { - return; - } - - $control = $this->getInspectorFieldControl($field); - $type = $control?->type ?? InputControlType::TEXT; - $value = match ($type) { - InputControlType::INTEGER => (int) trim($rawValue), - InputControlType::FLOAT => (float) trim($rawValue), - InputControlType::BOOLEAN => InputControl::parseBoolean($rawValue), - default => $rawValue, - }; - $target = (string) ($field['target'] ?? 'map'); - - if ($target === 'map') { - $fieldName = (string) $field['field']; - $oldValue = $selectedMap->getMapField($fieldName); - $selectedMap->setMapField($fieldName, $value); - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Field'), - static fn() => $selectedMap->setMapField($fieldName, $value), - static fn() => $selectedMap->setMapField($fieldName, $oldValue), - )); - return; - } - - if ($target === 'map-data') { - $path = array_values((array) ($field['path'] ?? [])); - - if ($path === []) { - return; - } - - $this->applyMapDataValue($selectedMap, $path, $value === '' ? null : $value, (string) ($field['label'] ?? 'Field')); - - return; - } - - if ($target === 'map-encounters') { - $this->applyMapEncounterValue($selectedMap, $field, $value); - - return; - } - - if ($target === 'map-bgm-variants') { - $this->applyMapBgmVariantValue($selectedMap, $field, $value); - - return; + return false; } - if ($target === 'map-size') { - $newWidth = (string) ($field['field'] ?? '') === 'width' ? max(1, (int) $value) : $selectedMap->getWidth(); - $newHeight = (string) ($field['field'] ?? '') === 'height' ? max(1, (int) $value) : $selectedMap->getHeight(); - $stranded = $selectedMap->describeNpcsStrandedBy($newWidth, $newHeight); - - if ($stranded !== []) { - // Refuse rather than clamp, delete, or truncate: the author - // moves, resizes or removes the NPC, then shrinks. - $this->setStatus( - sprintf('Cannot shrink to %dx%d: %d NPC%s would be stranded (Ctrl+E lists them).', $newWidth, $newHeight, count($stranded), count($stranded) === 1 ? '' : 's'), - StatusLevel::ERROR, - array_map(static fn(string $line): string => '- ' . $line, $stranded), - ); - - return; - } - - $snapshotBefore = $selectedMap->captureGridSnapshot(); - $selectedMap->resize($newWidth, $newHeight); - $snapshotAfter = $selectedMap->captureGridSnapshot(); - $this->recordCommand(new GenericCommand( - 'Map resize', - static fn() => $selectedMap->restoreGridSnapshot($snapshotAfter), - static fn() => $selectedMap->restoreGridSnapshot($snapshotBefore), - )); - return; + try { + $command = $this->createMapInspector()->apply($selectedMap, $field, $rawValue); + } catch (InspectorRefusal $refusal) { + $this->setStatus($refusal->getMessage(), $refusal->details === [] ? StatusLevel::WARN : StatusLevel::ERROR, $refusal->details); + return false; } - if ($target === 'event') { - $marker = (string) $field['marker']; - $path = (array) ($field['path'] ?? []); - $oldValue = $selectedMap->getEventField($marker, $path); - $selectedMap->setEventField($marker, $path, $value); - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Event field'), - static fn() => $selectedMap->setEventField($marker, $path, $value), - static fn() => $selectedMap->setEventField($marker, $path, $oldValue), - )); - return; + if ($command !== null) { + $this->recordCommand($command); + $this->reselectInspectorField(MapInspector::describeRowKey($field)); } - if ($target === 'event-bounds') { - $bounds = $selectedMap->getEventBounds((string) $field['marker']); + return true; + } - if ($bounds === null) { + /** + * Keeps the cursor on the row an edit was made through: an edit can add or remove rows above it (describing an + * event lists its landmark among the map's rows), and the cursor follows the row, not its old position. + * + * @param array|null $key The row's key ({@see MapInspector::describeRowKey()}). + */ + private function reselectInspectorField(?array $key): void + { + if ($key === null) { return; } + ksort($key); + foreach ($this->getInspectorFields() as $index => $candidate) { + $candidateKey = MapInspector::describeRowKey($candidate); + if ($candidateKey === null) { continue; } + ksort($candidateKey); + if ($candidateKey === $key) { + $this->selectedInspectorFieldIndex = $index; return; } - - $snapshotBefore = $selectedMap->captureGridSnapshot(); - $bounds[(string) $field['field']] = max(0, (int) $value); - $selectedMap->setEventBounds( - (string) $field['marker'], - $bounds['x'], - $bounds['y'], - $bounds['width'], - $bounds['height'], - ); - $snapshotAfter = $selectedMap->captureGridSnapshot(); - $this->recordCommand(new GenericCommand( - 'Event bounds edit', - static fn() => $selectedMap->restoreGridSnapshot($snapshotAfter), - static fn() => $selectedMap->restoreGridSnapshot($snapshotBefore), - )); } } - /** * Returns the currently selected database category label. * @@ -8836,16 +8609,6 @@ private function isSkillsDatabaseSelected(): bool { return $this->getSelectedDatabaseCategoryDefinition()->key === self::DATABASE_CATEGORY_SKILLS; } - /** - * Returns whether the Animations database is active. - * - * @return bool - */ - private function isAnimationsDatabaseSelected(): bool - { - return $this->getSelectedDatabaseCategoryDefinition()->key === self::DATABASE_CATEGORY_ANIMATIONS; - } - /** * Returns whether the Quests database is active. * @@ -8976,22 +8739,21 @@ private function createDatabaseRecord(): void return; } - $index = $database->addRecord(); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->createRecord($database)); - if ($index === null) { - $this->setStatus( - sprintf('%s entries cannot be created from the editor.', ucfirst($database->schema->entryNoun)), - StatusLevel::WARN, - ); - $this->renderDatabaseArea(); + if ($change === null) { return; } - $this->setSelectedRecordIndex($index); + $this->setSelectedRecordIndex((int) $change->index); $this->databaseSelectedSettingIndex = 0; $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->setStatus(sprintf('Created a new %s.', $database->schema->entryNoun), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Created a new %s.', $database->schema->entryNoun), StatusLevel::INFO); $this->renderDatabaseArea(); + if ($database->schema->key === 'skits') { + $this->selectNewSkitActor(0); + return; + } $this->beginDatabaseEdit(); } @@ -9015,29 +8777,14 @@ private function duplicateDatabaseRecord(): void } $index = $this->getSelectedRecordIndex(); - $copyIndex = $database->duplicateRecord($index); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->duplicateRecord($database, $index)); - if ($copyIndex === null) { - $this->setStatus( - sprintf('This %s cannot be duplicated.', $database->schema->entryNoun), - StatusLevel::WARN, - ); - $this->renderDatabaseArea(); + if ($change === null) { return; } - $copy = $database->getRecordByIndex($copyIndex); - - if ($copy instanceof ProjectRecord) { - $this->recordCommand(new GenericCommand( - sprintf('%s duplicate', ucfirst($database->schema->entryNoun)), - static fn() => $database->insertRecord($copyIndex, $copy), - static fn() => $database->removeRecord($copyIndex), - )); - } - - $this->setSelectedRecordIndex($copyIndex); - $this->setStatus(sprintf('Duplicated the %s.', $database->schema->entryNoun), StatusLevel::SUCCESS); + $this->setSelectedRecordIndex((int) $change->index); + $this->setStatus(sprintf('Duplicated the %s.', $database->schema->entryNoun), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9064,33 +8811,20 @@ private function moveDatabaseRecord(int $step): void return; } - if (! $database->supportsDurableReorder()) { - // Refusing beats a reorder the file cannot keep: nothing moves, - // nothing dirties, and the author learns why. - $this->setStatus((string) $database->reorderRefusalReason(), StatusLevel::WARN); - $this->renderDatabaseArea(); - return; - } - + // A category whose file would not keep the order refuses, and says why. $from = $this->getSelectedRecordIndex(); - $to = $from + $step; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->moveRecord($database, $from, $step)); - if (! $database->moveRecord($from, $to)) { + if ($change?->command === null) { return; } - $this->recordCommand(new GenericCommand( - sprintf('%s move', ucfirst($database->schema->entryNoun)), - static fn() => $database->moveRecord($from, $to), - static fn() => $database->moveRecord($to, $from), - )); - - $this->setSelectedRecordIndex($to); + $this->setSelectedRecordIndex((int) $change->index); $this->setStatus(sprintf( 'Moved the %s %s.', $database->schema->entryNoun, $step < 0 ? 'up' : 'down', - ), StatusLevel::SUCCESS); + ), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9113,45 +8847,37 @@ private function addDatabaseFrameItem(ProjectRecordDatabase $database, RecordSub $recordIndex = $this->getSelectedRecordIndex(); $framePath = $this->databaseCommandFramePath; $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - $prefix = preg_quote($subList->prefix, '/'); + // The frame's own list names its rows and the commands added to it. + $list = $database->getFrameSubList($framePath) ?? $subList; + $item = $database->locateItem($recordIndex, $framePath, $selectedId); - if (preg_match('/^' . $prefix . '(\\d+)Option(\\d+)/', $selectedId, $matches) === 1) { - $commandIndex = intval($matches[1]); - $optionIndex = $database->addChoiceOption($recordIndex, $framePath, $commandIndex); + if ($item?->kind === RecordItem::OPTION) { + $commandIndex = $item->entryIndex; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addOption($database, $recordIndex, $framePath, $commandIndex)); - if ($optionIndex === null) { + if ($change?->command === null) { return; } - $option = ['text' => 'New option', 'then' => []]; - $this->recordCommand(new GenericCommand( - 'Option add', - static fn() => $database->insertChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex, $option), - static fn() => $database->removeChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex), - )); - $this->selectDatabaseFieldById(sprintf('%s%dOption%dText', $subList->prefix, $commandIndex, $optionIndex)); - $this->setStatus(sprintf('Added option %d.', $optionIndex + 1), StatusLevel::SUCCESS); + $optionIndex = (int) $change->index; + $this->selectDatabaseFieldById(sprintf('%s%dOption%dText', $list->prefix, $commandIndex, $optionIndex)); + $this->setStatus(sprintf('Added option %d.', $optionIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); $this->beginDatabaseEdit(); return; } - $afterIndex = preg_match('/^' . $prefix . '(\\d+)/', $selectedId, $matches) === 1 ? intval($matches[1]) : null; - $commandIndex = $database->addFrameCommand($recordIndex, $framePath, $afterIndex); + $afterIndex = $item?->entryIndex; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addEntry($database, $recordIndex, $framePath, $afterIndex)); - if ($commandIndex === null) { + if ($change?->command === null) { return; } - $blank = $subList->blank; - $this->recordCommand(new GenericCommand( - sprintf('%s add', ucfirst($subList->singular)), - static fn() => $database->insertFrameCommand($recordIndex, $framePath, $commandIndex, $blank), - static fn() => $database->removeFrameCommand($recordIndex, $framePath, $commandIndex), - )); - $this->selectDatabaseFieldById(sprintf('%s%dType', $subList->prefix, $commandIndex)); - $this->setStatus(sprintf('Added %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::SUCCESS); + $commandIndex = (int) $change->index; + $this->selectDatabaseFieldById(sprintf('%s%dType', $list->prefix, $commandIndex)); + $this->setStatus(sprintf('Added %s %d.', $list->singular, $commandIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9168,52 +8894,45 @@ private function removeDatabaseFrameItem(ProjectRecordDatabase $database, Record $recordIndex = $this->getSelectedRecordIndex(); $framePath = $this->databaseCommandFramePath; $selectedId = (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); - $prefix = preg_quote($subList->prefix, '/'); + $list = $database->getFrameSubList($framePath) ?? $subList; + $item = $database->locateItem($recordIndex, $framePath, $selectedId); - if (preg_match('/^' . $prefix . '(\\d+)Option(\\d+)/', $selectedId, $matches) === 1) { - $commandIndex = intval($matches[1]); - $optionIndex = intval($matches[2]); - $removed = $database->removeChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex); + if ($item?->kind === RecordItem::OPTION) { + $optionIndex = (int) $item->childIndex; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeOption($database, $recordIndex, $framePath, $item->entryIndex, $optionIndex)); - if ($removed === null) { + if ($change?->command === null) { return; } - $armCount = count((array) ($removed['then'] ?? [])); + $armCount = count((array) ($change->removed['then'] ?? [])); $this->clampDatabaseSettingSelection(); - $this->recordCommand(new GenericCommand( - 'Option remove', - static fn() => $database->removeChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex), - static fn() => $database->insertChoiceOption($recordIndex, $framePath, $commandIndex, $optionIndex, $removed), - )); $this->setStatus( $armCount > 0 ? sprintf('Removed option %d and its %d commands.', $optionIndex + 1, $armCount) : sprintf('Removed option %d.', $optionIndex + 1), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->renderDatabaseArea(); return; } - $commands = $database->getFrameCommands($recordIndex, $framePath) ?? []; - $commandIndex = preg_match('/^' . $prefix . '(\\d+)/', $selectedId, $matches) === 1 - ? intval($matches[1]) - : count($commands) - 1; - $removed = $database->removeFrameCommand($recordIndex, $framePath, $commandIndex); + // With the cursor on no command, the last one goes. + $commandIndex = $item?->entryIndex ?? count($database->getFrameCommands($recordIndex, $framePath) ?? []) - 1; - if ($removed === null) { + if ($commandIndex < 0) { + return; + } + + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeEntry($database, $recordIndex, $framePath, $commandIndex)); + + if ($change?->command === null) { return; } $this->clampDatabaseSettingSelection(); - $this->recordCommand(new GenericCommand( - sprintf('%s remove', ucfirst($subList->singular)), - static fn() => $database->removeFrameCommand($recordIndex, $framePath, $commandIndex), - static fn() => $database->insertFrameCommand($recordIndex, $framePath, $commandIndex, $removed), - )); - $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $commandIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed %s %d.', $list->singular, $commandIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9292,6 +9011,12 @@ private function activeRecordIndex(): int : $this->getSelectedRecordIndex(); } + /** The field id of the settings row under the cursor, or an empty string. */ + private function getSelectedDatabaseFieldId(): string + { + return (string) ($this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex]['field'] ?? ''); + } + /** * Leaves the open command frame for the one enclosing it. * @@ -9307,27 +9032,14 @@ private function leaveCommandFrame(): void $database = $this->activeRecordDatabase(); - // A record-level list is [key]; a list member's arm ends - // [i, listKey, j, armKey] -- an option's `then`, a lane's - // `commands`; a branch, sequence or variant arm ends [i, arm]. - $count = count($path); - $chunk = $count === 1 && is_string($path[0]) - ? 1 - : ((is_string($path[$count - 3] ?? null) && is_int($path[$count - 2] ?? null) && is_string($path[$count - 1] ?? null) && is_int($path[$count - 4] ?? null)) ? 4 : 2); - $enclosingCommand = $chunk === 1 ? null : intval($path[$count - $chunk]); - $this->databaseCommandFramePath = array_slice($path, 0, $count - $chunk); - $this->databaseSelectedSettingIndex = 0; + if (! $database instanceof ProjectRecordDatabase) { + $this->databaseCommandFramePath = []; - // Land back on the row the frame belonged to: the record's own list - // row, or the command (or sub-list entry) that holds the arm. - $enclosingList = $this->databaseCommandFramePath !== [] && is_string($this->databaseCommandFramePath[0]) - ? ($database?->schema->commandLists[$this->databaseCommandFramePath[0]] ?? null) - : null; - $landing = $chunk === 1 - ? 'commandList' . ucfirst(strval($path[0])) - : (($this->databaseCommandFramePath === [] - ? ($database?->schema->subList?->prefix ?? 'command') - : ($enclosingList?->prefix ?? 'command')) . $enclosingCommand); + return; + } + + ['frame' => $this->databaseCommandFramePath, 'landing' => $landing] = $database->describeEnclosingFrame($path); + $this->databaseSelectedSettingIndex = 0; foreach ($this->getDatabaseSettingsFields() as $index => $field) { if (str_starts_with((string) ($field['field'] ?? ''), $landing)) { @@ -9336,14 +9048,14 @@ private function leaveCommandFrame(): void } } - $this->setStatus(($database?->describeFramePath($this->databaseCommandFramePath) ?? 'Commands') . '.', StatusLevel::INFO); + $this->setStatus($database->describeFramePath($this->databaseCommandFramePath) . '.', StatusLevel::INFO); $this->renderDatabasePanes(['settings', 'cue']); } private function addDatabaseRecordSubItem(): void { $database = $this->getSelectedRecordDatabase(); - $subList = $database?->schema->subList; + $subList = $database?->schema->getInlineSubLists()[0] ?? null; if (! $database instanceof ProjectRecordDatabase || $subList === null) { return; @@ -9355,30 +9067,138 @@ private function addDatabaseRecordSubItem(): void return; } - if ($database->hasCommandFrames()) { + // Inside a frame the frame's commands; at a record's root with lists beside its own (a troop's + // event pages), the list the cursor's row belongs to, as for any record. + if ($database->hasCommandFrames() && ($this->databaseCommandFramePath !== [] || $database->schema->subLists === [])) { $this->addDatabaseFrameItem($database, $subList); return; } $recordIndex = $this->getSelectedRecordIndex(); - $entryIndex = $database->addSubItem($recordIndex); + // The row under the cursor says which list and where: after its + // entry, or first in the list whose heading it is. Elsewhere, at the + // end of the record's own list. + $fieldId = $this->getSelectedDatabaseFieldId(); + $item = $database->locateItem($recordIndex, [], $fieldId); + $subList = $database->schema->findInlineSubList($item?->listKey) ?? $subList; + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $item === null + ? $authoring->addEntry($database, $recordIndex, []) + : $authoring->addItem($database, $recordIndex, [], $fieldId)); - if ($entryIndex === null) { + if ($change?->command === null) { return; } - $entry = $database->getRecordByIndex($recordIndex)?->getSubList($subList->key)[$entryIndex] ?? []; + $entryIndex = (int) $change->index; + $this->setStatus(sprintf('Added %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::INFO); + $this->renderDatabaseArea(); + if ($database->schema->key === 'skits') { + $this->selectNewSkitActor($entryIndex); + } + } - $this->recordCommand(new GenericCommand( - sprintf('%s add', ucfirst($subList->singular)), - static fn() => $database->insertSubItem($recordIndex, $entryIndex, $entry), - static fn() => $database->removeSubItem($recordIndex, $entryIndex), - )); + /** Whether the cursor is on the troop's own rows or members, not its event pages or their commands. */ + private function isTroopMemberContext(): bool + { + $database = $this->getSelectedRecordDatabase(); + if ($this->databaseCommandFramePath !== [] || ! $database instanceof ProjectRecordDatabase) { + return $this->databaseCommandFramePath === []; + } + $item = $database->locateItem($this->getSelectedRecordIndex(), [], $this->getSelectedDatabaseFieldId()); + + return $item?->listKey === null || $item->listKey === TroopAuthoring::MEMBERS; + } + + /** Opens the project's enemies to add a member to the selected troop, fighting the one chosen. */ + private function openTroopMemberPicker(): void + { + $catalog = $this->referenceCatalog(); + if (! $this->referencePicker->open(self::TROOP_MEMBER_ENEMY_FIELD, 'Enemy', 'enemies', $catalog->valuesFor('enemies'), '', $catalog->labelsFor('enemies'))) { + $this->setStatus('This project defines no enemies to add to a troop.', StatusLevel::WARN); + $this->renderDatabaseArea(); + + return; + } + + $this->statusMessage = 'Choose the enemy the new member fights.'; + $this->renderDatabasePanes(['settings']); + } + + /** Adds a member fighting the enemy named to the selected troop, and puts the cursor on it. */ + private function addTroopMember(string $enemy): void + { + $database = $this->getSelectedRecordDatabase(); + if (! $database instanceof ProjectRecordDatabase) { + return; + } + + $recordIndex = $this->getSelectedRecordIndex(); + $enemies = $this->referenceCatalog()->valuesFor('enemies'); + // It stands on the terminal field where the Engine puts a new member beside those there. + $sprites = new EnemySprites($this->workspace->projectRoot)->readByEnemy($this->workspace->getRecordDatabase('enemies')); + $change = $this->applyRecordChange(static fn(): RecordChange => new TroopAuthoring()->addMember($database, $recordIndex, $enemy, $enemies, $sprites)); + if ($change === null) { + return; + } + + $member = (int) $change->index; + $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; + $this->selectDatabaseFieldById(ProjectRecordDatabase::subFieldId('member', $member, 'enemy')); + $this->setStatus(sprintf('Added %s as member %d.', $enemy, $member + 1), StatusLevel::INFO); + $this->renderDatabaseArea(); + } + + /** Names the selected troop after its members. */ + private function nameTroopAfterMembers(): void + { + $database = $this->getSelectedRecordDatabase(); + if (! $database instanceof ProjectRecordDatabase) { + return; + } + + $recordIndex = $this->getSelectedRecordIndex(); + $change = $this->applyRecordChange(static fn(): RecordChange => new TroopAuthoring()->nameAfterMembers($database, $recordIndex)); + if ($change === null) { + return; + } + + $record = $database->getRecordByIndex($recordIndex); + $name = $record === null ? '' : $database->getEntryLabel($record); + $this->setStatus($change->command === null ? sprintf('The troop is already named %s.', $name) : sprintf('Named the troop %s.%s', $name, $change->note === null ? '' : ' ' . $change->note), StatusLevel::INFO); + $this->renderDatabaseArea(); + } + + /** Removes every member of the selected troop; undo brings them back. */ + private function clearTroopMembers(): void + { + $database = $this->getSelectedRecordDatabase(); + if (! $database instanceof ProjectRecordDatabase) { + return; + } + + $recordIndex = $this->getSelectedRecordIndex(); + $change = $this->applyRecordChange(static fn(): RecordChange => new TroopAuthoring()->clearMembers($database, $recordIndex)); + if ($change === null) { + return; + } - $this->setStatus(sprintf('Added %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::SUCCESS); + $this->clampDatabaseSettingSelection(); + $this->setStatus($change->command === null ? 'The troop has no members to clear.' : 'Cleared the troop\'s members. Undo brings them back.', StatusLevel::INFO); $this->renderDatabaseArea(); } + /** New beats start with a resource choice, never an invented speaker. */ + private function selectNewSkitActor(int $entryIndex): void + { + foreach ($this->getDatabaseSettingsFields() as $index => $field) { + if (($field['field'] ?? '') !== sprintf('beat%dActor', $entryIndex)) { continue; } + $this->databaseSelectedSettingIndex = $index; + $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; + $this->openReferencePicker($field); + return; + } + } + /** * Removes the last sub-list entry and records it for undo. * @@ -9387,7 +9207,7 @@ private function addDatabaseRecordSubItem(): void private function removeDatabaseRecordSubItem(): void { $database = $this->getSelectedRecordDatabase(); - $subList = $database?->schema->subList; + $subList = $database?->schema->getInlineSubLists()[0] ?? null; if (! $database instanceof ProjectRecordDatabase || $subList === null) { return; @@ -9399,13 +9219,18 @@ private function removeDatabaseRecordSubItem(): void return; } - if ($database->hasCommandFrames()) { + if ($database->hasCommandFrames() && ($this->databaseCommandFramePath !== [] || $database->schema->subLists === [])) { $this->removeDatabaseFrameItem($database, $subList); return; } $recordIndex = $this->getSelectedRecordIndex(); - $entryIndex = $database->countSubItems($recordIndex) - 1; + // The entry under the cursor, in whichever list it is; elsewhere the + // last entry of the record's own list. + $item = $database->locateItem($recordIndex, [], $this->getSelectedDatabaseFieldId()); + $listKey = $item?->kind === RecordItem::ENTRY ? $item->listKey : null; + $subList = $database->schema->findInlineSubList($listKey) ?? $subList; + $entryIndex = $item?->kind === RecordItem::ENTRY ? $item->entryIndex : $database->countSubItems($recordIndex) - 1; if ($entryIndex < 0) { $this->setStatus(sprintf('No %s to remove.', $subList->singular), StatusLevel::WARN); @@ -9413,20 +9238,14 @@ private function removeDatabaseRecordSubItem(): void return; } - $removed = $database->removeSubItem($recordIndex, $entryIndex); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeEntry($database, $recordIndex, [], $entryIndex, $listKey)); - if ($removed === null) { + if ($change?->command === null) { return; } - $this->recordCommand(new GenericCommand( - sprintf('%s remove', ucfirst($subList->singular)), - static fn() => $database->removeSubItem($recordIndex, $entryIndex), - static fn() => $database->insertSubItem($recordIndex, $entryIndex, $removed), - )); - $this->databaseSelectedSettingIndex = 0; - $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::SUCCESS); + $this->setStatus(sprintf('Removed %s %d.', $subList->singular, $entryIndex + 1), StatusLevel::INFO); $this->renderDatabaseArea(); } @@ -9463,22 +9282,16 @@ private function addDatabaseNestedSubItem(): void $recordIndex = $this->getSelectedRecordIndex(); $framePath = $this->databaseCommandFramePath; $parentIndex = $context['parentIndex']; - $nestedIndex = $database->addFrameNestedItem($recordIndex, $framePath, $parentIndex); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->addNestedItem($database, $recordIndex, $framePath, $parentIndex)); - if ($nestedIndex === null) { + if ($change?->command === null) { return; } - $entry = $context['list']->blank; - - $this->recordCommand(new GenericCommand( - sprintf('%s add', ucfirst($context['list']->singular)), - static fn() => $database->addFrameNestedItem($recordIndex, $framePath, $parentIndex, $entry, $nestedIndex), - static fn() => $database->removeFrameNestedItem($recordIndex, $framePath, $parentIndex, $nestedIndex), - )); + $nestedIndex = (int) $change->index; $this->setStatus( sprintf('Added %s %d.', $context['list']->singular, $nestedIndex + 1), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->renderDatabaseArea(); } @@ -9504,28 +9317,48 @@ private function removeDatabaseNestedSubItem(): void return; } - $removed = $database->removeFrameNestedItem($recordIndex, $framePath, $parentIndex, $nestedIndex); + $change = $this->applyRecordChange(static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeNestedItem($database, $recordIndex, $framePath, $parentIndex, $nestedIndex)); - if ($removed === null) { + if ($change?->command === null) { return; } - $this->recordCommand(new GenericCommand( - sprintf('%s remove', ucfirst($context['list']->singular)), - static fn() => $database->removeFrameNestedItem($recordIndex, $framePath, $parentIndex, $nestedIndex), - static fn() => $database->addFrameNestedItem($recordIndex, $framePath, $parentIndex, $removed, $nestedIndex), - )); $this->databaseSelectedSettingIndex = min( $this->databaseSelectedSettingIndex, max(0, count($this->getDatabaseSettingsFields()) - 1), ); $this->setStatus( sprintf('Removed %s %d.', $context['list']->singular, $nestedIndex + 1), - StatusLevel::SUCCESS, + StatusLevel::INFO, ); $this->renderDatabaseArea(); } + /** + * Makes one record change through the shared record rules and records + * it, reporting a refusal on the status line instead. + * + * @param Closure(RecordAuthoring): RecordChange $change The change. + * @return RecordChange|null What it did, or null when it was refused. + */ + private function applyRecordChange(Closure $change): ?RecordChange + { + try { + $applied = $change(new RecordAuthoring()); + } catch (RecordRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderDatabaseArea(); + + return null; + } + + if ($applied->command !== null) { + $this->recordCommand($applied->command); + } + + return $applied; + } + /** * Phrases a read-only category's reason for the status line. * @@ -9566,59 +9399,35 @@ private function getSelectedActor(): ?ProjectActor } /** - * Returns the selected skill from the project database. + * The selected skill's record data, as the shared record holds it, + * unsaved edits included. * - * @return ProjectSkill|null + * @return array|null */ - private function getSelectedSkill(): ?ProjectSkill + private function getSelectedSkillData(): ?array { if (! $this->isSkillsDatabaseSelected()) { return null; } - return $this->workspace?->skillDatabase->getSkillByIndex($this->databaseSelectedSkillIndex); - } - - /** - * Returns the selected quest from the project database. - * - * @return ProjectQuest|null - */ - private function getSelectedQuest(): ?ProjectQuest - { - if (! $this->isQuestsDatabaseSelected()) { - return null; - } + $record = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); - return $this->workspace?->questDatabase->getQuestByIndex($this->databaseSelectedQuestIndex); + return $record === null ? null : (array) $record->toArray(); } /** - * Returns the selected class from the project database. - * - * @return ProjectClass|null + * The selected skill as the game builds it from its record, or null when + * none is selected or its record does not read as a skill yet. */ - private function getSelectedClass(): ?ProjectClass + private function getSelectedSkill(): ?Skill { - if (! $this->isClassesDatabaseSelected()) { - return null; - } - - return $this->workspace?->classDatabase->getClassByIndex($this->databaseSelectedClassIndex); - } + $data = $this->getSelectedSkillData(); - /** - * Returns the selected animation from the project database. - * - * @return Animation|null - */ - private function getSelectedAnimation() - { - if (! $this->isAnimationsDatabaseSelected()) { + try { + return $data === null ? null : SkillRecord::readSkill($data); + } catch (InvalidArgumentException) { return null; } - - return $this->workspace?->animationDatabase->getAnimationByIndex($this->databaseSelectedAnimationIndex); } /** @@ -9637,26 +9446,6 @@ private function createDatabaseEntry(): void return; } - if ($this->isSkillsDatabaseSelected()) { - $this->createDatabaseSkill(); - return; - } - - if ($this->isClassesDatabaseSelected()) { - $this->createDatabaseClass(); - return; - } - - if ($this->isQuestsDatabaseSelected()) { - $this->createDatabaseQuest(); - return; - } - - if ($this->isAnimationsDatabaseSelected()) { - $this->createDatabaseAnimation(); - return; - } - $this->createDatabaseRecord(); } @@ -9671,7 +9460,11 @@ private function createDatabaseActor(): void return; } - $this->databaseSelectedActorIndex = $this->workspace->actorDatabase->addActor(); + $change = $this->actorAuthoring->createActor($this->workspace); + if ($change->command !== null) { + $this->recordCommand($change->command); + } + $this->databaseSelectedActorIndex = (int) $change->index; $this->databaseSelectedSettingIndex = 0; $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; $this->statusMessage = 'Created a new actor.'; @@ -9680,1562 +9473,1287 @@ private function createDatabaseActor(): void } /** - * Creates a new class entry in the project database. + * Saves the active Database category. * * @return void */ - private function createDatabaseClass(): void + private function saveActiveDatabase(): void { if (! $this->workspace instanceof ProjectWorkspace) { return; } - $this->databaseSelectedClassIndex = $this->workspace->classDatabase->addClass(); - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->statusMessage = "Created a new class."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); + try { + if ($this->isActorsDatabaseSelected()) { + $this->backupBeforeSave(...$this->workspace->actorDatabase->getBackupPaths()); + $this->workspace->actorDatabase->save(); + $this->setStatus('Actor database saved.', StatusLevel::SUCCESS); + } elseif (($recordDatabase = $this->getSelectedRecordDatabase()) instanceof ProjectRecordDatabase) { + if (! $recordDatabase->isEditable()) { + $this->setStatus($this->describeRecordReadOnly($recordDatabase), StatusLevel::WARN); + } else { + $this->backupBeforeSave(...$recordDatabase->getBackupPaths()); + $recordDatabase->save(); + $this->setStatus( + sprintf('%s database saved.', $this->getSelectedDatabaseCategoryDefinition()->label), + StatusLevel::SUCCESS, + ); + } + } else { + $this->setStatus('This database category is not editable yet.', StatusLevel::WARN); + } + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, 'Database save'); + } + + $this->renderDatabaseArea(); } /** - * Creates a new skill entry in the project database. + * Returns the editable settings fields for the selected animation. * - * @return void + * @return array> */ - private function createDatabaseSkill(): void + private function getDatabaseSettingsFields(): array { - if (! $this->workspace instanceof ProjectWorkspace) { - return; + if ($this->isCutscenesOpen) { + return $this->getCutsceneSettingsFields(); } - $this->databaseSelectedSkillIndex = $this->workspace->skillDatabase->addSkill(); - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->statusMessage = "Created a new skill."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); - } + if ($this->isNpcInspectorHosting()) { + // The Inspector hosts the record pane in NPC mode: same fields, + // pickers, condition and write editors, and command frames as + // any Database category, over the map's own NPCs. + return $this->getNpcInspectorFields(); + } - /** - * Creates a new quest entry in the project database. - * - * @return void - */ - private function createDatabaseQuest(): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; + if ($this->isActorsDatabaseSelected()) { + $actor = $this->getSelectedActor(); + + return $actor instanceof ProjectActor && $this->workspace instanceof ProjectWorkspace + ? $this->actorAuthoring->describeFields($this->workspace, $actor) + : []; } - $this->databaseSelectedQuestIndex = $this->workspace->questDatabase->addQuest(); - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->statusMessage = "Created a new quest."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); - } + $recordDatabase = $this->getSelectedRecordDatabase(); - /** - * Appends an objective to the selected quest and records it for undo. - * - * @return void - */ - /** - * Returns the reward slot the settings cursor is on, if it is on one. - * - * @return int|null The slot. - */ - private function selectedQuestRewardSlot(): ?int - { - $field = $this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex] ?? null; + if ($recordDatabase instanceof ProjectRecordDatabase) { + if ($recordDatabase->hasCommandFrames()) { + $fields = $recordDatabase->getFrameSettingsFields( + $this->getSelectedRecordIndex(), + $this->databaseCommandFramePath, + ); + + if ( + $this->databaseCommandFramePath !== [] + && $recordDatabase->getFrameCommands($this->getSelectedRecordIndex(), $this->databaseCommandFramePath) === null + ) { + // The frame no longer resolves (an undo removed its + // command): fall back to the script rather than a void. + // An empty frame still resolves, and stays open. + $this->databaseCommandFramePath = []; + + return $recordDatabase->getFrameSettingsFields($this->getSelectedRecordIndex(), []); + } + + return $fields; + } - if (is_array($field) && preg_match('/^rewardItem(\d+)$/', strval($field['field'] ?? '')) === 1) { - return intval(substr(strval($field['field']), strlen('rewardItem'))); + return $recordDatabase->getSettingsFields($this->getSelectedRecordIndex()); } - return null; + return []; } + /** - * Determines whether the cursor sits on the quest's reward fields, which - * is where adding a first reward item should work from. + * Returns the input control for a database settings field when editable. * - * @return bool True when it does. + * @param array $field The field descriptor. + * @return InputControl|null */ - private function isQuestRewardListSelected(): bool + private function getDatabaseFieldControl(array $field): ?InputControl { - $field = $this->getDatabaseSettingsFields()[$this->databaseSelectedSettingIndex] ?? null; + if (($field['editable'] ?? null) === false) { return null; } + $control = $field['control'] ?? null; - return is_array($field) - && in_array($field['field'] ?? '', ['rewardGold', 'rewardExperience', 'rewardItems'], true); + return $control instanceof InputControl ? $control : null; } /** - * Adds a reward item slot, below the cursor's slot when it is on one. + * Starts editing the selected database settings field. * * @return void */ - private function addDatabaseQuestRewardItem(): void + private function beginDatabaseEdit(): void { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $slot = $questDatabase->addRewardItem($questIndex, $this->selectedQuestRewardSlot()); + $fields = $this->getDatabaseSettingsFields(); + $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - if ($slot === null) { + if (! is_array($field)) { return; } - $this->recordCommand(new GenericCommand( - 'Reward item add', - static fn() => $questDatabase->insertRewardItem($questIndex, $slot, 'S-Potion'), - static fn() => $questDatabase->removeRewardItem($questIndex, $slot), - )); - - // Land on the new row and open its picker: an unchosen placeholder - // is not what anyone wanted to add. - foreach ($this->getDatabaseSettingsFields() as $index => $field) { - if (($field['field'] ?? null) === sprintf('rewardItem%d', $slot)) { - $this->databaseSelectedSettingIndex = $index; - break; - } - } - - $this->setStatus(sprintf('Reward item %d added.', $slot + 1), StatusLevel::SUCCESS); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - $this->beginDatabaseEdit(); - } - - /** - * Removes the reward item slot the cursor is on. - * - * @return void - */ - private function removeDatabaseQuestRewardItem(): void - { - $slot = $this->selectedQuestRewardSlot(); - - if ($slot === null || ! $this->workspace instanceof ProjectWorkspace) { + if (($field['editable'] ?? null) === false && ! isset($field['frame'])) { return; } - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $removed = $questDatabase->removeRewardItem($questIndex, $slot); - - if ($removed === null) { + if ($this->isActorsDatabaseSelected() && is_string($field['action'] ?? null) && ($actor = $this->getSelectedActor()) instanceof ProjectActor) { + // An actor without an id: its row's edit is the one-time freeze. + $this->openActorIdentityMigration($actor); return; } - $this->databaseSelectedSettingIndex = min( - $this->databaseSelectedSettingIndex, - max(0, count($this->getDatabaseSettingsFields()) - 1) - ); - $this->recordCommand(new GenericCommand( - 'Reward item remove', - static fn() => $questDatabase->removeRewardItem($questIndex, $slot), - static fn() => $questDatabase->insertRewardItem($questIndex, $slot, $removed), - )); - $this->setStatus(sprintf('Reward item %d removed (%s).', $slot + 1, $removed), StatusLevel::SUCCESS); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - - private function addDatabaseQuestObjective(): void - { - if ($this->selectedQuestRewardSlot() !== null || $this->isQuestRewardListSelected()) { - $this->addDatabaseQuestRewardItem(); - + // A field that names another record is chosen from, never typed into. + if (is_string($field['reference'] ?? null) && $this->openReferencePicker($field)) { return; } - if (! $this->workspace instanceof ProjectWorkspace || ! $this->getSelectedQuest() instanceof ProjectQuest) { + if (($field['conditions'] ?? false) === true) { + $this->openConditionEditor($field); + return; } - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $objectiveIndex = $questDatabase->addObjective($questIndex); + if (($field['affinities'] ?? false) === true) { + $this->openAffinityEditor($field); - if ($objectiveIndex === null) { return; } - $objective = $questDatabase->getQuestByIndex($questIndex)?->getObjectives()[$objectiveIndex] ?? []; - $this->recordCommand(new GenericCommand( - 'Quest objective add', - static fn() => $questDatabase->insertObjective($questIndex, $objectiveIndex, $objective), - static fn() => $questDatabase->removeObjective($questIndex, $objectiveIndex), - )); - $this->setStatus(sprintf('Objective %d added.', $objectiveIndex + 1), StatusLevel::SUCCESS); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - - /** - * Removes the selected quest objective and records it for undo. - * - * The objective under the highlighted settings field is removed when one - * is highlighted; the last objective otherwise. - * - * @return void - */ - private function removeDatabaseQuestObjective(): void - { - if ($this->selectedQuestRewardSlot() !== null) { - $this->removeDatabaseQuestRewardItem(); + if (($field['worldWrites'] ?? false) === true) { + $this->openWorldWriteEditor($field); return; } - $quest = $this->getSelectedQuest(); + if (($field['actorPredicates'] ?? false) === true) { + $this->openBattleEntryPredicateEditor($field); - if (! $this->workspace instanceof ProjectWorkspace || ! $quest instanceof ProjectQuest) { return; } - $objectiveCount = count($quest->getObjectives()); + if (is_array($field['frame'] ?? null)) { + $this->enterCommandFrame($field['frame']); - if ($objectiveCount === 0) { return; } - $objectiveIndex = $objectiveCount - 1; - $fields = $this->getDatabaseSettingsFields(); - $selectedField = (string) ($fields[$this->databaseSelectedSettingIndex]['field'] ?? ''); + $control = $this->getDatabaseFieldControl($field); - if (preg_match('/^objective(\d+)/', $selectedField, $matches) === 1) { - $objectiveIndex = min($objectiveCount - 1, intval($matches[1])); + if (! $control instanceof InputControl) { + return; } - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $removed = $questDatabase->removeObjective($questIndex, $objectiveIndex); + if ($control->type === InputControlType::MULTILINE) { + // Text with line breaks is edited in its own editor, exactly. + $this->openMultilineEditor($field); - if ($removed === null) { return; } - $this->databaseSelectedSettingIndex = min( - $this->databaseSelectedSettingIndex, - max(0, count($this->getDatabaseSettingsFields()) - 1) - ); - $this->recordCommand(new GenericCommand( - 'Quest objective remove', - static fn() => $questDatabase->removeObjective($questIndex, $objectiveIndex), - static fn() => $questDatabase->insertObjective($questIndex, $objectiveIndex, $removed), - )); - $this->setStatus(sprintf('Objective %d removed.', $objectiveIndex + 1), StatusLevel::SUCCESS); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + $this->isDatabaseEditing = true; + $this->databaseEditBuffer = $control->rawValue; + $this->databaseEditCursorIndex = mb_strlen($this->databaseEditBuffer); + $this->statusMessage = sprintf('Editing %s.', $field['label'] ?? 'field'); + $this->renderDatabasePanes(['settings']); } - /** - * Creates a new animation entry in the project database. - * - * @return void - */ - private function createDatabaseAnimation(): void + private function openActorIdentityMigration(ProjectActor $actor): void { - if (! $this->workspace instanceof ProjectWorkspace || ! $this->isAnimationsDatabaseSelected()) { + try { + $plan = $this->workspace instanceof ProjectWorkspace ? $this->actorAuthoring->planIdentityRepair($this->workspace, $actor) : null; + if ($plan !== null) { + $this->openActorReferenceMigration($plan); + return; + } + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Actor identity migration'); return; } - - $this->databaseSelectedAnimationIndex = $this->workspace->animationDatabase->addAnimation(); - $this->databaseSelectedFrameIndex = 1; - $this->databaseSelectedSettingIndex = 0; - $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; - $this->centerDatabasePreviewCursor(); - $this->statusMessage = "Created a new animation."; - $this->renderDatabasePanes(["list", "settings", "cue", "frames", "preview"]); - $this->beginDatabaseEdit(); + $this->optionDialogField = ['actorIdentityMigration' => $actor]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Freeze actor identity'; + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave the actor unchanged.'], + ['label' => sprintf('Freeze "%s" as the permanent id', $actor->getName()), 'value' => 'freeze', + 'description' => 'Later display-name changes will keep this identity. Save the project to write it.'], + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Confirm the one-time actor identity migration.'; + $this->renderSelectionDependentArea(); } - /** - * Saves the active Database category. - * - * @return void - */ - private function saveActiveDatabase(): void + private function confirmActorIdentityMigration(ProjectActor $actor, bool $confirmed): void { - if (! $this->workspace instanceof ProjectWorkspace) { + if (! $confirmed) { + $this->closeEventOptionDialog('Actor identity migration cancelled.'); return; } - try { - if ($this->isActorsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->actorDatabase)); - $this->workspace->actorDatabase->save(); - $this->setStatus('Actor database saved.', StatusLevel::SUCCESS); - } elseif ($this->isClassesDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->classDatabase)); - $this->workspace->classDatabase->save(); - $this->setStatus('Class database saved.', StatusLevel::SUCCESS); - } elseif ($this->isSkillsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->skillDatabase)); - $this->workspace->skillDatabase->save(); - $this->setStatus('Skill database saved.', StatusLevel::SUCCESS); - } elseif ($this->isQuestsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->questDatabase)); - $this->workspace->questDatabase->save(); - $this->setStatus('Quest database saved.', StatusLevel::SUCCESS); - } elseif ($this->isAnimationsDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->animationDatabase)); - $this->workspace->animationDatabase->save(); - $this->setStatus('Animation database saved.', StatusLevel::SUCCESS); - } elseif ($this->isSystemDatabaseSelected()) { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($this->workspace->systemDatabase)); - $this->workspace->systemDatabase->save(); - $this->setStatus('System database saved.', StatusLevel::SUCCESS); - } elseif (($recordDatabase = $this->getSelectedRecordDatabase()) instanceof ProjectRecordDatabase) { - if (! $recordDatabase->isEditable()) { - $this->setStatus($this->describeRecordReadOnly($recordDatabase), StatusLevel::WARN); - } else { - $this->backupBeforeSave(...$this->getDatabaseBackupPaths($recordDatabase)); - $recordDatabase->save(); - $this->setStatus( - sprintf('%s database saved.', $this->getSelectedDatabaseCategoryDefinition()->label), - StatusLevel::SUCCESS, - ); - } - } else { - $this->setStatus('This database category is not editable yet.', StatusLevel::WARN); + $index = array_search($actor, $this->workspace->actorDatabase->getActors(), true); + if (! is_int($index)) { + throw new RecordRefusal('The actor is no longer in the project database.'); } - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, 'Database save'); + $change = $this->actorAuthoring->freezeIdentity($this->workspace, $index); + if ($change->command !== null) { + $this->recordCommand($change->command); + } + $this->closeEventOptionDialog('Actor identity frozen. Save the project to write it.'); + } catch (Throwable $failure) { + $this->closeEventOptionDialog(''); + $this->setErrorStatus($failure, 'Actor identity migration'); } - - $this->renderDatabaseArea(); + $this->renderDatabasePanes(['list', 'settings']); } - /** - * Returns the editable settings fields for the selected animation. - * - * @return array> - */ - private function getDatabaseSettingsFields(): array + private function openActorReferenceMigration(?ActorIdentityMigrationPlan $plan = null): void { - if ($this->isCutscenesOpen) { - return $this->getCutsceneSettingsFields(); - } - - if ($this->isNpcInspectorHosting()) { - // The Inspector hosts the record pane in NPC mode: same fields, - // pickers, condition and write editors, and command frames as - // any Database category, over the map's own NPCs. - return $this->getNpcInspectorFields(); - } - - if ($this->isActorsDatabaseSelected()) { - return $this->getDatabaseActorSettingsFields(); - } - - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassSettingsFields(); - } - - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillSettingsFields(); - } - - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestSettingsFields(); - } - - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemSettingsFields(); + if (! $this->workspace instanceof ProjectWorkspace) { return; } + if ($this->workspace->hasUnsavedChanges()) { + $this->setStatus('Save or undo pending edits before repairing actor references. No files were changed.', StatusLevel::WARN); + return; } - - $recordDatabase = $this->getSelectedRecordDatabase(); - - if ($recordDatabase instanceof ProjectRecordDatabase) { - if ($recordDatabase->hasCommandFrames()) { - $fields = $recordDatabase->getFrameSettingsFields( - $this->getSelectedRecordIndex(), - $this->databaseCommandFramePath, - ); - - if ( - $this->databaseCommandFramePath !== [] - && $recordDatabase->getFrameCommands($this->getSelectedRecordIndex(), $this->databaseCommandFramePath) === null - ) { - // The frame no longer resolves (an undo removed its - // command): fall back to the script rather than a void. - // An empty frame still resolves, and stays open. - $this->databaseCommandFramePath = []; - - return $recordDatabase->getFrameSettingsFields($this->getSelectedRecordIndex(), []); - } - - return $fields; + try { + $plan ??= ActorIdentityMigration::planProject($this->projectRoot); + $paths = $plan->getChangedPaths(); + if ($paths === []) { + $this->setStatus('Actor identities and references need no repair.'); + return; } - - return $recordDatabase->getSettingsFields($this->getSelectedRecordIndex()); + $relativePaths = array_map(fn(string $path): string => substr($path, strlen($this->projectRoot) + 1), $paths); + $this->optionDialogField = ['actorReferenceMigration' => $plan]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'Repair actor identities and references'; + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => 'Leave all files unchanged.'], + ['label' => sprintf('Write %d repaired files now', count($paths)), 'value' => 'migrate', + 'description' => 'Writes now, not on Save. Ctrl+Z restores files.'], + ...array_map(static fn(string $path): array => [ + 'label' => $path, 'value' => 'preview', + 'description' => 'Repair preview only. Select Write to apply all files.', + ], $relativePaths), + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Confirm source-preserving actor reference repair.'; + $this->renderSelectionDependentArea(); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Actor reference migration'); } + } - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return []; + private function confirmActorReferenceMigration(ActorIdentityMigrationPlan $plan, bool $confirmed): void + { + if (! $confirmed || ! $this->workspace instanceof ProjectWorkspace) { + $this->closeEventOptionDialog('Actor reference migration cancelled.'); + return; + } + try { + $command = new SourceSetCommand('Migrate actor identities and references', 'this actor migration', $plan->getSourceSet(), $this->workspace, + fn(): ?ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + $command->execute(); + $this->recordCommand($command); + $this->closeEventOptionDialog('Actor identities and references repaired. Ctrl+Z restores the original files.'); + $this->requestFullRender(); + } catch (Throwable $failure) { + $this->closeEventOptionDialog(''); + $this->setErrorStatus($failure, 'Actor reference migration'); } - - $cue = $animation->getCue($this->databaseSelectedFrameIndex); - - return [ - [ - 'label' => 'Name', - 'value' => $animation->name, - 'control' => new InputControl(InputControlType::TEXT, $animation->name), - 'field' => 'name', - ], - [ - 'label' => 'Position', - 'value' => ucfirst($animation->position->value), - 'options' => array_map( - static fn(AnimationTargetPosition $position): string => $position->value, - AnimationTargetPosition::cases() - ), - 'field' => 'position', - ], - [ - 'label' => 'Max Frames', - 'value' => (string) $animation->maxFrames, - 'control' => new InputControl(InputControlType::INTEGER, (string) $animation->maxFrames), - 'field' => 'maxFrames', - ], - [ - 'label' => 'Brush Symbol', - 'value' => $this->databaseSelectedPaintSymbol === ' ' ? 'Space' : $this->databaseSelectedPaintSymbol, - 'control' => new InputControl(InputControlType::TEXT, $this->databaseSelectedPaintSymbol), - 'field' => 'brushSymbol', - ], - [ - 'label' => 'Brush Color', - 'value' => ucfirst((string) ($this->databaseSelectedPaintColor ?? 'none')), - 'options' => ['none', 'white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta'], - 'field' => 'brushColor', - ], - [ - 'label' => 'Frame Sound', - 'value' => $cue?->soundEffect ?? '', - 'reference' => 'sfx', - 'allowsNone' => true, - 'noneLabel' => '(No sound)', - 'field' => 'frameSound', - ], - [ - 'label' => 'Flash Color', - 'value' => ucfirst((string) ($cue?->flashColor ?? 'none')), - 'options' => ['none', 'white', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta'], - 'field' => 'flashColor', - ], - [ - 'label' => 'Flash Frames', - 'value' => (string) ($cue?->flashDurationFrames ?? 0), - 'control' => new InputControl(InputControlType::INTEGER, (string) ($cue?->flashDurationFrames ?? 0)), - 'field' => 'flashDurationFrames', - ], - ]; } - - /** - * Returns the editable settings fields for the selected actor. - * - * @return array> - */ /** - * The rows that say which actor this is to a save. + * Handles inline database-field editing. * - * @param ProjectActor $actor The actor. - * @return array> The rows. + * @param string $input The raw input. + * @return void */ - private function actorIdentityFields(ProjectActor $actor): array + private function handleDatabaseEditingInput(string $input): void { - return [ - ['label' => 'Identity', 'value' => '', 'editable' => false, 'field' => ''], - [ - 'label' => 'Definition Id', - 'value' => $actor->hasDefinitionId() ? $actor->getDefinitionId() : '', - 'control' => new InputControl(InputControlType::TEXT, $actor->hasDefinitionId() ? $actor->getDefinitionId() : ''), - 'field' => 'id', - // A project that declares no id is resolved by name, which is - // what strands a save when the actor is renamed. - 'displayDefault' => sprintf('%s (the name; declare an id so a rename keeps saves)', $actor->getName()), - ], - ]; + $fields = $this->getDatabaseSettingsFields(); + $field = $fields[$this->databaseSelectedSettingIndex] ?? null; + $control = is_array($field) ? $this->getDatabaseFieldControl($field) : null; + + switch ($this->databaseFieldEditor->handleKey($input, $control)) { + case TextFieldKeyResult::CANCELLED: + $this->databaseFieldEditor->close(); + $this->statusMessage = 'Database edit cancelled.'; + $this->renderDatabasePanes(['settings']); + return; + case TextFieldKeyResult::SUBMITTED: + $this->commitDatabaseEdit(); + return; + case TextFieldKeyResult::CHANGED: + $this->renderDatabasePanes(['settings']); + return; + case TextFieldKeyResult::IGNORED: + return; + } } /** - * The rows for an actor's own nature: the adjustments it makes to its - * class baseline, and the named variants of that nature. + * Returns the fullest form of a pane's key hint that its border fits. * - * While an actor declares variants the runtime reads the selected - * variant's adjustments and ignores the fixed ones, so the rows edit - * whichever set is actually in force. + * A hint cut off mid-word ("Shift+X/De") is worse than a shorter one that + * reads: it looks like a bug and it teaches nothing. Callers pass the + * forms they would like in order, longest first, and the last is the one + * that has to fit anywhere. * - * @param ProjectActor $actor The actor. - * @return array> The rows. + * @param int $windowWidth The window's full width. + * @param string ...$candidates The forms, longest first. + * @return string The one that fits. */ - private function actorNatureFields(ProjectActor $actor): array + private function fitHelp(int $windowWidth, string ...$candidates): string { - $variants = $actor->getNaturalVariants(); - $selected = $this->selectedActorVariantId($actor); - $rows = [['label' => 'Nature', 'value' => '', 'editable' => false, 'field' => '']]; - - if ($variants !== []) { - $rows[] = [ - 'label' => 'Default Variant', - 'value' => (string) $actor->getDefaultNaturalVariantId(), - 'options' => array_keys($variants), - 'field' => 'defaultNaturalVariantId', - ]; - $rows[] = [ - 'label' => 'Editing Variant', - 'value' => $selected ?? '', - 'options' => array_keys($variants), - 'field' => self::ACTOR_VARIANT_FIELD, - ]; - } + // EditorWindow spends a corner and a border character before the + // label, and keeps one for the closing corner. + $available = max(0, $windowWidth - 3); - // Both layers are real: the runtime adds the selected variant on top - // of the fixed adjustments rather than replacing them, so both are - // shown and both are editable. - $inForce = $actor->getNaturalAdjustmentsFor($selected); - $fixed = $actor->getActorNaturalAdjustments(); - $rows = [...$rows, ...$this->actorAdjustmentRows( - $variants === [] ? 'Adjustments' : 'Fixed, always applied', - 'actorNaturalAdjustments', - $fixed, - )]; - - if ($variants !== [] && $selected !== null) { - $rows = [...$rows, ...$this->actorAdjustmentRows( - sprintf('Variant %s, added on top', $selected), - sprintf('naturalVariants.%s', $selected), - $variants[$selected] ?? [], - )]; - $rows[] = [ - 'label' => ' In force', - 'value' => $this->describeAdjustments($inForce), - 'editable' => false, - 'field' => '', - ]; + foreach ($candidates as $candidate) { + if (mb_strwidth($candidate) <= $available) { + return $candidate; + } } - return $rows; + return ''; } /** - * The rows for one layer of an actor's nature. + * Determines whether the selected record has a list to add to. * - * @param string $heading What the layer is. - * @param string $prefix The payload path the rows write to. - * @param array $adjustments The layer's adjustments. - * @return array> The rows. + * @return bool True when it has. */ - private function actorAdjustmentRows(string $heading, string $prefix, array $adjustments): array + private function hasDatabaseSubList(): bool { - $rows = [['label' => ' ' . $heading, 'value' => '', 'editable' => false, 'field' => '']]; - - foreach (ActorStatPreview::statKeys() as $key) { - $amount = $adjustments[$key] ?? 0; - $rows[] = [ - 'label' => ' ' . ucfirst(strtolower((string) preg_replace('/(? (string) $amount, - 'control' => new InputControl(InputControlType::INTEGER, (string) $amount), - 'field' => $prefix . '.' . $key, - ]; - } - - return $rows; + return ($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== []; } /** - * Describes what an actor's nature comes to once composed. + * Removes whichever kind of sub-item the settings cursor is on. * - * @param array $adjustments The composed adjustments. - * @return string The description. + * @return void */ - private function describeAdjustments(array $adjustments): string + private function removeDatabaseSubItem(): void { - $parts = []; - - foreach ($adjustments as $key => $amount) { - if ($amount !== 0) { - $parts[] = sprintf('%+d %s', $amount, $key); - } + if (($this->getSelectedRecordDatabase()?->schema->getInlineSubLists() ?? []) !== []) { + $this->selectedDatabaseNestedContext() !== null + ? $this->removeDatabaseNestedSubItem() + : $this->removeDatabaseRecordSubItem(); } - - return $parts === [] ? 'nothing adjusted' : implode(', ', $parts); } /** - * The read-only rows showing what each stat actually comes to. + * Opens the world-write editor on a field that holds a `sets` list. * - * @param ProjectActor $actor The actor. - * @return array> The rows. + * @param array $field The settings-pane field descriptor. + * @return void */ - private function actorStatPreviewFields(ProjectActor $actor): array + private function openWorldWriteEditor(array $field): void { - if (! ActorStatPreview::isAvailable()) { - return []; - } - - $catalog = PermanentGrowthCatalog::fromProject($this->workspace->projectRoot); - $assumed = $this->assumedGrowthFor($actor); - $rows = [ - ['label' => 'Resolved Stats', 'value' => '', 'editable' => false, 'field' => ''], - [ - // Earned growth is save state. What a preview can do is - // assume some of it, say that it is assuming, and write - // nothing. - 'label' => ' Assumed Growth', - 'value' => $assumed, - 'options' => [ - self::NO_ASSUMED_GROWTH, - self::ALL_ASSUMED_GROWTH, - ...$catalog->ids(), - ], - 'field' => self::ACTOR_GROWTH_FIELD, - 'displayDefault' => 'assumed for this preview only; the party earns growth in play', - ], - ]; - - $permanent = match ($assumed) { - self::NO_ASSUMED_GROWTH => [], - self::ALL_ASSUMED_GROWTH => $catalog->totalsFor(), - default => $catalog->totalsFor([$assumed]), - }; - - foreach (ActorStatPreview::resolve($actor, $this->selectedActorVariantId($actor), $permanent) as $row) { - $rows[] = [ - 'label' => ' ' . $row['stat'], - 'value' => ActorStatPreview::describeRow($row), - 'editable' => false, - 'field' => '', - ]; - } - - return [...$rows, ...$this->actorOptimizeFields($actor)]; + $writeTypes = $field['writeTypes'] ?? null; + $this->worldWriteEditor->open( + (string) ($field['field'] ?? ''), + (string) ($field['label'] ?? 'Writes'), + WorldWriteCodec::decodeAll((string) ($field['value'] ?? '')), + is_array($writeTypes) ? $writeTypes : null, + ); + $this->statusMessage = 'Building writes.'; + $this->renderDatabasePanes(['settings']); } /** - * Reports a field value the editor will not guess at, leaving the record - * untouched. - * - * A project-owned parameter line has an explicit grammar, and repairing - * a malformed one silently is how an author loses a value without being - * told. The status line says what is wrong with it instead. + * Handles input while world writes are being built. * - * @param string $field The field being edited. - * @param string $rawValue The value typed. - * @return bool True when the value was rejected. + * @param string $input The raw input. + * @return void */ - private function isDatabaseFieldRejected(string $field, string $rawValue): bool + private function handleWorldWriteEditorInput(string $input): void { - if (! $this->workspace instanceof ProjectWorkspace) { - return false; + if ($this->isWorldWriteNaming) { + $this->handleWorldWriteNameInput($input); + + return; } - $database = $this->getSelectedRecordDatabase(); - $schema = $database?->schema; + if ($input === "\033" || $input === "\x1b") { + $this->worldWriteEditor->close(); + $this->statusMessage = 'Writes unchanged.'; + $this->renderDatabasePanes(['settings']); - if ($schema === null) { - return false; + return; } - foreach ($schema->fields as $declared) { - if ($declared->key !== $field || $declared->codec !== RecordFieldCodec::KEY_VALUES) { - continue; - } - - try { - ParameterMapCodec::decode($rawValue); - } catch (ParameterMapSyntaxError $error) { - $this->setStatus($error->getMessage(), StatusLevel::ERROR); + if ($input === "\n" || $input === "\r") { + $this->commitWorldWrites(); - return true; - } + return; } - return false; + match (true) { + str_contains($input, "\033[A") => $this->worldWriteEditor->move(-1), + str_contains($input, "\033[B") => $this->worldWriteEditor->move(1), + $input === 'a' => $this->worldWriteEditor->add(), + $input === 'd' => $this->worldWriteEditor->remove(), + $input === 't' => $this->worldWriteEditor->cycleType(1), + $input === 'T' => $this->worldWriteEditor->cycleType(-1), + $input === 'x', $input === 'X' => $this->worldWriteEditor->cycleExtra(1), + $input === 'n' => $this->beginWorldWriteName(), + $input === 'v' => $this->beginWorldWriteValue(), + default => null, + }; + + $this->renderDatabasePanes(['settings']); } /** - * Returns which permanent growth this actor's preview assumes. - * - * @param ProjectActor $actor The actor. - * @return string The selection. + * Starts setting what the selected write names: a quest is picked, the + * rest are typed. + * + * @return void */ - private function assumedGrowthFor(ProjectActor $actor): string + private function beginWorldWriteName(): void { - return $this->actorGrowthSelections[$actor->getDefinitionId()] ?? self::NO_ASSUMED_GROWTH; + $reference = $this->worldWriteEditor->nameReference(); + $set = $this->worldWriteEditor->selected(); + + if ($reference === null || $set === null || ! $this->workspace instanceof ProjectWorkspace) { + return; + } + + if (! is_string($reference['category'])) { + $this->isWorldWriteNaming = true; + $this->worldWriteNameBuffer = strval($set['name'] ?? ''); + $this->worldWriteNamingValue = false; + $this->statusMessage = sprintf('Naming the %s.', mb_strtolower($reference['label'])); + + return; + } + + $catalog = $this->referenceCatalog(); + $values = $catalog->valuesFor($reference['category']); + + if (! $this->referencePicker->open(self::WORLD_WRITE_NAME_FIELD, $reference['label'], $reference['category'], $values, strval($set['name'] ?? ''), $catalog->labelsFor($reference['category']))) { + $this->setStatus(sprintf('This project defines no %s to choose from.', $reference['category']), StatusLevel::WARN); + } } /** - * Returns which slot this actor's Optimize preview fills. + * Starts typing a variable write's value. * - * @param ProjectActor $actor The actor. - * @return string The semantic slot. + * @return void */ - private function optimizeSlotFor(ProjectActor $actor): string + private function beginWorldWriteValue(): void { - $slots = EquipmentOptimizationPolicy::slotKeys(); - $selected = $this->actorOptimizeSlots[$actor->getDefinitionId()] ?? ''; + $set = $this->worldWriteEditor->selected(); + + if ($set === null || strval($set['type'] ?? '') !== 'variable') { + return; + } - return in_array($selected, $slots, true) ? $selected : ($slots[0] ?? 'weapon'); + $this->isWorldWriteNaming = true; + $this->worldWriteNamingValue = true; + $this->worldWriteNameBuffer = strval($set['value'] ?? '0'); + $this->statusMessage = 'Typing the value.'; } /** - * The read-only rows showing what Optimize would choose, and why. + * Handles input while a write's name or value is being typed. * - * @param ProjectActor $actor The actor. - * @return array> The rows. + * @param string $input The raw input. + * @return void */ - private function actorOptimizeFields(ProjectActor $actor): array + private function handleWorldWriteNameInput(string $input): void { - if (! $this->workspace instanceof ProjectWorkspace || ! EquipmentOptimizationPolicy::isAvailable()) { - return []; + if ($input === "\033" || $input === "\x1b") { + $this->isWorldWriteNaming = false; + $this->worldWriteNameBuffer = ''; + $this->statusMessage = 'Unchanged.'; + $this->renderDatabasePanes(['settings']); + + return; } - $root = $this->workspace->projectRoot; - $slot = $this->optimizeSlotFor($actor); - $rows = [ - ['label' => 'Optimize Preview', 'value' => '', 'editable' => false, 'field' => ''], - [ - // Which policy is scoring is the first thing to know: a - // project that has declared none is not being scored by its - // own rules at all. - 'label' => ' Policy', - 'value' => EquipmentOptimizationPolicy::describeSource($root), - 'editable' => false, - 'field' => '', - ], - [ - 'label' => ' Slot', - 'value' => $slot, - 'options' => EquipmentOptimizationPolicy::slotKeys(), - 'field' => self::ACTOR_OPTIMIZE_SLOT_FIELD, - ], - ]; - $ranked = EquipmentOptimizationPolicy::rank($this->workspace, $actor, $slot); + if ($input === "\n" || $input === "\r") { + if ($this->worldWriteNamingValue) { + $this->worldWriteEditor->setValue(trim($this->worldWriteNameBuffer)); + } else { + $this->worldWriteEditor->setName(trim($this->worldWriteNameBuffer)); + } - if ($ranked === []) { - $rows[] = [ - 'label' => ' (nothing)', - 'value' => 'no equipment this project has fits that slot', - 'editable' => false, - 'field' => '', - ]; + $this->isWorldWriteNaming = false; + $this->worldWriteNameBuffer = ''; + $this->statusMessage = 'Building writes.'; + $this->renderDatabasePanes(['settings']); - return $rows; + return; } - foreach ($ranked as $position => $candidate) { - $rows[] = [ - 'label' => sprintf(' %d. %s', $position + 1, $candidate['name']), - 'value' => sprintf( - '%d · %s', - $candidate['value'], - EquipmentOptimizationPolicy::describeRow($candidate), - ), - 'editable' => false, - 'field' => '', - ]; + if ($input === "\x7f" || $input === "\x08") { + $this->worldWriteNameBuffer = mb_substr($this->worldWriteNameBuffer, 0, max(0, mb_strlen($this->worldWriteNameBuffer) - 1)); + $this->renderDatabasePanes(['settings']); + + return; } - return $rows; + if (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { + $this->worldWriteNameBuffer .= $input; + $this->renderDatabasePanes(['settings']); + } } /** - * Returns which natural variant the actor rows are editing. + * Stores the writes as the line the record layer decodes. * - * @param ProjectActor $actor The actor. - * @return string|null The variant id, or null when the actor has none. + * @return void */ - private function selectedActorVariantId(ProjectActor $actor): ?string + private function commitWorldWrites(): void { - $variants = $actor->getNaturalVariants(); + $fieldId = $this->worldWriteEditor->fieldId(); + $label = $this->worldWriteEditor->label(); + $encoded = $this->worldWriteEditor->encoded(); + $this->worldWriteEditor->close(); - if ($variants === []) { - return null; + if ($this->mapWriteField !== null) { + $field = $this->mapWriteField; + $this->mapWriteField = null; + + // The row applies the line as any interface sends it: its target owns the list it changes. + if ($this->applyInspectorFieldValue($field, $encoded)) { + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + } + + $this->renderSelectionDependentArea(); + + return; } - $selected = $this->actorVariantSelections[$actor->getDefinitionId()] ?? null; + $field = null; - return $selected !== null && isset($variants[$selected]) - ? $selected - : ($actor->getDefaultNaturalVariantId() ?? array_key_first($variants)); - } + foreach ($this->getDatabaseSettingsFields() as $candidate) { + if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { + $field = $candidate; + } + } - private function getDatabaseActorSettingsFields(): array - { - $actor = $this->getSelectedActor(); + if (! is_array($field)) { + return; + } - if (! $actor instanceof ProjectActor) { - return []; + try { + $this->applyDatabaseFieldValueRecorded($field, $encoded); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, sprintf('%s edit', $label)); } - return [ - ...$this->actorIdentityFields($actor), - [ - 'label' => 'Name', - 'value' => $actor->getName(), - 'control' => new InputControl(InputControlType::TEXT, $actor->getName()), - 'field' => 'name', - ], - [ - 'label' => 'Description', - 'value' => $actor->getDescription(), - 'control' => new InputControl(InputControlType::TEXT, $actor->getDescription()), - 'field' => 'description', - ], - [ - // The character-class reference: a name from - // assets/Data/classes.php, written to the actor's - // data['class'] key (the engine's ClassStore hydrates it - // into a CharacterRole). ←/→ cycles the picker; Ctrl+G jumps - // to the class entry. - 'label' => 'Class', - 'value' => $actor->getClassName() === '' ? ProjectActor::CLASS_NONE : $actor->getClassName(), - 'options' => $this->getActorClassOptions(), - 'field' => 'class', - ], - ...$this->actorSummonFields($actor), - [ - 'label' => 'Level', - 'value' => (string) $actor->getLevel(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getLevel()), - 'field' => 'level', - ], - [ - 'label' => 'Current Exp', - 'value' => (string) $actor->getCurrentExp(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getCurrentExp()), - 'field' => 'currentExp', - ], - [ - 'label' => 'Current HP', - 'value' => (string) $actor->getStat('currentHp'), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentHp')), - 'field' => 'currentHp', - ], - [ - 'label' => 'Current MP', - 'value' => (string) $actor->getStat('currentMp'), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentMp')), - 'field' => 'currentMp', - ], - [ - 'label' => 'Current AP', - 'value' => (string) $actor->getStat('currentAp'), - 'control' => new InputControl(InputControlType::INTEGER, (string) $actor->getStat('currentAp')), - 'field' => 'currentAp', - ], - ...$this->actorNatureFields($actor), - ...$this->actorStatPreviewFields($actor), - ]; + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } /** - * Returns the actor's starting summon assignments: a multi-pick over the - * project's summons, and one verdict row per assignment judged by the - * same rules the validator applies (existence, wielder eligibility, - * story locks, duplicates, exclusive tenancy across the cast). + * Returns the rows shown while writes are being built. * - * @return array> + * @return string[] The rows. */ - private function actorSummonFields(ProjectActor $actor): array + private function buildWorldWriteEditorRows(): array { - $assignments = $actor->getSummons(); - $list = is_array($assignments) ? array_values(array_filter(array_map(static fn(mixed $id): string => is_string($id) ? trim($id) : '', $assignments), static fn(string $id): bool => $id !== '')) : []; - $rows = [ - [ - 'label' => 'Summons', - 'value' => implode(', ', $list), - 'reference' => 'summons', - 'multi' => true, - 'noneLabel' => '(None)', - 'field' => 'summons', - ], - ]; - $diagnostics = SummonAssignmentDiagnostics::fromLibrary($this->workspace?->cutscenes); - $holders = []; - - foreach ($this->workspace?->actorDatabase->getActors() ?? [] as $other) { - if ($other === $actor) { - continue; - } + if ($this->isWorldWriteNaming) { + $reference = $this->worldWriteEditor->nameReference(); - foreach ((array) $other->getSummons() as $id) { - if (is_string($id) && trim($id) !== '') { - $holders[strtolower(trim($id))][] = $other->getName(); - } - } + return [ + sprintf('%s: %s', $this->worldWriteNamingValue ? 'Value' : ($reference['label'] ?? 'Name'), $this->worldWriteNameBuffer), + '', + ' Enter to accept, Esc to leave it alone.', + ]; } - foreach ($diagnostics->forActor($actor->getName(), $actor->getClassName(), $assignments) as $row) { - $verdict = SummonAssignmentDiagnostics::describe($row); - $id = strtolower($row['id']); + $rows = $this->worldWriteEditor->rows(); + $lines = [sprintf('%s · %d', $this->worldWriteEditor->label(), count($rows)), '']; - if ($row['problems'] === [] && $diagnostics->isExclusive($id) && isset($holders[$id])) { - $verdict = sprintf('✗ %s: exclusive, also held by %s.', $row['id'], implode(', ', $holders[$id])); - } + if ($rows === []) { + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. Nothing changes when this completes.', $this->recordPaneMetrics()['width'])]; + $lines[] = ''; + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add a write.', $this->recordPaneMetrics()['width'])]; + + return $lines; + } + + $selectedIndex = $this->worldWriteEditor->selectedIndex(); - $rows[] = ['label' => ' ' . $verdict, 'value' => '', 'editable' => false, 'field' => '']; + foreach ($rows as $index => $row) { + $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $row); } - return $rows; + // Two header lines, then the rows in whatever height the pane has. + $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); + + return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; } /** - * Returns the actor class picker options. - * - * The list is the project's own class names from - * `assets/Data/classes.php`, prefixed with the "none" sentinel that - * clears the reference. + * Opens the predicate editor on a field that holds battle-entry actor + * predicates. * - * @return string[] + * @param array $field The settings-pane field descriptor. + * @return void */ - private function getActorClassOptions(): array + private function openBattleEntryPredicateEditor(array $field): void { - return [ - ProjectActor::CLASS_NONE, - ...array_map( - static fn(ProjectClass $class): string => $class->getName(), - $this->workspace?->classDatabase->getClasses() ?? [], - ), - ]; + $this->battleEntryPredicateEditor->open( + (string) ($field['field'] ?? ''), + (string) ($field['label'] ?? 'Actor Predicates'), + BattleEntryPredicateCodec::decodeAll((string) ($field['value'] ?? '')), + ); + $this->statusMessage = 'Building actor predicates.'; + $this->renderDatabasePanes(['settings']); } /** - * Returns the editable settings fields for the selected class. + * Handles input while actor predicates are being built. * - * @return array> + * @param string $input The raw input. + * @return void */ - private function getDatabaseClassSettingsFields(): array + private function handleBattleEntryPredicateEditorInput(string $input): void { - $class = $this->getSelectedClass(); + if ($input === "\033" || $input === "\x1b") { + $this->battleEntryPredicateEditor->close(); + $this->statusMessage = 'Actor predicates unchanged.'; + $this->renderDatabasePanes(['settings']); - if (! $class instanceof ProjectClass) { - return []; + return; } - $experienceCurve = $class->getExperienceCurve(); + if ($input === "\n" || $input === "\r") { + $this->commitBattleEntryPredicates(); - return [ - [ - 'label' => 'Name', - 'value' => $class->getName(), - 'control' => new InputControl(InputControlType::TEXT, $class->getName()), - 'field' => 'name', - ], - [ - 'label' => 'Description', - 'value' => $class->getDescription(), - 'control' => new InputControl(InputControlType::TEXT, $class->getDescription()), - 'field' => 'description', - ], - [ - 'label' => 'Initial Level', - 'value' => (string) $class->getInitialLevel(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $class->getInitialLevel()), - 'field' => 'initialLevel', - ], - [ - 'label' => 'Max Level', - 'value' => (string) $class->getMaxLevel(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $class->getMaxLevel()), - 'field' => 'maxLevel', - ], - [ - 'label' => 'EXP Base', - 'value' => (string) $experienceCurve['baseValue'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['baseValue']), - 'field' => 'expBaseValue', - ], - [ - 'label' => 'EXP Extra', - 'value' => (string) $experienceCurve['extraValue'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['extraValue']), - 'field' => 'expExtraValue', - ], - [ - 'label' => 'EXP Accel A', - 'value' => (string) $experienceCurve['accelerationA'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['accelerationA']), - 'field' => 'expAccelerationA', - ], - [ - 'label' => 'EXP Accel B', - 'value' => (string) $experienceCurve['accelerationB'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $experienceCurve['accelerationB']), - 'field' => 'expAccelerationB', - ], - ...$this->getDatabaseClassBaseValueFields($class), - ]; + return; + } + + match (true) { + str_contains($input, "\033[A") => $this->battleEntryPredicateEditor->move(-1), + str_contains($input, "\033[B") => $this->battleEntryPredicateEditor->move(1), + $input === 'a' => $this->battleEntryPredicateEditor->add(), + $input === 'd' => $this->battleEntryPredicateEditor->remove(), + $input === 'x' => $this->battleEntryPredicateEditor->cyclePresence(1), + $input === 'X' => $this->battleEntryPredicateEditor->cyclePresence(-1), + $input === 'n' => $this->beginBattleEntryPredicateActor(), + default => null, + }; + + $this->renderDatabasePanes(['settings']); } /** - * Returns the editable settings fields for the selected skill. + * Starts picking the selected predicate's actor: a durable identity is + * chosen, never typed. * - * @return array> + * @return void */ - private function getDatabaseSkillSettingsFields(): array + private function beginBattleEntryPredicateActor(): void { - $skill = $this->getSelectedSkill(); + $predicate = $this->battleEntryPredicateEditor->selected(); - if (! $skill instanceof ProjectSkill) { - return []; + if ($predicate === null || ! $this->workspace instanceof ProjectWorkspace) { + return; } - $scope = $skill->getScope(); - $invocation = $skill->getInvocation(); + $catalog = $this->referenceCatalog(); + $values = $catalog->valuesFor('actor_ids'); - return [ - ['label' => 'Name', 'value' => $skill->getName(), 'control' => new InputControl(InputControlType::TEXT, $skill->getName()), 'field' => 'name'], - ['label' => 'Description', 'value' => $skill->getDescription(), 'control' => new InputControl(InputControlType::TEXT, $skill->getDescription()), 'field' => 'description'], - ['label' => 'Type', 'value' => $skill->getType(), 'options' => ['basic', 'special', 'magic'], 'field' => 'type'], - ['label' => 'Icon', 'value' => $skill->getIcon(), 'control' => new InputControl(InputControlType::TEXT, $skill->getIcon()), 'field' => 'icon'], - ['label' => 'Cost', 'value' => (string) $skill->getCost(), 'control' => new InputControl(InputControlType::INTEGER, (string) $skill->getCost()), 'field' => 'cost'], - ['label' => 'Cooldown', 'value' => (string) $skill->getCooldown(), 'control' => new InputControl(InputControlType::INTEGER, (string) $skill->getCooldown()), 'field' => 'cooldown'], - ['label' => 'Occasion', 'value' => $skill->getOccasion(), 'options' => array_map(static fn(Occasion $occasion): string => $occasion->value, Occasion::cases()), 'field' => 'occasion'], - ['label' => 'Scope Side', 'value' => (string) ($scope['side'] ?? ItemScopeSide::ENEMY->value), 'options' => array_map(static fn(ItemScopeSide $side): string => $side->value, ItemScopeSide::cases()), 'field' => 'scopeSide'], - ['label' => 'Scope Number', 'value' => (string) ($scope['number'] ?? ItemScopeNumber::ONE->value), 'options' => array_map(static fn(ItemScopeNumber $number): string => $number->value, ItemScopeNumber::cases()), 'field' => 'scopeNumber'], - ['label' => 'Scope Status', 'value' => (string) ($scope['status'] ?? ItemScopeStatus::ALIVE->value), 'options' => array_map(static fn(ItemScopeStatus $status): string => $status->value, ItemScopeStatus::cases()), 'field' => 'scopeStatus'], - ['label' => 'Target Count', 'value' => (string) ($scope['targetCount'] ?? ''), 'control' => new InputControl(InputControlType::INTEGER, (string) ($scope['targetCount'] ?? '')), 'field' => 'scopeTargetCount'], - ['label' => 'Invoke Text', 'value' => (string) ($invocation['message'] ?? ''), 'control' => new InputControl(InputControlType::TEXT, (string) ($invocation['message'] ?? '')), 'field' => 'invocationMessage'], - ['label' => 'Invoke Speed', 'value' => (string) ($invocation['speed'] ?? 0), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['speed'] ?? 0)), 'field' => 'invocationSpeed'], - ['label' => 'Accuracy', 'value' => (string) ($invocation['accuracy'] ?? 0), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['accuracy'] ?? 0)), 'field' => 'invocationAccuracy'], - ['label' => 'Repeat', 'value' => (string) ($invocation['repeat'] ?? 1), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['repeat'] ?? 1)), 'field' => 'invocationRepeat'], - ['label' => 'AP Gain', 'value' => (string) ($invocation['apGain'] ?? 10), 'control' => new InputControl(InputControlType::INTEGER, (string) ($invocation['apGain'] ?? 10)), 'field' => 'invocationApGain'], - ['label' => 'Effect Type', 'value' => $skill->getEffectType() ?? MagicEffectType::DESTRUCTIVE->value, 'options' => array_map(static fn(MagicEffectType $effectType): string => $effectType->value, MagicEffectType::cases()), 'field' => 'effectType'], - ]; + if (! $this->referencePicker->open(self::BATTLE_PREDICATE_ACTOR_FIELD, 'Actor', 'actor_ids', $values, strval($predicate['actor'] ?? ''), $catalog->labelsFor('actor_ids'))) { + $this->setStatus('This project defines no actors to choose from.', StatusLevel::WARN); + } } + /** - * Returns the editable settings fields for the selected quest. - * - * Objectives are flattened into per-objective field groups (type, - * target, quantity, spoiler-safe text, and conditional revealed text) so - * the flat settings pane can edit the nested list; Shift+O / Shift+X add - * and remove objectives. + * Stores the predicate list being built back onto its field. * - * @return array> + * @return void */ - private function getDatabaseQuestSettingsFields(): array + private function commitBattleEntryPredicates(): void { - $quest = $this->getSelectedQuest(); - - if (! $quest instanceof ProjectQuest) { - return []; - } + $fieldId = $this->battleEntryPredicateEditor->fieldId(); + $label = $this->battleEntryPredicateEditor->label(); + $encoded = $this->battleEntryPredicateEditor->encoded(); + $this->battleEntryPredicateEditor->close(); - $fields = [ - // Derived from the name, so an id is never invented or mistyped. - ['label' => 'Id', 'value' => $quest->getId(), 'field' => 'id'], - ['label' => 'Name', 'value' => $quest->getName(), 'control' => new InputControl(InputControlType::TEXT, $quest->getName()), 'field' => 'name'], - ['label' => 'Description', 'value' => $quest->getDescription(), 'control' => new InputControl(InputControlType::TEXT, $quest->getDescription()), 'field' => 'description'], - ['label' => 'Giver', 'value' => $quest->getGiver(), 'control' => new InputControl(InputControlType::TEXT, $quest->getGiver()), 'field' => 'giver'], - ['label' => 'Reward Gold', 'value' => (string) $quest->getRewardGold(), 'control' => new InputControl(InputControlType::INTEGER, (string) $quest->getRewardGold()), 'field' => 'rewardGold'], - ['label' => 'Reward EXP', 'value' => (string) $quest->getRewardExperience(), 'control' => new InputControl(InputControlType::INTEGER, (string) $quest->getRewardExperience()), 'field' => 'rewardExperience'], - ['label' => 'Prereqs', 'value' => $quest->getPrerequisitesString(), 'conditions' => true, 'field' => 'prerequisites'], - ]; + $field = null; - // One row per reward item, picked from the inventory the engine's - // store resolves them against. Shift+O on a row adds a slot below - // it; Shift+X or Del removes the one the cursor is on. - foreach ($quest->getRewardItems() as $slot => $item) { - $fields[] = [ - 'label' => sprintf('Reward Item %d', $slot + 1), - 'value' => $item, - 'reference' => 'inventory', - 'field' => sprintf('rewardItem%d', $slot), - ]; + foreach ($this->getDatabaseSettingsFields() as $candidate) { + if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { + $field = $candidate; + } } - foreach ($quest->getObjectives() as $index => $objective) { - $label = sprintf('Obj %d', $index + 1); - $type = strval($objective['type'] ?? QuestObjectiveType::TALK_TO->value); - $target = strval($objective['target'] ?? ''); - $quantity = (string) max(1, intval($objective['quantity'] ?? 1)); - $description = strval($objective['description'] ?? ''); - $revealedDescription = strval($objective['revealedDescription'] ?? ''); - $revealConditions = ConditionCodec::encodeAll((array) ($objective['revealConditions'] ?? [])); - $fields[] = [ - 'label' => $label . ' Type', - 'value' => $type, - 'options' => array_map(static fn(QuestObjectiveType $objectiveType): string => $objectiveType->value, QuestObjectiveType::cases()), - 'field' => sprintf('objective%dType', $index), - ]; - $reference = self::questObjectiveReference($type); - $targetField = [ - 'label' => $label . ' Target', - 'value' => $target, - 'field' => sprintf('objective%dTarget', $index), - ]; - - // What a target may be depends on what the objective asks for, so - // the picker follows the type: an item to collect, an enemy to - // defeat, a map to reach. - if ($reference !== null) { - $targetField['reference'] = $reference; - } else { - $targetField['control'] = new InputControl(InputControlType::TEXT, $target); - } + if (! is_array($field)) { + return; + } - $fields[] = $targetField; - $fields[] = [ - 'label' => $label . ' Qty', - 'value' => $quantity, - 'control' => new InputControl(InputControlType::INTEGER, $quantity), - 'field' => sprintf('objective%dQuantity', $index), - ]; - $fields[] = [ - 'label' => $label . ' Text', - 'value' => $description, - 'control' => new InputControl(InputControlType::TEXT, $description), - 'field' => sprintf('objective%dDescription', $index), - ]; - $fields[] = [ - 'label' => $label . ' Revealed', - 'value' => $revealedDescription, - 'control' => new InputControl(InputControlType::TEXT, $revealedDescription), - 'field' => sprintf('objective%dRevealedDescription', $index), - ]; - $fields[] = [ - 'label' => $label . ' Reveal When', - 'value' => $revealConditions, - 'conditions' => true, - 'field' => sprintf('objective%dRevealConditions', $index), - ]; + try { + $this->applyDatabaseFieldValueRecorded($field, $encoded); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, sprintf('%s edit', $label)); } - return $fields; + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } /** - * Returns the editable class base-value fields. + * Returns the rows shown while actor predicates are being built. * - * @param ProjectClass $class The selected class. - * @return array> + * @return string[] The rows. */ - private function getDatabaseClassBaseValueFields(ProjectClass $class): array + private function buildBattleEntryPredicateEditorRows(): array { - $fieldMap = [ - 'HP Base' => ['field' => 'totalHpBaseValue', 'curve' => 'totalHp'], - 'MP Base' => ['field' => 'totalMpBaseValue', 'curve' => 'totalMp'], - 'ATK Base' => ['field' => 'attackBaseValue', 'curve' => 'attack'], - 'DEF Base' => ['field' => 'defenceBaseValue', 'curve' => 'defence'], - 'MAT Base' => ['field' => 'magicAttackBaseValue', 'curve' => 'magicAttack'], - 'MDF Base' => ['field' => 'magicDefenceBaseValue', 'curve' => 'magicDefence'], - 'SPD Base' => ['field' => 'speedBaseValue', 'curve' => 'speed'], - ]; - $fields = []; + $labels = $this->workspace instanceof ProjectWorkspace + ? $this->referenceCatalog()->labelsFor('actor_ids') + : []; + $rows = $this->battleEntryPredicateEditor->rows($labels); + $lines = [sprintf('%s · %d', $this->battleEntryPredicateEditor->label(), count($rows)), '']; - foreach ($fieldMap as $label => $definition) { - $curve = $class->getParameterCurve($definition['curve']); - $value = (string) $curve['baseValue']; - $fields[] = [ - 'label' => $label, - 'value' => $value, - 'control' => new InputControl(InputControlType::INTEGER, $value), - 'field' => $definition['field'], - ]; + if ($rows === []) { + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. The rule matches no one and never runs.', $this->recordPaneMetrics()['width'])]; + $lines[] = ''; + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add a predicate.', $this->recordPaneMetrics()['width'])]; + + return $lines; } - return $fields; - } + $selectedIndex = $this->battleEntryPredicateEditor->selectedIndex(); - /** - * Returns the input control for a database settings field when editable. - * - * @param array $field The field descriptor. - * @return InputControl|null - */ - private function getDatabaseFieldControl(array $field): ?InputControl - { - $control = $field['control'] ?? null; + foreach ($rows as $index => $row) { + $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $row); + } - return $control instanceof InputControl ? $control : null; + // Two header lines, then the rows in whatever height the pane has. + $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); + + return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; } /** - * Starts editing the selected database settings field. + * Opens the affinity editor on a field that holds an element map. * + * @param array $field The settings-pane field descriptor. * @return void */ - private function beginDatabaseEdit(): void + /** + * Opens the timeline conversion on the selected animation record, when + * it still holds legacy cell frames or cues. + */ + private function openAnimationConversion(): void { - $fields = $this->getDatabaseSettingsFields(); - $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - - if (! is_array($field)) { + if (! $this->workspace instanceof ProjectWorkspace) { return; } + $index = $this->getSelectedRecordIndex(); + $id = $this->getSelectedRecordDatabase()?->getRecordByIndex($index)?->get('id'); + try { + $facts = LegacyAnimationConversion::describe($this->workspace, is_int($id) ? $id : -1); + } catch (InvalidArgumentException $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderDatabasePanes(['settings']); - // A field that names another record is chosen from, never typed into. - if (is_string($field['reference'] ?? null) && $this->openReferencePicker($field)) { return; } + $this->animationConversion->open($index, $facts); + $this->databaseFocus = self::DATABASE_FOCUS_SETTINGS; + $this->statusMessage = sprintf('Converting %s to a timeline.', $this->animationConversion->getName()); + $this->renderDatabasePanes(['settings']); + } - if (($field['conditions'] ?? false) === true) { - $this->openConditionEditor($field); + /** + * Handles input while a conversion is being set up or reviewed. Up/Down + * walk the mode's lines, the pane following the cursor; choices step + * with Left/Right and text takes typing; Enter on Preview plans it and + * opens the review, and in review writes exactly that plan as one undo + * step. Escape goes back from review to the choices, and from the + * choices leaves everything unchanged. + */ + private function handleAnimationConversionInput(string $input): void + { + $conversion = $this->animationConversion; + $review = $conversion->getMode() === AnimationConversionEditor::MODE_REVIEW; + if ($input === "\033" || $input === "\x1b") { + if ($review) { + $conversion->returnToChoices(); + } else { + $conversion->close(); + $this->statusMessage = 'Conversion cancelled; nothing was written.'; + } + $this->renderDatabasePanes(['settings']); return; } + $lineCount = count($conversion->getLines($this->recordPaneMetrics()['width'], $this->projectRoot)); + match (true) { + str_contains($input, "\033[A") => $conversion->move(-1, $lineCount), + str_contains($input, "\033[B") => $conversion->move(1, $lineCount), + str_contains($input, "\033[D") => $conversion->cycle(-1), + str_contains($input, "\033[C") => $conversion->cycle(1), + $input === "\x7f" || $input === "\x08" => $conversion->backspace(), + $input === "\n" || $input === "\r" => $this->submitAnimationConversion(), + mb_strlen($input) === 1 && ctype_print($input) => $conversion->type($input), + default => null, + }; + if ($conversion->isOpen()) { + $this->renderDatabasePanes(['settings']); + } + } - if (($field['affinities'] ?? false) === true) { - $this->openAffinityEditor($field); - + /** Enter in the conversion: preview it, open its review, write the reviewed plan, or move on a line. */ + private function submitAnimationConversion(): void + { + $conversion = $this->animationConversion; + if (! $this->workspace instanceof ProjectWorkspace) { return; } - - if (($field['worldWrites'] ?? false) === true) { - $this->openWorldWriteEditor($field); + if ($conversion->getMode() === AnimationConversionEditor::MODE_CHOICES) { + $control = $conversion->getSelectedControl(); + if ($control === 'preview') { + // The status line carries the outcome, so a refusal is read wherever the pane has scrolled. + $conversion->preview($this->workspace) + ? $this->setStatus('Review the files; Enter writes exactly these.') + : $this->setStatus((string) $conversion->getError(), StatusLevel::WARN); + } elseif ($control === 'review') { + $conversion->review(); + } elseif ($control !== null) { + $conversion->move(1, count($conversion->getLines($this->recordPaneMetrics()['width'], $this->projectRoot))); + } return; } - - if (($field['actorPredicates'] ?? false) === true) { - $this->openBattleEntryPredicateEditor($field); - + $plan = $conversion->getPlan(); + if ($plan === null) { return; } + if ($this->workspace->hasUnsavedChanges()) { + $conversion->setError('Save or undo pending edits before converting. No files were changed.'); - if (is_array($field['frame'] ?? null)) { - $this->enterCommandFrame($field['frame']); + return; + } + try { + $command = new SourceSetCommand(sprintf('Convert %s to a timeline', $conversion->getName()), 'this animation conversion', $plan, $this->workspace, + fn(): ?ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + $command->execute(); + } catch (Throwable $failure) { + // Files changed since the preview, or the write failed: nothing is written; preview again. + $conversion->setError($failure->getMessage()); + $conversion->returnToChoices(); + $this->setStatus($failure->getMessage(), StatusLevel::WARN); return; } + $this->recordCommand($command); + $name = $conversion->getName(); + $conversion->close(); + $this->setStatus(sprintf('%s converted. Ctrl+Z restores the files.', $name)); + $this->requestFullRender(); + } - $control = $this->getDatabaseFieldControl($field); + /** + * The conversion in the settings pane: its title, then the mode's lines + * in whatever height the pane has, following the cursor through the + * shared scroll window so the selected line is always shown. + * + * @return list + */ + private function buildAnimationConversionRows(): array + { + $metrics = $this->recordPaneMetrics(); + $conversion = $this->animationConversion; + $title = $conversion->getMode() === AnimationConversionEditor::MODE_REVIEW + ? sprintf('Review: convert %s', $conversion->getName()) + : sprintf('Convert %s to a timeline', $conversion->getName()); + $lines = $conversion->getLines($metrics['width'], $this->projectRoot); + // Two header lines, then the lines in whatever height the pane has. + $visibleRows = max(1, $metrics['rows'] - 2); - if (! $control instanceof InputControl) { + // The whole selected control stays on screen, not only its first line. + return [$title, '', ...ScrollWindow::slice($lines, $conversion->getCursorEnd($visibleRows), $visibleRows)]; + } + + private function openAffinityEditor(array $field): void + { + if (! $this->workspace instanceof ProjectWorkspace) { return; } - if ($control->type === InputControlType::MULTILINE) { - // Text with line breaks is edited in its own editor, exactly. - $this->openMultilineEditor($field); + $elements = $this->referenceCatalog()->valuesFor('elements'); + + if ($elements === []) { + // No element enum, no rows to build: saying so beats an editor + // that opens with nothing to pick. + $this->setStatus( + 'This project defines no element types under assets/Data/Types.', + StatusLevel::WARN + ); + $this->renderDatabasePanes(['settings']); return; } - $this->isDatabaseEditing = true; - $this->databaseEditBuffer = $control->rawValue; - $this->databaseEditCursorIndex = mb_strlen($this->databaseEditBuffer); - $this->statusMessage = sprintf('Editing %s.', $field['label'] ?? 'field'); + $this->affinityEditor->open( + (string) ($field['field'] ?? ''), + (string) ($field['label'] ?? 'Elemental'), + ElementAffinityCodec::decodeAll((string) ($field['value'] ?? '')), + $elements, + ); + + $this->statusMessage = 'Building elemental wards.'; $this->renderDatabasePanes(['settings']); } /** - * Handles inline database-field editing. + * Handles input while affinities are being built. * * @param string $input The raw input. * @return void */ - private function handleDatabaseEditingInput(string $input): void + private function handleAffinityEditorInput(string $input): void { - $fields = $this->getDatabaseSettingsFields(); - $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - $control = is_array($field) ? $this->getDatabaseFieldControl($field) : null; + if ($input === "\033" || $input === "\x1b") { + $this->affinityEditor->close(); + $this->statusMessage = 'Elemental wards unchanged.'; + $this->renderDatabasePanes(['settings']); - switch ($this->databaseFieldEditor->handleKey($input, $control)) { - case TextFieldKeyResult::CANCELLED: - $this->databaseFieldEditor->close(); - $this->statusMessage = 'Database edit cancelled.'; - $this->renderDatabasePanes(['settings']); - return; - case TextFieldKeyResult::SUBMITTED: - $this->commitDatabaseEdit(); - return; - case TextFieldKeyResult::CHANGED: - $this->renderDatabasePanes(['settings']); - return; - case TextFieldKeyResult::IGNORED: - return; + return; } - } - /** - * Returns the fullest form of a pane's key hint that its border fits. - * - * A hint cut off mid-word ("Shift+X/De") is worse than a shorter one that - * reads: it looks like a bug and it teaches nothing. Callers pass the - * forms they would like in order, longest first, and the last is the one - * that has to fit anywhere. - * - * @param int $windowWidth The window's full width. - * @param string ...$candidates The forms, longest first. - * @return string The one that fits. - */ - private function fitHelp(int $windowWidth, string ...$candidates): string - { - // EditorWindow spends a corner and a border character before the - // label, and keeps one for the closing corner. - $available = max(0, $windowWidth - 3); + if ($input === "\n" || $input === "\r") { + $this->commitAffinities(); - foreach ($candidates as $candidate) { - if (mb_strwidth($candidate) <= $available) { - return $candidate; - } + return; } - return ''; - } + match (true) { + str_contains($input, "\033[A") => $this->affinityEditor->move(-1), + str_contains($input, "\033[B") => $this->affinityEditor->move(1), + $input === 'a' => $this->affinityEditor->add(), + $input === 'd' => $this->affinityEditor->remove(), + $input === 'x' => $this->affinityEditor->cycleEffect(1), + $input === 'X' => $this->affinityEditor->cycleEffect(-1), + $input === 'n' => $this->beginAffinityElement(), + default => null, + }; - /** - * Determines whether the selected record has a list to add to. - * - * @return bool True when it has. - */ - private function hasDatabaseSubList(): bool - { - return $this->isQuestsDatabaseSelected() - || $this->getSelectedRecordDatabase()?->schema->subList !== null; + $this->renderDatabasePanes(['settings']); } /** - * Removes whichever kind of sub-item the settings cursor is on. + * Opens the element picker for the selected affinity row. * * @return void */ - private function removeDatabaseSubItem(): void + private function beginAffinityElement(): void { - if ($this->isQuestsDatabaseSelected()) { - $this->removeDatabaseQuestObjective(); + $row = $this->affinityEditor->selected(); + if ($row === null) { return; } - if ($this->getSelectedRecordDatabase()?->schema->subList !== null) { - $this->selectedDatabaseNestedContext() !== null - ? $this->removeDatabaseNestedSubItem() - : $this->removeDatabaseRecordSubItem(); - } + $this->referencePicker->open( + self::AFFINITY_ELEMENT_FIELD, + 'Element', + 'elements', + $this->affinityEditor->elements(), + $row['element'], + ); } /** - * Renames the selected quest, and its id with it where that is safe. + * Stores the affinities as the line the record layer decodes. * - * @param string $name The new name. * @return void */ - private function renameSelectedQuest(string $name): void + private function commitAffinities(): void { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } + $fieldId = $this->affinityEditor->fieldId(); + $label = $this->affinityEditor->label(); + $encoded = $this->affinityEditor->encoded(); + $this->affinityEditor->close(); + + $field = null; - $questDatabase = $this->workspace->questDatabase; - $questIndex = $this->databaseSelectedQuestIndex; - $quest = $questDatabase->getQuestByIndex($questIndex); + foreach ($this->getDatabaseSettingsFields() as $candidate) { + if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { + $field = $candidate; + } + } - if (! $quest instanceof ProjectQuest) { + if (! is_array($field)) { return; } - $previousName = $quest->getName(); - $previousId = $quest->getId(); - $isReferenced = new QuestReferences($this->workspace)->exist($previousId); - $newId = $questDatabase->renameQuest($questIndex, $name, ! $isReferenced); + try { + $this->applyDatabaseFieldValueRecorded($field, $encoded); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, sprintf('%s edit', $label)); + } + + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + } - $this->recordCommand(new GenericCommand( - 'Quest rename', - static fn() => $questDatabase->renameQuest($questIndex, $name, ! $isReferenced), - static function () use ($questDatabase, $questIndex, $previousName, $previousId): void { - $questDatabase->renameQuest($questIndex, $previousName, false); - $questDatabase->setField($questIndex, 'id', $previousId); - }, - )); + /** + * Returns the rows shown while affinities are being built. + * + * @return string[] The rows. + */ + private function buildAffinityEditorRows(): array + { + $rows = $this->affinityEditor->describeRows(); + $lines = [sprintf('%s · %d', $this->affinityEditor->label(), count($rows)), '']; - if (is_string($newId)) { - $this->setStatus(sprintf('Renamed. Its id is now %s.', $newId), StatusLevel::SUCCESS); + if ($rows === []) { + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. This piece is neutral to every element.', $this->recordPaneMetrics()['width'])]; + $lines[] = ''; + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add a ward.', $this->recordPaneMetrics()['width'])]; - return; + return $lines; } - if ($isReferenced) { - // Saying so beats an id that silently stops matching its name. - $this->setStatus( - sprintf('Renamed. Its id stays %s, which other things point at.', $previousId), - StatusLevel::INFO - ); + $selectedIndex = $this->affinityEditor->selectedIndex(); + + foreach ($rows as $index => $row) { + $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $row); } + + // Two header lines, then the rows in whatever height the pane has. + $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); + + return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; } /** - * Opens the world-write editor on a field that holds a `sets` list. + * Opens the condition editor on a field that holds a condition list. * * @param array $field The settings-pane field descriptor. * @return void */ - private function openWorldWriteEditor(array $field): void + private function openConditionEditor(array $field): void { - $writeTypes = $field['writeTypes'] ?? null; - $this->worldWriteEditor->open( + $this->conditionEditor->open( (string) ($field['field'] ?? ''), - (string) ($field['label'] ?? 'Writes'), - WorldWriteCodec::decodeAll((string) ($field['value'] ?? '')), - is_array($writeTypes) ? $writeTypes : null, + (string) ($field['label'] ?? 'Conditions'), + ConditionCodec::decodeAll((string) ($field['value'] ?? '')), ); - $this->statusMessage = 'Building writes.'; + + $this->statusMessage = 'Building conditions.'; $this->renderDatabasePanes(['settings']); } /** - * Handles input while world writes are being built. + * Handles input while conditions are being built. * * @param string $input The raw input. * @return void */ - private function handleWorldWriteEditorInput(string $input): void + private function handleConditionEditorInput(string $input): void { - if ($this->isWorldWriteNaming) { - $this->handleWorldWriteNameInput($input); + if ($this->isConditionNaming) { + $this->handleConditionNameInput($input); return; } if ($input === "\033" || $input === "\x1b") { - $this->worldWriteEditor->close(); - $this->statusMessage = 'Writes unchanged.'; + $this->conditionEditor->close(); + $this->statusMessage = 'Conditions unchanged.'; $this->renderDatabasePanes(['settings']); return; } if ($input === "\n" || $input === "\r") { - $this->commitWorldWrites(); + $this->commitConditions(); return; } - match (true) { - str_contains($input, "\033[A") => $this->worldWriteEditor->move(-1), - str_contains($input, "\033[B") => $this->worldWriteEditor->move(1), - $input === 'a' => $this->worldWriteEditor->add(), - $input === 'd' => $this->worldWriteEditor->remove(), - $input === 't' => $this->worldWriteEditor->cycleType(1), - $input === 'T' => $this->worldWriteEditor->cycleType(-1), - $input === 'x', $input === 'X' => $this->worldWriteEditor->cycleExtra(1), - $input === 'n' => $this->beginWorldWriteName(), - $input === 'v' => $this->beginWorldWriteValue(), + $handled = match (true) { + str_contains($input, "\033[A") => $this->conditionEditor->move(-1), + str_contains($input, "\033[B") => $this->conditionEditor->move(1), + $input === 'a' => $this->conditionEditor->add(), + $input === 'd' => $this->conditionEditor->remove(), + $input === 't' => $this->conditionEditor->cycleType(1), + $input === 'T' => $this->conditionEditor->cycleType(-1), + $input === 'x' => $this->conditionEditor->cycleExtra(1), + $input === 'X' => $this->conditionEditor->cycleExtra(-1), + $input === '!' => $this->conditionEditor->toggleNegate(), + $input === 'n' => $this->beginConditionName(), + $input === 'e' => $this->beginConditionMarker(), default => null, }; + unset($handled); $this->renderDatabasePanes(['settings']); } /** - * Starts setting what the selected write names: a quest is picked, the - * rest are typed. + * Starts choosing which event of its named map a map event condition + * waits on, from that map's own events. * * @return void */ - private function beginWorldWriteName(): void + private function beginConditionMarker(): void { - $reference = $this->worldWriteEditor->nameReference(); - $set = $this->worldWriteEditor->selected(); + $condition = $this->conditionEditor->selected(); - if ($reference === null || $set === null || ! $this->workspace instanceof ProjectWorkspace) { + if ($condition === null || strval($condition['type'] ?? '') !== 'map_event') { return; } - if (! is_string($reference['category'])) { - $this->isWorldWriteNaming = true; - $this->worldWriteNameBuffer = strval($set['name'] ?? ''); - $this->worldWriteNamingValue = false; - $this->statusMessage = sprintf('Naming the %s.', mb_strtolower($reference['label'])); + $map = trim(strval($condition['name'] ?? '')); + if ($map === '') { + $this->setStatus('Choose the map first (n), then its event.', StatusLevel::WARN); return; } - $catalog = $this->referenceCatalog(); - $values = $catalog->valuesFor($reference['category']); + $markers = $this->referenceCatalog()->listMapEventMarkers($map); - if (! $this->referencePicker->open(self::WORLD_WRITE_NAME_FIELD, $reference['label'], $reference['category'], $values, strval($set['name'] ?? ''), $catalog->labelsFor($reference['category']))) { - $this->setStatus(sprintf('This project defines no %s to choose from.', $reference['category']), StatusLevel::WARN); + if (! $this->referencePicker->open( + self::CONDITION_MARKER_FIELD, + 'Event', + 'map_event_markers', + $markers['values'], + strval($condition['marker'] ?? ''), + $markers['labels'], + )) { + $this->setStatus(sprintf('%s has no events to choose from.', $map), StatusLevel::WARN); } } /** - * Starts typing a variable write's value. + * Starts setting what the selected condition names. + * + * A quest or an item is chosen from the project; a switch, story event or + * variable is a name the author invents, so that one is typed. * * @return void */ - private function beginWorldWriteValue(): void + private function beginConditionName(): void { - $set = $this->worldWriteEditor->selected(); + $reference = $this->conditionEditor->nameReference(); + $condition = $this->conditionEditor->selected(); - if ($set === null || strval($set['type'] ?? '') !== 'variable') { + if ($reference === null || $condition === null || ! $this->workspace instanceof ProjectWorkspace) { return; } - $this->isWorldWriteNaming = true; - $this->worldWriteNamingValue = true; - $this->worldWriteNameBuffer = strval($set['value'] ?? '0'); - $this->statusMessage = 'Typing the value.'; + if (! is_string($reference['category'])) { + $this->isConditionNaming = true; + $this->conditionNameBuffer = strval($condition['name'] ?? ''); + $this->statusMessage = sprintf('Naming the %s.', mb_strtolower($reference['label'])); + + return; + } + + $catalog = $this->referenceCatalog(); + $values = $catalog->valuesFor($reference['category']); + + if (! $this->referencePicker->open( + self::CONDITION_NAME_FIELD, + $reference['label'], + $reference['category'], + $values, + strval($condition['name'] ?? ''), + $catalog->labelsFor($reference['category']), + )) { + $this->setStatus( + sprintf('This project defines no %s to choose from.', str_replace('_', ' ', $reference['category'])), + StatusLevel::WARN + ); + } } /** - * Handles input while a write's name or value is being typed. + * Handles input while a condition's name is being typed. * * @param string $input The raw input. * @return void */ - private function handleWorldWriteNameInput(string $input): void + private function handleConditionNameInput(string $input): void { if ($input === "\033" || $input === "\x1b") { - $this->isWorldWriteNaming = false; - $this->worldWriteNameBuffer = ''; - $this->statusMessage = 'Unchanged.'; + $this->isConditionNaming = false; + $this->conditionNameBuffer = ''; + $this->statusMessage = 'Name unchanged.'; $this->renderDatabasePanes(['settings']); return; } if ($input === "\n" || $input === "\r") { - if ($this->worldWriteNamingValue) { - $this->worldWriteEditor->setValue(trim($this->worldWriteNameBuffer)); - } else { - $this->worldWriteEditor->setName(trim($this->worldWriteNameBuffer)); - } - - $this->isWorldWriteNaming = false; - $this->worldWriteNameBuffer = ''; - $this->statusMessage = 'Building writes.'; + $this->conditionEditor->setName(trim($this->conditionNameBuffer)); + $this->isConditionNaming = false; + $this->conditionNameBuffer = ''; + $this->statusMessage = 'Building conditions.'; $this->renderDatabasePanes(['settings']); return; } if ($input === "\x7f" || $input === "\x08") { - $this->worldWriteNameBuffer = mb_substr($this->worldWriteNameBuffer, 0, max(0, mb_strlen($this->worldWriteNameBuffer) - 1)); + $this->conditionNameBuffer = mb_substr( + $this->conditionNameBuffer, + 0, + max(0, mb_strlen($this->conditionNameBuffer) - 1) + ); $this->renderDatabasePanes(['settings']); return; } if (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { - $this->worldWriteNameBuffer .= $input; + $this->conditionNameBuffer .= $input; $this->renderDatabasePanes(['settings']); } } /** - * Stores the writes as the line the record layer decodes. + * Stores the conditions as the line the data file holds. * * @return void */ - private function commitWorldWrites(): void + private function commitConditions(): void { - $fieldId = $this->worldWriteEditor->fieldId(); - $label = $this->worldWriteEditor->label(); - $encoded = $this->worldWriteEditor->encoded(); - $this->worldWriteEditor->close(); + if ($this->mapConditionField !== null) { + $field = $this->mapConditionField; + $this->mapConditionField = null; + $label = $this->conditionEditor->label(); + $encoded = $this->conditionEditor->encoded(); + $this->conditionEditor->close(); + $selectedMap = $this->getSelectedMap(); + + if ($selectedMap instanceof ProjectMap) { + // The row applies the line as any interface sends it: its target owns the list it changes. + if ($this->applyInspectorFieldValue($field, $encoded)) { + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); + } + } + + $this->renderSelectionDependentArea(); + + return; + } + + $fieldId = $this->conditionEditor->fieldId(); + $label = $this->conditionEditor->label(); + $encoded = $this->conditionEditor->encoded(); + $this->conditionEditor->close(); $field = null; @@ -11251,7 +10769,7 @@ private function commitWorldWrites(): void try { $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); + $this->setStatus(sprintf('%s updated.', $label), StatusLevel::INFO); } catch (Throwable $throwable) { $this->setErrorStatus($throwable, sprintf('%s edit', $label)); } @@ -11260,34 +10778,35 @@ private function commitWorldWrites(): void } /** - * Returns the rows shown while writes are being built. + * Returns the rows shown while conditions are being built. * * @return string[] The rows. */ - private function buildWorldWriteEditorRows(): array + private function buildConditionEditorRows(): array { - if ($this->isWorldWriteNaming) { - $reference = $this->worldWriteEditor->nameReference(); + $rows = $this->conditionEditor->rows(); + + if ($this->isConditionNaming) { + $reference = $this->conditionEditor->nameReference(); return [ - sprintf('%s: %s', $this->worldWriteNamingValue ? 'Value' : ($reference['label'] ?? 'Name'), $this->worldWriteNameBuffer), + sprintf('%s: %s', $reference['label'] ?? 'Name', $this->conditionNameBuffer), '', ' Enter to accept, Esc to leave it alone.', ]; } - $rows = $this->worldWriteEditor->rows(); - $lines = [sprintf('%s · %d', $this->worldWriteEditor->label(), count($rows)), '']; + $lines = [sprintf('%s · %d', $this->conditionEditor->label(), count($rows)), '']; if ($rows === []) { - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. Nothing changes when this completes.', $this->recordPaneMetrics()['width'])]; + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. Every condition holds, so this always runs.', $this->recordPaneMetrics()['width'])]; $lines[] = ''; - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add a write.', $this->recordPaneMetrics()['width'])]; + $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add one.', $this->recordPaneMetrics()['width'])]; return $lines; } - $selectedIndex = $this->worldWriteEditor->selectedIndex(); + $selectedIndex = $this->conditionEditor->selectedIndex(); foreach ($rows as $index => $row) { $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $row); @@ -11300,5761 +10819,3151 @@ private function buildWorldWriteEditorRows(): array } /** - * Opens the predicate editor on a field that holds battle-entry actor - * predicates. + * Opens the picker on a field that names another record. * * @param array $field The settings-pane field descriptor. - * @return void + * @return bool True when the picker opened. */ - private function openBattleEntryPredicateEditor(array $field): void - { - $this->battleEntryPredicateEditor->open( - (string) ($field['field'] ?? ''), - (string) ($field['label'] ?? 'Actor Predicates'), - BattleEntryPredicateCodec::decodeAll((string) ($field['value'] ?? '')), - ); - $this->statusMessage = 'Building actor predicates.'; - $this->renderDatabasePanes(['settings']); - } - /** - * Handles input while actor predicates are being built. - * - * @param string $input The raw input. - * @return void + * Returns the catalogue pickers choose from, reading the map the author + * is working in -- the selected map, or the cinematic's start map while + * the Cutscenes screen hosts the pane -- and the cinematic itself, for + * its cast, checkpoints and subjects. */ - private function handleBattleEntryPredicateEditorInput(string $input): void + private function referenceCatalog(): ReferenceCatalog { - if ($input === "\033" || $input === "\x1b") { - $this->battleEntryPredicateEditor->close(); - $this->statusMessage = 'Actor predicates unchanged.'; - $this->renderDatabasePanes(['settings']); - - return; + if (! $this->workspace instanceof ProjectWorkspace) { + throw new RuntimeException('The editor workspace is not loaded.'); } - if ($input === "\n" || $input === "\r") { - $this->commitBattleEntryPredicates(); - - return; + if ($this->isCutscenesOpen) { + return new ReferenceCatalog($this->workspace, $this->cutsceneReferenceMap(), $this->selectedCutscene()); } - match (true) { - str_contains($input, "\033[A") => $this->battleEntryPredicateEditor->move(-1), - str_contains($input, "\033[B") => $this->battleEntryPredicateEditor->move(1), - $input === 'a' => $this->battleEntryPredicateEditor->add(), - $input === 'd' => $this->battleEntryPredicateEditor->remove(), - $input === 'x' => $this->battleEntryPredicateEditor->cyclePresence(1), - $input === 'X' => $this->battleEntryPredicateEditor->cyclePresence(-1), - $input === 'n' => $this->beginBattleEntryPredicateActor(), - default => null, - }; - - $this->renderDatabasePanes(['settings']); + return new ReferenceCatalog($this->workspace, $this->getSelectedMap()); } - /** - * Starts picking the selected predicate's actor: a durable identity is - * chosen, never typed. - * - * @return void - */ - private function beginBattleEntryPredicateActor(): void + private function openReferencePicker(array $field): bool { - $predicate = $this->battleEntryPredicateEditor->selected(); - - if ($predicate === null || ! $this->workspace instanceof ProjectWorkspace) { - return; + if (! $this->workspace instanceof ProjectWorkspace) { + return false; } + $category = (string) ($field['reference'] ?? ''); + $label = (string) ($field['label'] ?? 'Reference'); $catalog = $this->referenceCatalog(); - $values = $catalog->valuesFor('actor_ids'); - - if (! $this->referencePicker->open(self::BATTLE_PREDICATE_ACTOR_FIELD, 'Actor', 'actor_ids', $values, strval($predicate['actor'] ?? ''), $catalog->labelsFor('actor_ids'))) { - $this->setStatus('This project defines no actors to choose from.', StatusLevel::WARN); - } - } - /** - * Stores the predicate list being built back onto its field. - * - * @return void - */ - private function commitBattleEntryPredicates(): void - { - $fieldId = $this->battleEntryPredicateEditor->fieldId(); - $label = $this->battleEntryPredicateEditor->label(); - $encoded = $this->battleEntryPredicateEditor->encoded(); - $this->battleEntryPredicateEditor->close(); + if (is_string($field['referenceMap'] ?? null)) { + // A map-local reference chooses from the map its own entry names. + $map = $field['referenceMap'] === '' ? null : $catalog->findMap($field['referenceMap']); - $field = null; + if ($map === null) { + $this->setStatus('Choose a map for this entry first.', StatusLevel::WARN); + $this->renderDatabasePanes(['settings']); - foreach ($this->getDatabaseSettingsFields() as $candidate) { - if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { - $field = $candidate; + return true; } - } - - if (! is_array($field)) { - return; - } - try { - $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, sprintf('%s edit', $label)); + $catalog = new ReferenceCatalog($this->workspace, $map); } + $values = $catalog->valuesFor($category); + $labels = $catalog->labelsFor($category); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } - - /** - * Returns the rows shown while actor predicates are being built. - * - * @return string[] The rows. - */ - private function buildBattleEntryPredicateEditorRows(): array - { - $labels = $this->workspace instanceof ProjectWorkspace - ? $this->referenceCatalog()->labelsFor('actor_ids') - : []; - $rows = $this->battleEntryPredicateEditor->rows($labels); - $lines = [sprintf('%s · %d', $this->battleEntryPredicateEditor->label(), count($rows)), '']; - - if ($rows === []) { - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. The rule matches no one and never runs.', $this->recordPaneMetrics()['width'])]; - $lines[] = ''; - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add a predicate.', $this->recordPaneMetrics()['width'])]; + $specials = []; - return $lines; + if (($field['allowsNone'] ?? false) === true) { + // An optional reference needs a way back to nothing, or setting + // it once would be a dead end. The row reads as what "nothing" + // means to the runtime when the schema says so. + $specials[] = (string) ($field['noneLabel'] ?? '(None)'); } - $selectedIndex = $this->battleEntryPredicateEditor->selectedIndex(); - - foreach ($rows as $index => $row) { - $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $row); + if (is_string($field['blankLabel'] ?? null)) { + // Where the runtime gives an empty value a meaning of its own (a + // page with no speaker), distinct from an absent one, that is a + // row to pick as well. + $specials[] = $field['blankLabel']; } - // Two header lines, then the rows in whatever height the pane has. - $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); - - return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; - } - - /** - * Opens the affinity editor on a field that holds an element map. - * - * @param array $field The settings-pane field descriptor. - * @return void - */ - private function openAffinityEditor(array $field): void - { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } + // The special rows stand on their own: with nothing else to choose + // from, clearing a stale reference or picking "no speaker" is still + // a choice the author must be able to make. + $values = [...$specials, ...$values]; - $elements = $this->referenceCatalog()->valuesFor('elements'); + $opened = $this->referencePicker->open( + (string) ($field['field'] ?? ''), + $label, + $category, + $values, + (string) ($field['value'] ?? ''), + $labels, + ); - if ($elements === []) { - // No element enum, no rows to build: saying so beats an editor - // that opens with nothing to pick. + if (! $opened) { + // Saying so beats opening an empty list, or worse, quietly doing + // nothing when the key is pressed. $this->setStatus( - 'This project defines no element types under assets/Data/Types.', + sprintf('This project defines no %s to choose from.', str_replace('_', ' ', $category)), StatusLevel::WARN ); $this->renderDatabasePanes(['settings']); - return; + return true; } - $this->affinityEditor->open( - (string) ($field['field'] ?? ''), - (string) ($field['label'] ?? 'Elemental'), - ElementAffinityCodec::decodeAll((string) ($field['value'] ?? '')), - $elements, - ); - - $this->statusMessage = 'Building elemental wards.'; + $this->statusMessage = sprintf('Choose %s.', lcfirst($label)); $this->renderDatabasePanes(['settings']); + + return true; } /** - * Handles input while affinities are being built. + * Drives the reference picker. * * @param string $input The raw input. * @return void */ - private function handleAffinityEditorInput(string $input): void + private function handleReferencePickerInput(string $input): void { if ($input === "\033" || $input === "\x1b") { - $this->affinityEditor->close(); - $this->statusMessage = 'Elemental wards unchanged.'; + $this->referencePicker->close(); + $this->statusMessage = 'Selection cancelled.'; $this->renderDatabasePanes(['settings']); return; } if ($input === "\n" || $input === "\r") { - $this->commitAffinities(); + $this->commitReferenceSelection(); return; } - match (true) { - str_contains($input, "\033[A") => $this->affinityEditor->move(-1), - str_contains($input, "\033[B") => $this->affinityEditor->move(1), - $input === 'a' => $this->affinityEditor->add(), - $input === 'd' => $this->affinityEditor->remove(), - $input === 'x' => $this->affinityEditor->cycleEffect(1), - $input === 'X' => $this->affinityEditor->cycleEffect(-1), - $input === 'n' => $this->beginAffinityElement(), - default => null, - }; + if (str_contains($input, "\033[A")) { + $this->referencePicker->move(-1); + $this->renderDatabasePanes(['settings']); - $this->renderDatabasePanes(['settings']); - } + return; + } - /** - * Opens the element picker for the selected affinity row. - * - * @return void - */ - private function beginAffinityElement(): void - { - $row = $this->affinityEditor->selected(); + if (str_contains($input, "\033[B")) { + $this->referencePicker->move(1); + $this->renderDatabasePanes(['settings']); - if ($row === null) { return; } - $this->referencePicker->open( - self::AFFINITY_ELEMENT_FIELD, - 'Element', - 'elements', - $this->affinityEditor->elements(), - $row['element'], - ); + if ($input === "\x7f" || $input === "\x08") { + $this->referencePicker->backspace(); + $this->renderDatabasePanes(['settings']); + + return; + } + + // Anything printable narrows the list, so a long list is reached by + // typing a few letters rather than scrolling. + if (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { + $this->referencePicker->type($input); + $this->renderDatabasePanes(['settings']); + } } /** - * Stores the affinities as the line the record layer decodes. + * Stores what the picker was left on. * * @return void */ - private function commitAffinities(): void + private function commitReferenceSelection(): void { - $fieldId = $this->affinityEditor->fieldId(); - $label = $this->affinityEditor->label(); - $encoded = $this->affinityEditor->encoded(); - $this->affinityEditor->close(); - - $field = null; + $selected = $this->referencePicker->selected(); + $fieldId = $this->referencePicker->fieldId(); + $label = $this->referencePicker->label(); + $this->referencePicker->close(); - foreach ($this->getDatabaseSettingsFields() as $candidate) { - if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { - $field = $candidate; - } - } + if ($fieldId === self::NPC_SELECT_FIELD) { + $this->selectNpcFromList($selected); - if (! is_array($field)) { return; } - try { - $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, sprintf('%s edit', $label)); - } + if ($fieldId === self::TROOP_MEMBER_ENEMY_FIELD) { + $selected === null ? $this->renderDatabasePanes(['settings']) : $this->addTroopMember($selected); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } + return; + } - /** - * Returns the rows shown while affinities are being built. - * - * @return string[] The rows. - */ - private function buildAffinityEditorRows(): array - { - $rows = $this->affinityEditor->describeRows(); - $lines = [sprintf('%s · %d', $this->affinityEditor->label(), count($rows)), '']; + if ($fieldId === self::WORLD_WRITE_NAME_FIELD) { + if ($selected !== null) { + $this->worldWriteEditor->setName($selected); + } - if ($rows === []) { - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. This piece is neutral to every element.', $this->recordPaneMetrics()['width'])]; - $lines[] = ''; - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add a ward.', $this->recordPaneMetrics()['width'])]; + $this->statusMessage = 'Building writes.'; + $this->renderDatabasePanes(['settings']); - return $lines; + return; } - $selectedIndex = $this->affinityEditor->selectedIndex(); + if ($fieldId === self::BATTLE_PREDICATE_ACTOR_FIELD) { + // Opened from the predicate editor, which is still the thing + // being edited; the field itself is written when that closes. + if ($selected !== null) { + $this->battleEntryPredicateEditor->setActor($selected); + } - foreach ($rows as $index => $row) { - $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $row); + $this->statusMessage = 'Building actor predicates.'; + $this->renderDatabasePanes(['settings']); + + return; } - // Two header lines, then the rows in whatever height the pane has. - $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); + if ($fieldId === self::AFFINITY_ELEMENT_FIELD) { + // Opened from the affinity editor, which is still the thing + // being edited; the field itself is written when that closes. + if ($selected !== null) { + $this->affinityEditor->setElement($selected); + } - return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; - } + $this->statusMessage = 'Building elemental wards.'; + $this->renderDatabasePanes(['settings']); - /** - * Opens the condition editor on a field that holds a condition list. - * - * @param array $field The settings-pane field descriptor. - * @return void - */ - private function openConditionEditor(array $field): void - { - $this->conditionEditor->open( - (string) ($field['field'] ?? ''), - (string) ($field['label'] ?? 'Conditions'), - ConditionCodec::decodeAll((string) ($field['value'] ?? '')), - ); + return; + } - $this->statusMessage = 'Building conditions.'; - $this->renderDatabasePanes(['settings']); - } + if ($fieldId === self::CONDITION_NAME_FIELD) { + // Opened from the condition editor, which is still the thing + // being edited; the field itself is written when that closes. + if ($selected !== null) { + $this->conditionEditor->setName($selected); + } - /** - * Handles input while conditions are being built. - * - * @param string $input The raw input. - * @return void - */ - private function handleConditionEditorInput(string $input): void - { - if ($this->isConditionNaming) { - $this->handleConditionNameInput($input); + $this->statusMessage = 'Building conditions.'; + $this->renderDatabasePanes(['settings']); return; } - if ($input === "\033" || $input === "\x1b") { - $this->conditionEditor->close(); - $this->statusMessage = 'Conditions unchanged.'; + if ($fieldId === self::CONDITION_MARKER_FIELD) { + if ($selected !== null) { + $this->conditionEditor->setMarker($selected); + } + + $this->statusMessage = 'Building conditions.'; $this->renderDatabasePanes(['settings']); return; } - if ($input === "\n" || $input === "\r") { - $this->commitConditions(); + if ($selected === null) { + $this->statusMessage = 'Nothing matched.'; + $this->renderDatabasePanes(['settings']); return; } - $handled = match (true) { - str_contains($input, "\033[A") => $this->conditionEditor->move(-1), - str_contains($input, "\033[B") => $this->conditionEditor->move(1), - $input === 'a' => $this->conditionEditor->add(), - $input === 'd' => $this->conditionEditor->remove(), - $input === 't' => $this->conditionEditor->cycleType(1), - $input === 'T' => $this->conditionEditor->cycleType(-1), - $input === 'x' => $this->conditionEditor->cycleExtra(1), - $input === 'X' => $this->conditionEditor->cycleExtra(-1), - $input === '!' => $this->conditionEditor->toggleNegate(), - $input === 'n' => $this->beginConditionName(), - default => null, - }; - - unset($handled); - $this->renderDatabasePanes(['settings']); - } + $field = null; - /** - * Starts setting what the selected condition names. - * - * A quest or an item is chosen from the project; a switch, story event or - * variable is a name the author invents, so that one is typed. - * - * @return void - */ - private function beginConditionName(): void - { - $reference = $this->conditionEditor->nameReference(); - $condition = $this->conditionEditor->selected(); + foreach ($this->getDatabaseSettingsFields() as $candidate) { + if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { + $field = $candidate; + } + } - if ($reference === null || $condition === null || ! $this->workspace instanceof ProjectWorkspace) { + if (! is_array($field)) { return; } - if (! is_string($reference['category'])) { - $this->isConditionNaming = true; - $this->conditionNameBuffer = strval($condition['name'] ?? ''); - $this->statusMessage = sprintf('Naming the %s.', mb_strtolower($reference['label'])); + try { + if (($field['multi'] ?? false) === true) { + // A list picked a member at a time: the pick toggles it. + $members = array_values(array_filter(array_map(trim(...), explode(',', (string) ($field['value'] ?? ''))), static fn(string $member): bool => $member !== '')); + $position = array_search($selected, $members, true); - return; - } + if ($position === false) { + $members[] = $selected; + } else { + array_splice($members, $position, 1); + } - $catalog = $this->referenceCatalog(); - $values = $catalog->valuesFor($reference['category']); + $this->applyDatabaseFieldValueRecorded($field, implode(', ', $members)); + $this->setStatus( + $position === false ? sprintf('%s: added %s.', $label, $selected) : sprintf('%s: removed %s.', $label, $selected), + StatusLevel::INFO, + ); + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - if (! $this->referencePicker->open( - self::CONDITION_NAME_FIELD, - $reference['label'], - $reference['category'], - $values, - strval($condition['name'] ?? ''), - $catalog->labelsFor($reference['category']), - )) { + return; + } + + // The record layer reads the none row as clearing the reference. + $this->applyDatabaseFieldValueRecorded($field, $selected); $this->setStatus( - sprintf('This project defines no %s to choose from.', str_replace('_', ' ', $reference['category'])), - StatusLevel::WARN + $selected === (string) ($field['noneLabel'] ?? '(None)') + ? sprintf('%s cleared.', $label) + : sprintf('%s set to %s.', $label, $selected), + StatusLevel::INFO, ); + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, sprintf('%s selection', $label)); } + + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } /** - * Handles input while a condition's name is being typed. + * Commits the current database edit back into the active asset. * - * @param string $input The raw input. * @return void */ - private function handleConditionNameInput(string $input): void + private function commitDatabaseEdit(): void { - if ($input === "\033" || $input === "\x1b") { - $this->isConditionNaming = false; - $this->conditionNameBuffer = ''; - $this->statusMessage = 'Name unchanged.'; - $this->renderDatabasePanes(['settings']); - - return; - } + $fields = $this->getDatabaseSettingsFields(); + $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - if ($input === "\n" || $input === "\r") { - $this->conditionEditor->setName(trim($this->conditionNameBuffer)); - $this->isConditionNaming = false; - $this->conditionNameBuffer = ''; - $this->statusMessage = 'Building conditions.'; + if (! is_array($field)) { + $this->isDatabaseEditing = false; + $this->databaseEditBuffer = ''; + $this->databaseEditCursorIndex = 0; $this->renderDatabasePanes(['settings']); - return; } - if ($input === "\x7f" || $input === "\x08") { - $this->conditionNameBuffer = mb_substr( - $this->conditionNameBuffer, - 0, - max(0, mb_strlen($this->conditionNameBuffer) - 1) - ); - $this->renderDatabasePanes(['settings']); - - return; + try { + $this->applyDatabaseFieldValueRecorded($field, $this->databaseEditBuffer); + $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::INFO); + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); } - if (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { - $this->conditionNameBuffer .= $input; - $this->renderDatabasePanes(['settings']); - } + $this->isDatabaseEditing = false; + $this->databaseEditBuffer = ''; + $this->databaseEditCursorIndex = 0; + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); } /** - * Stores the conditions as the line the data file holds. + * Applies one database settings value and records it for undo/redo. + * + * The command pins the entry identity (category, entry index, frame), so + * undoing later still edits the right record even after the selection + * moved elsewhere. Brush symbol/color are editor-local preferences and + * are deliberately not recorded. * + * @param array $field The settings field descriptor. + * @param string $rawValue The raw edited value. * @return void */ - private function commitConditions(): void + private function applyDatabaseFieldValueRecorded(array $field, string $rawValue): void { - if ($this->mapConditionField !== null) { - $field = $this->mapConditionField; - $this->mapConditionField = null; - $label = $this->conditionEditor->label(); - $encoded = $this->conditionEditor->encoded(); - $this->conditionEditor->close(); - $selectedMap = $this->getSelectedMap(); + if (($field['allowsNone'] ?? false) === true && $rawValue === (string) ($field['noneLabel'] ?? '(None)')) { + $rawValue = ''; + } - if ($selectedMap instanceof ProjectMap) { - $this->applyMapBgmVariantValue($selectedMap, $field, ConditionCodec::decodeAll($encoded)); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); - } + if ($this->isCutscenesOpen) { + $this->applyCutsceneFieldValueRecorded($field, $rawValue); - $this->renderSelectionDependentArea(); + return; + } + + if ($this->isNpcInspectorHosting()) { + $this->applyNpcFieldValueRecorded($field, $rawValue); return; } - $fieldId = $this->conditionEditor->fieldId(); - $label = $this->conditionEditor->label(); - $encoded = $this->conditionEditor->encoded(); - $this->conditionEditor->close(); + $fieldId = (string) ($field['field'] ?? ''); + $recordDatabase = $this->getSelectedRecordDatabase(); - $field = null; + if ($recordDatabase instanceof ProjectRecordDatabase) { + // The shared record rules apply the edit and say how to undo it: + // the record's whole payload, pinned to the record itself, so an + // undo after the selection moved still edits the right one. + $change = $this->applyRecordFieldValue($recordDatabase, $fieldId, $rawValue, (string) ($field['label'] ?? 'Database field')); - foreach ($this->getDatabaseSettingsFields() as $candidate) { - if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { - $field = $candidate; + if ($change?->command !== null) { + $this->recordCommand($change->command); + } + if ($change?->note !== null) { + $this->setStatus($change->note, StatusLevel::INFO); } - } - if (! is_array($field)) { return; } - try { - $this->applyDatabaseFieldValueRecorded($field, $encoded); - $this->setStatus(sprintf('%s updated.', $label), StatusLevel::SUCCESS); - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, sprintf('%s edit', $label)); - } + if ($this->isActorsDatabaseSelected()) { + if (! $this->workspace instanceof ProjectWorkspace) { + return; + } - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); - } + try { + $change = $this->actorAuthoring->applyField($this->workspace, $this->databaseSelectedActorIndex, $fieldId, $rawValue, (string) ($field['label'] ?? 'Actor field')); + } catch (RecordRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::ERROR); - /** - * Returns the rows shown while conditions are being built. - * - * @return string[] The rows. - */ - private function buildConditionEditorRows(): array - { - $rows = $this->conditionEditor->rows(); + return; + } - if ($this->isConditionNaming) { - $reference = $this->conditionEditor->nameReference(); + if ($change->command !== null) { + $this->recordCommand($change->command); + } - return [ - sprintf('%s: %s', $reference['label'] ?? 'Name', $this->conditionNameBuffer), - '', - ' Enter to accept, Esc to leave it alone.', - ]; + return; } - $lines = [sprintf('%s · %d', $this->conditionEditor->label(), count($rows)), '']; - - if ($rows === []) { - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' None. Every condition holds, so this always runs.', $this->recordPaneMetrics()['width'])]; - $lines[] = ''; - $lines = [...$lines, ...SettingsPaneLayout::wrapProse(' a to add one.', $this->recordPaneMetrics()['width'])]; - - return $lines; + $control = $this->getDatabaseFieldControl($field); + // Option values keep their authored case (the actor class picker + // writes `Vanguard`); matching them is case-insensitive instead. + $oldRawValue = $control instanceof InputControl + ? $control->rawValue + : (string) ($field['value'] ?? ''); + if (($field['allowsNone'] ?? false) === true && $oldRawValue === (string) ($field['noneLabel'] ?? '(None)')) { + $oldRawValue = ''; } + $identity = [ + 'category' => $this->databaseCategoryIndex, + 'actor' => $this->databaseSelectedActorIndex, + 'records' => $this->databaseSelectedRecordIndexes, + ]; - $selectedIndex = $this->conditionEditor->selectedIndex(); + // A schema record's edit is one step only when it changed the record: + // a value the record refused, or the value it already held, leaves none. + $schemaRecord = $this->getSelectedRecordDatabase()?->getRecordByIndex($this->getSelectedRecordIndex()); + $schemaBefore = $schemaRecord?->toArray(); + $this->applyDatabaseFieldValue($fieldId, $rawValue); - foreach ($rows as $index => $row) { - $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $row); + if ($schemaRecord !== null && $schemaRecord->toArray() === $schemaBefore) { + return; } - // Two header lines, then the rows in whatever height the pane has. - $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); + if ($oldRawValue === $rawValue) { + return; + } - return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; + $this->recordCommand(new GenericCommand( + sprintf('%s edit', $field['label'] ?? 'Database field'), + fn() => $this->applyDatabaseFieldValueAt($identity, $fieldId, $rawValue), + fn() => $this->applyDatabaseFieldValueAt($identity, $fieldId, $oldRawValue), + )); } /** - * Opens the picker on a field that names another record. + * Applies one row of a schema-driven record through the shared record + * rules ({@see RecordAuthoring::applyField()}), at the selected record + * and open frame. A value the field cannot take - a parameter line it + * cannot read, a coordinate pair that is not two numbers - is reported + * on the status line, and the record is left as it was. * - * @param array $field The settings-pane field descriptor. - * @return bool True when the picker opened. - */ - /** - * Returns the catalogue pickers choose from, reading the map the author - * is working in -- the selected map, or the cinematic's start map while - * the Cutscenes screen hosts the pane -- and the cinematic itself, for - * its cast, checkpoints and subjects. + * @param ProjectRecordDatabase $database The category. + * @param string $fieldId The row's field id. + * @param string $rawValue The raw edited value. + * @param string $label The row's label, which names the undo step. + * @return RecordChange|null What it did, or null when it was refused. */ - private function referenceCatalog(): ReferenceCatalog + private function applyRecordFieldValue(ProjectRecordDatabase $database, string $fieldId, string $rawValue, string $label): ?RecordChange { - if (! $this->workspace instanceof ProjectWorkspace) { - throw new RuntimeException('The editor workspace is not loaded.'); - } + try { + return (new RecordAuthoring())->applyField( + $database, + $this->getSelectedRecordIndex(), + $this->databaseCommandFramePath, + $fieldId, + $rawValue, + $label, + ); + } catch (RecordRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::ERROR); - if ($this->isCutscenesOpen) { - return new ReferenceCatalog($this->workspace, $this->cutsceneReferenceMap(), $this->selectedCutscene()); + return null; } - - return new ReferenceCatalog($this->workspace, $this->getSelectedMap()); } - private function openReferencePicker(array $field): bool + /** + * Applies a database value onto a pinned entry identity, restoring the + * live selection afterwards. + * + * @param array{category: int, actor: int, skill: int, records?: array} $identity The pinned selection. + * @param string $field The field identifier. + * @param string $rawValue The raw value to apply. + * @return void + */ + private function applyDatabaseFieldValueAt(array $identity, string $field, string $rawValue): void { - if (! $this->workspace instanceof ProjectWorkspace) { - return false; - } - - $category = (string) ($field['reference'] ?? ''); - $label = (string) ($field['label'] ?? 'Reference'); - $catalog = $this->referenceCatalog(); - $values = $catalog->valuesFor($category); - $labels = $catalog->labelsFor($category); - - $specials = []; - - if (($field['allowsNone'] ?? false) === true) { - // An optional reference needs a way back to nothing, or setting - // it once would be a dead end. The row reads as what "nothing" - // means to the runtime when the schema says so. - $specials[] = (string) ($field['noneLabel'] ?? '(None)'); - } - - if (is_string($field['blankLabel'] ?? null)) { - // Where the runtime gives an empty value a meaning of its own (a - // page with no speaker), distinct from an absent one, that is a - // row to pick as well. - $specials[] = $field['blankLabel']; - } - - // The special rows stand on their own: with nothing else to choose - // from, clearing a stale reference or picking "no speaker" is still - // a choice the author must be able to make. - $values = [...$specials, ...$values]; - - $opened = $this->referencePicker->open( - (string) ($field['field'] ?? ''), - $label, - $category, - $values, - (string) ($field['value'] ?? ''), - $labels, - ); - - if (! $opened) { - // Saying so beats opening an empty list, or worse, quietly doing - // nothing when the key is pressed. - $this->setStatus( - sprintf('This project defines no %s to choose from.', str_replace('_', ' ', $category)), - StatusLevel::WARN - ); - $this->renderDatabasePanes(['settings']); + $liveSelection = [ + $this->databaseCategoryIndex, + $this->databaseSelectedActorIndex, + $this->databaseSelectedRecordIndexes, + ]; + $this->databaseCategoryIndex = $identity['category']; + $this->databaseSelectedActorIndex = $identity['actor']; + $this->databaseSelectedRecordIndexes = $identity['records'] ?? $this->databaseSelectedRecordIndexes; - return true; + try { + $this->applyDatabaseFieldValue($field, $rawValue); + } finally { + [ + $this->databaseCategoryIndex, + $this->databaseSelectedActorIndex, + $this->databaseSelectedRecordIndexes, + ] = $liveSelection; } - - $this->statusMessage = sprintf('Choose %s.', lcfirst($label)); - $this->renderDatabasePanes(['settings']); - - return true; } /** - * Drives the reference picker. + * Applies one database settings value. * - * @param string $input The raw input. + * @param string $field The field identifier. + * @param string $rawValue The raw edited value. * @return void */ - private function handleReferencePickerInput(string $input): void + private function applyDatabaseFieldValue(string $field, string $rawValue): void { - if ($input === "\033" || $input === "\x1b") { - $this->referencePicker->close(); - $this->statusMessage = 'Selection cancelled.'; - $this->renderDatabasePanes(['settings']); - + if (! $this->workspace instanceof ProjectWorkspace) { return; } - if ($input === "\n" || $input === "\r") { - $this->commitReferenceSelection(); + $recordDatabase = $this->getSelectedRecordDatabase(); - return; + if ($recordDatabase instanceof ProjectRecordDatabase) { + // The schema owns coercion, so no per-category intval/trim rules + // are needed here. This path keeps no undo step. + $this->applyRecordFieldValue($recordDatabase, $field, $rawValue, $field); } + } - if (str_contains($input, "\033[A")) { - $this->referencePicker->move(-1); - $this->renderDatabasePanes(['settings']); + /** + * Cycles option-based settings fields with the arrow keys. + * + * @param int $step The cycle direction. + * @return void + */ + private function adjustDatabaseOptionField(int $step): void + { + $fields = $this->getDatabaseSettingsFields(); + $field = $fields[$this->databaseSelectedSettingIndex] ?? null; + if (! is_array($field) || ($field['editable'] ?? null) === false) { return; } - if (str_contains($input, "\033[B")) { - $this->referencePicker->move(1); - $this->renderDatabasePanes(['settings']); + $control = $this->getDatabaseFieldControl($field); + if ($control instanceof InputControl && $control->type === InputControlType::INTEGER) { + $this->applyDatabaseFieldValueRecorded($field, $control->adjust((string) ($field['value'] ?? '0'), $step)); + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); return; } - if ($input === "\x7f" || $input === "\x08") { - $this->referencePicker->backspace(); - $this->renderDatabasePanes(['settings']); + $options = $field['options'] ?? null; + if (! is_array($options) || $options === []) { return; } - // Anything printable narrows the list, so a long list is reached by - // typing a few letters rather than scrolling. - if (mb_strlen($input) === 1 && ! ctype_cntrl($input)) { - $this->referencePicker->type($input); - $this->renderDatabasePanes(['settings']); - } - } - - /** - * Stores what the picker was left on. - * - * @return void + // Options match case-insensitively but apply verbatim: the actor + // class picker must write `Vanguard`, not `vanguard`, because the + // engine matches the value against a `name` in classes.php. + $currentValue = mb_strtolower((string) ($field['value'] ?? '')); + $normalizedOptions = array_map(static fn(mixed $option): string => mb_strtolower((string) $option), $options); + $optionIndex = array_search($currentValue, $normalizedOptions, true); + $optionIndex = is_int($optionIndex) ? $optionIndex : 0; + $optionIndex = ListNavigation::step($optionIndex, $step, count($options)); + $this->applyDatabaseFieldValueRecorded($field, (string) $options[$optionIndex]); + $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + } + + /** + * Returns the variant row the inspector cursor is inside, or null. + * + * @return array{map: ProjectMap, variants: MapBgmVariants, index: int}|null */ - private function commitReferenceSelection(): void + private function selectedMapBgmVariantRow(): ?array { - $selected = $this->referencePicker->selected(); - $fieldId = $this->referencePicker->fieldId(); - $label = $this->referencePicker->label(); - $this->referencePicker->close(); - - if ($fieldId === self::NPC_SELECT_FIELD) { - $this->selectNpcFromList($selected); + $selectedMap = $this->getSelectedMap(); + $fields = $this->getInspectorFields(); + $field = $fields[$this->selectedInspectorFieldIndex] ?? null; - return; + if (! $selectedMap instanceof ProjectMap + || ! is_array($field) + || ! is_array($field['bgmVariantList'] ?? null)) { + return null; } - if ($fieldId === self::WORLD_WRITE_NAME_FIELD) { - if ($selected !== null) { - $this->worldWriteEditor->setName($selected); - } + return [ + 'map' => $selectedMap, + 'variants' => MapBgmVariants::fromMap($selectedMap), + 'index' => (int) ($field['bgmVariantList']['index'] ?? 0), + ]; + } - $this->statusMessage = 'Building writes.'; - $this->renderDatabasePanes(['settings']); + /** + * Moves the music variant the cursor is inside up or down, because + * declaration order is runtime behavior: the first match wins. + */ + private function moveMapBgmVariant(int $step): void + { + $context = $this->selectedMapBgmVariantRow(); + if ($context === null) { return; } - if ($fieldId === self::BATTLE_PREDICATE_ACTOR_FIELD) { - // Opened from the predicate editor, which is still the thing - // being edited; the field itself is written when that closes. - if ($selected !== null) { - $this->battleEntryPredicateEditor->setActor($selected); - } + $variants = $context['variants']; - $this->statusMessage = 'Building actor predicates.'; - $this->renderDatabasePanes(['settings']); + if (! $variants->isSupported()) { + $this->setStatus(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), StatusLevel::WARN); + $this->renderInspectorArea(); return; } - if ($fieldId === self::AFFINITY_ELEMENT_FIELD) { - // Opened from the affinity editor, which is still the thing - // being edited; the field itself is written when that closes. - if ($selected !== null) { - $this->affinityEditor->setElement($selected); - } - - $this->statusMessage = 'Building elemental wards.'; - $this->renderDatabasePanes(['settings']); + $from = $context['index']; + $block = $variants->withVariantMovedTo($from, $from + $step); + if ($block === null) { return; } - if ($fieldId === self::CONDITION_NAME_FIELD) { - // Opened from the condition editor, which is still the thing - // being edited; the field itself is written when that closes. - if ($selected !== null) { - $this->conditionEditor->setName($selected); - } + $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $block, 'Music variant move'); - $this->statusMessage = 'Building conditions.'; - $this->renderDatabasePanes(['settings']); + // The cursor follows the variant it was on. + $fields = $this->getInspectorFields(); - return; + foreach ($fields as $fieldIndex => $field) { + if (($field['target'] ?? null) === 'map-bgm-variants' + && ($field['field'] ?? null) === 'track' + && ($field['index'] ?? null) === $from + $step) { + $this->selectedInspectorFieldIndex = $fieldIndex; + break; + } } - if ($selected === null) { - $this->statusMessage = 'Nothing matched.'; - $this->renderDatabasePanes(['settings']); + $this->setStatus(sprintf('Moved variant %s. The first matching variant wins.', $step < 0 ? 'up' : 'down'), StatusLevel::INFO); + $this->renderSelectionDependentArea(); + } - return; - } + /** + * Opens the shared Condition editor on a map row's conditions (a music + * variant's, a landmark's reveal), hosted in the Inspector pane. The row + * carries its conditions, the editor's title and its hint; the result + * applies back through the row. + * + * @param array $field The inspector field descriptor. + */ + private function openMapConditionEditor(array $field): void + { + $this->mapConditionField = $field; + $this->conditionEditor->open( + (string) ($field['field'] ?? 'conditions'), + (string) ($field['conditionsTitle'] ?? trim((string) ($field['label'] ?? 'Conditions'))), + ConditionCodec::decodeAll((string) ($field['encoded'] ?? '')), + ); + $this->statusMessage = (string) ($field['conditionsHint'] ?? 'Building conditions.'); + $this->renderInspectorArea(); + } - $field = null; + /** + * Builds the inspector field list for the current selection. + * + * @return array> + */ + private function getInspectorFields(): array + { + $selectedMap = $this->getSelectedMap(); - foreach ($this->getDatabaseSettingsFields() as $candidate) { - if (is_array($candidate) && ($candidate['field'] ?? null) === $fieldId) { - $field = $candidate; - } + if (! $selectedMap instanceof ProjectMap) { + return []; } - if (! is_array($field)) { - return; + if ($this->isNpcInspectorHosting()) { + if ($this->selectedNpcIndex === null) { + return [[ + 'label' => 'NPCs · ' . $selectedMap->getNpcs()->count(), + 'value' => 'Enter on the canvas to select or create one.', + 'editable' => false, + ]]; + } + + return $this->getDatabaseSettingsFields(); } - try { - if (($field['multi'] ?? false) === true) { - // A list picked a member at a time: the pick toggles it. - $members = array_values(array_filter(array_map(trim(...), explode(',', (string) ($field['value'] ?? ''))), static fn(string $member): bool => $member !== '')); - $position = array_search($selected, $members, true); + $inspector = $this->createMapInspector(); + $fields = [ + ...$inspector->getMapFields($selectedMap), + ...$this->getLayerInspectorFields(), + ]; - if ($position === false) { - $members[] = $selected; - } else { - array_splice($members, $position, 1); - } + if ($this->editingMode !== self::MODE_EVENT) { + return $fields; + } - $this->applyDatabaseFieldValueRecorded($field, implode(', ', $members)); - $this->setStatus( - $position === false ? sprintf('%s: added %s.', $label, $selected) : sprintf('%s: removed %s.', $label, $selected), - StatusLevel::SUCCESS, - ); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + $marker = $this->getFocusedEventMarker(); - return; - } + if ($marker === null) { + $fields[] = [ + 'label' => 'Event', + 'value' => 'Move cursor onto an event marker', + 'editable' => false, + ]; + return $fields; + } - // The record layer reads the none row as clearing the reference. - $this->applyDatabaseFieldValueRecorded($field, $selected); - $this->setStatus( - $selected === (string) ($field['noneLabel'] ?? '(None)') - ? sprintf('%s cleared.', $label) - : sprintf('%s set to %s.', $label, $selected), - StatusLevel::SUCCESS, - ); - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, sprintf('%s selection', $label)); + $frame = $this->getOpenEventInspectorFrame(); + $frameFields = $frame === [] ? null : $inspector->getEventFrameFields($selectedMap, $marker, $frame); + + if ($frameFields !== null) { + // A frame of the event's inline commands, alone under the trail that names it. + return [['label' => $inspector->describeEventFrame($selectedMap, $marker, $frame), 'value' => 'Esc goes back', 'editable' => false], ...$frameFields]; } - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + // A frame an edit or an undo took away leaves the event's own rows. + $this->eventInspectorFrame = null; + + return [...$fields, ...$inspector->getEventFields($selectedMap, $marker)]; } /** - * Commits the current database edit back into the active asset. + * The frame of the focused event's inline commands the Inspector shows: + * [] for the event's own rows, and once the cursor has moved to another + * event. * - * @return void + * @return list */ - private function commitDatabaseEdit(): void + private function getOpenEventInspectorFrame(): array { - $fields = $this->getDatabaseSettingsFields(); - $field = $fields[$this->databaseSelectedSettingIndex] ?? null; + $open = $this->eventInspectorFrame; - if (! is_array($field)) { - $this->isDatabaseEditing = false; - $this->databaseEditBuffer = ''; - $this->databaseEditCursorIndex = 0; - $this->renderDatabasePanes(['settings']); - return; - } + if ($open === null || $this->editingMode !== self::MODE_EVENT || $this->getFocusedEventMarker() !== $open['marker']) { + $this->eventInspectorFrame = null; - try { - $this->applyDatabaseFieldValueRecorded($field, $this->databaseEditBuffer); - $this->setStatus(sprintf('%s updated.', $field['label'] ?? 'Field'), StatusLevel::SUCCESS); - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, sprintf('%s edit', $field['label'] ?? 'Field')); + return []; } - $this->isDatabaseEditing = false; - $this->databaseEditBuffer = ''; - $this->databaseEditCursorIndex = 0; - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + return $open['frame']; } /** - * Returns the editable settings fields for the project system. + * Opens a frame of an event's inline commands in the Inspector: a Story + * Script's script, or an arm of one of its commands. * - * @return array> + * @param list $frame */ - private function getDatabaseSystemSettingsFields(): array + private function enterEventInspectorFrame(string $marker, array $frame): void { - if (! $this->workspace instanceof ProjectWorkspace) { - return []; - } + $map = $this->getSelectedMap(); - $system = $this->workspace->systemDatabase; + if (! $map instanceof ProjectMap || $this->createMapInspector()->getEventFrameFields($map, $marker, $frame) === null) { + return; + } - return [ - [ - 'label' => 'Battle Engine', - 'value' => $system->getBattleEngine(), - 'options' => ['traditional', 'active_time'], - 'field' => 'battleEngine', - ], - [ - 'label' => 'ATB Mode', - 'value' => $system->getAtbMode(), - 'options' => ['wait'], - 'field' => 'atbMode', - ], - [ - 'label' => 'ATB Base Fill Rate', - 'value' => (string) $system->getAtbBaseFillRate(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $system->getAtbBaseFillRate()), - 'field' => 'atbBaseFillRate', - ], - [ - 'label' => 'ATB Speed Factor %', - 'value' => (string) $system->getAtbSpeedFactorPercent(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $system->getAtbSpeedFactorPercent()), - 'field' => 'atbSpeedFactorPercent', - ], - ]; + $this->eventInspectorFrame = ['marker' => $marker, 'frame' => $frame]; + $this->selectedInspectorFieldIndex = 1; + $this->setStatus($this->createMapInspector()->describeEventFrame($map, $marker, $frame) . '. Esc goes back.', StatusLevel::INFO); + $this->renderInspectorArea(); } - /** - * Applies one database settings value and records it for undo/redo. - * - * The command pins the entry identity (category, entry index, frame), so - * undoing later still edits the right record even after the selection - * moved elsewhere. Brush symbol/color are editor-local preferences and - * are deliberately not recorded. - * - * @param array $field The settings field descriptor. - * @param string $rawValue The raw edited value. - * @return void - */ - private function applyDatabaseFieldValueRecorded(array $field, string $rawValue): void + /** Leaves the open frame of an event's commands for the one enclosing it, back on the row that opened it. */ + private function leaveEventInspectorFrame(): void { - if (($field['allowsNone'] ?? false) === true && $rawValue === (string) ($field['noneLabel'] ?? '(None)')) { - $rawValue = ''; - } + $map = $this->getSelectedMap(); + $open = $this->eventInspectorFrame; + $definition = $map instanceof ProjectMap && $open !== null ? $map->getEventDefinition($open['marker']) : null; - if ($this->isCutscenesOpen) { - $this->applyCutsceneFieldValueRecorded($field, $rawValue); + if ($open === null || $definition === null) { + $this->eventInspectorFrame = null; return; } - if ($this->isNpcInspectorHosting()) { - $this->applyNpcFieldValueRecorded($field, $rawValue); + $pane = $this->createMapInspector()->createEventRecordPane($open['marker'], $definition); + ['frame' => $frame, 'landing' => $landing] = $pane->records()->describeEnclosingFrame($open['frame']); + $this->eventInspectorFrame = $frame === [] ? null : ['marker' => $open['marker'], 'frame' => $frame]; + $this->selectedInspectorFieldIndex = 0; - return; + foreach ($this->getInspectorFields() as $index => $field) { + if (($field['target'] ?? null) === EventRecordPane::TARGET && str_starts_with((string) ($field['field'] ?? ''), $landing)) { + $this->selectedInspectorFieldIndex = $index; + break; + } } - $fieldId = (string) ($field['field'] ?? ''); - $control = $this->getDatabaseFieldControl($field); - // Option values keep their authored case (the actor class picker - // writes `Vanguard`); matching them is case-insensitive instead. - $oldRawValue = $control instanceof InputControl - ? $control->rawValue - : (string) ($field['value'] ?? ''); - $identity = [ - 'category' => $this->databaseCategoryIndex, - 'actor' => $this->databaseSelectedActorIndex, - 'class' => $this->databaseSelectedClassIndex, - 'skill' => $this->databaseSelectedSkillIndex, - 'quest' => $this->databaseSelectedQuestIndex, - 'animation' => $this->databaseSelectedAnimationIndex, - 'frame' => $this->databaseSelectedFrameIndex, - 'records' => $this->databaseSelectedRecordIndexes, - ]; - - $this->applyDatabaseFieldValue($fieldId, $rawValue); - - if (in_array($fieldId, ['brushSymbol', 'brushColor'], true) || $oldRawValue === $rawValue) { - return; - } - - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $field['label'] ?? 'Database field'), - fn() => $this->applyDatabaseFieldValueAt($identity, $fieldId, $rawValue), - fn() => $this->applyDatabaseFieldValueAt($identity, $fieldId, $oldRawValue), - )); + $this->setStatus($frame === [] ? sprintf('Event %s.', $open['marker']) : $pane->describeFrame($frame) . '.', StatusLevel::INFO); + $this->renderInspectorArea(); } /** - * Applies a database value onto a pinned entry identity, restoring the - * live selection afterwards. + * Opens the shared write editor on a map inspector row's writes (an + * event's completion writes, a dialogue variant's), hosted in the + * Inspector pane; the result applies back through the row. * - * @param array{category: int, actor: int, class: int, skill: int, quest: int, animation: int, frame: int, records?: array} $identity The pinned selection. - * @param string $field The field identifier. - * @param string $rawValue The raw value to apply. - * @return void + * @param array $field The inspector field descriptor. */ - private function applyDatabaseFieldValueAt(array $identity, string $field, string $rawValue): void + private function openMapWriteEditor(array $field): void { - $liveSelection = [ - $this->databaseCategoryIndex, - $this->databaseSelectedActorIndex, - $this->databaseSelectedClassIndex, - $this->databaseSelectedSkillIndex, - $this->databaseSelectedQuestIndex, - $this->databaseSelectedAnimationIndex, - $this->databaseSelectedFrameIndex, - $this->databaseSelectedRecordIndexes, - ]; - $this->databaseCategoryIndex = $identity['category']; - $this->databaseSelectedActorIndex = $identity['actor']; - $this->databaseSelectedClassIndex = $identity['class']; - $this->databaseSelectedSkillIndex = $identity['skill']; - $this->databaseSelectedQuestIndex = $identity['quest']; - $this->databaseSelectedAnimationIndex = $identity['animation']; - $this->databaseSelectedFrameIndex = $identity['frame']; - $this->databaseSelectedRecordIndexes = $identity['records'] ?? $this->databaseSelectedRecordIndexes; - - try { - $this->applyDatabaseFieldValue($field, $rawValue); - } finally { - [ - $this->databaseCategoryIndex, - $this->databaseSelectedActorIndex, - $this->databaseSelectedClassIndex, - $this->databaseSelectedSkillIndex, - $this->databaseSelectedQuestIndex, - $this->databaseSelectedAnimationIndex, - $this->databaseSelectedFrameIndex, - $this->databaseSelectedRecordIndexes, - ] = $liveSelection; - } + $this->mapWriteField = $field; + $writeTypes = $field['writeTypes'] ?? null; + $this->worldWriteEditor->open( + (string) ($field['field'] ?? 'sets'), + trim((string) ($field['label'] ?? 'Writes')), + WorldWriteCodec::decodeAll((string) ($field['value'] ?? '')), + is_array($writeTypes) ? $writeTypes : null, + ); + $this->statusMessage = 'Building writes.'; + $this->renderInspectorArea(); } + /** The map inspector over the project's references as the selected map sees them. */ + private function createMapInspector(): MapInspector + { + return new MapInspector($this->referenceCatalog()); + } /** - * Returns an edited actor value as the field's own type. - * - * Most actor numbers are quantities that cannot go below zero, but an - * actor's nature is an adjustment: being slower than the class baseline - * is a legitimate thing to author, so those keep their sign. + * Draws the editor shell. * - * @param string $field The field identifier. - * @param string $rawValue The raw edited value. - * @return string|int The coerced value. + * @return void */ - private function coerceActorFieldValue(string $field, string $rawValue): string|int + private function render(): void { - if (in_array($field, ['name', 'description', 'class', 'id', 'defaultNaturalVariantId', 'summons'], true)) { - return trim($rawValue); + if (! $this->isRunning) { + return; } - if (str_starts_with($field, 'actorNaturalAdjustments.') || str_starts_with($field, 'naturalVariants.')) { - return intval(trim($rawValue)); + if ($this->isFullRenderPending) { + $this->isFullRenderPending = false; + $this->clearDirtyRenderState(); + $this->renderFullScreen(); + return; } - return max(0, intval($rawValue)); + $this->flushDirtyPanels(); } /** - * Applies one database settings value. + * Repaints exactly the panels and overlays that input handlers marked + * dirty this tick. An idle frame writes zero bytes. * - * @param string $field The field identifier. - * @param string $rawValue The raw edited value. * @return void */ - private function applyDatabaseFieldValue(string $field, string $rawValue): void + private function flushDirtyPanels(): void { if (! $this->workspace instanceof ProjectWorkspace) { return; } - if ($this->isActorsDatabaseSelected() && in_array($field, [self::ACTOR_GROWTH_FIELD, self::ACTOR_OPTIMIZE_SLOT_FIELD], true)) { - // Both are assumptions the preview makes, not values the project - // stores: neither marks anything dirty and neither is written. - $actor = $this->getSelectedActor(); + if ($this->isDatabaseOpen) { + $this->databaseScreen->flush(); - if ($actor instanceof ProjectActor && $field === self::ACTOR_GROWTH_FIELD) { - $this->actorGrowthSelections[$actor->getDefinitionId()] = trim($rawValue); - } elseif ($actor instanceof ProjectActor) { - $this->actorOptimizeSlots[$actor->getDefinitionId()] = trim($rawValue); + if ($this->areOverlaysDirty) { + $this->drawOverlays(); } + $this->clearDirtyRenderState(); return; } - if ($this->isActorsDatabaseSelected() && $field === self::ACTOR_VARIANT_FIELD) { - // Which variant the rows edit is a choice about the pane, not a - // value the project stores. - $actor = $this->getSelectedActor(); + if ($this->isCutscenesOpen) { + $this->flushCutscenesScreen(); - if ($actor instanceof ProjectActor) { - $this->actorVariantSelections[$actor->getDefinitionId()] = trim($rawValue); + if ($this->areOverlaysDirty) { + $this->drawOverlays(); } + $this->clearDirtyRenderState(); return; } - if ($this->isDatabaseFieldRejected($field, $rawValue)) { - return; - } - - if ($this->isActorsDatabaseSelected()) { - $this->workspace->actorDatabase->setField( - $this->databaseSelectedActorIndex, - $field, - $this->coerceActorFieldValue($field, $rawValue), - ); - - return; - } - - if ($this->isClassesDatabaseSelected()) { - $this->workspace->classDatabase->setField( - $this->databaseSelectedClassIndex, - $field, - in_array($field, ['name', 'description', 'note'], true) ? trim($rawValue) : max(0, intval($rawValue)), - ); - - return; - } - - if ($this->isSkillsDatabaseSelected()) { - $value = in_array($field, ["cost", "cooldown", "invocationSpeed", "invocationAccuracy", "invocationRepeat", "invocationApGain"], true) - ? max(0, intval($rawValue)) - : ($field === "scopeTargetCount" ? $rawValue : trim($rawValue)); - $this->workspace->skillDatabase->setField($this->databaseSelectedSkillIndex, $field, $value); - return; - } - - if ($this->isQuestsDatabaseSelected()) { - if ($field === 'name') { - $this->renameSelectedQuest(trim($rawValue)); - - return; + foreach ([$this->assetsPanel, $this->canvasPanel, $this->inspectorPanel] as $panel) { + if ($panel->isDirty()) { + $panel->render(); } - - $isIntegerField = in_array($field, ['rewardGold', 'rewardExperience'], true) - || preg_match('/^objective\d+Quantity$/', $field) === 1; - $value = $isIntegerField ? max(0, intval($rawValue)) : trim($rawValue); - $this->workspace->questDatabase->setField($this->databaseSelectedQuestIndex, $field, $value); - return; } - if ($this->isSystemDatabaseSelected()) { - $value = in_array($field, ['battleEngine', 'atbMode'], true) - ? trim($rawValue) - : max(0, intval($rawValue)); - $this->workspace->systemDatabase->setField($field, $value); - return; + if ($this->workspace->hasUnsavedChanges() !== $this->paintedHeaderUnsaved) { + $this->drawHeader(); } - $recordDatabase = $this->getSelectedRecordDatabase(); - - if ($recordDatabase instanceof ProjectRecordDatabase) { - // The schema owns coercion, so no per-category intval/trim rules - // are needed here. - if ($recordDatabase->hasCommandFrames()) { - $recordDatabase->setFrameField( - $this->getSelectedRecordIndex(), - $this->databaseCommandFramePath, - $field, - $rawValue, - ); - - return; - } - - $recordDatabase->setField($this->getSelectedRecordIndex(), $field, $rawValue); - return; + if ($this->isFooterDirty) { + $this->drawFooter(); } - match ($field) { - 'name', 'position', 'maxFrames' => $this->workspace->animationDatabase->setField( - $this->databaseSelectedAnimationIndex, - $field, - $field === 'maxFrames' ? max(1, intval($rawValue)) : trim($rawValue) - ), - 'brushSymbol' => $this->databaseSelectedPaintSymbol = $this->normalizeDatabaseSymbol($rawValue), - 'brushColor' => $this->databaseSelectedPaintColor = $this->normalizeDatabaseColor($rawValue), - 'frameSound', 'flashColor', 'flashDurationFrames' => $this->applyDatabaseCueFieldValue($field, $rawValue), - default => null, - }; - - $animation = $this->getSelectedAnimation(); - - if ($animation instanceof Animation) { - $this->databaseSelectedFrameIndex = max(1, min($animation->maxFrames, $this->databaseSelectedFrameIndex)); - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; + if ($this->areOverlaysDirty) { + $this->drawOverlays(); + } elseif ($this->isCanvasCursorDirty) { + $this->renderCanvasCursor($this->resolveLayout()); } - $this->centerDatabasePreviewCursor(); + $this->clearDirtyRenderState(); } /** - * Applies cue-field edits for the selected frame. + * Clears every per-panel dirty flag (a full-screen redraw supersedes + * partial repaints). * - * @param string $field The field identifier. - * @param string $rawValue The raw field value. * @return void */ - private function applyDatabaseCueFieldValue(string $field, string $rawValue): void + private function clearDirtyRenderState(): void { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $cue = $animation->getCue($this->databaseSelectedFrameIndex) ?? new AnimationCue(); - $soundEffect = $cue->soundEffect; - $flashColor = $cue->flashColor; - $flashDurationFrames = $cue->flashDurationFrames; - - if ($field === 'frameSound') { - $soundEffect = trim($rawValue); - } - - if ($field === 'flashColor') { - $flashColor = $this->normalizeDatabaseColor($rawValue); - } - - if ($field === 'flashDurationFrames') { - $flashDurationFrames = max(0, intval($rawValue)); - } - - $this->workspace->animationDatabase->setFrameCue( - $this->databaseSelectedAnimationIndex, - $this->databaseSelectedFrameIndex, - $soundEffect, - $flashColor, - $flashDurationFrames, - ); + $this->assetsPanel->clearDirty(); + $this->canvasPanel->clearDirty(); + $this->inspectorPanel->clearDirty(); + $this->isFooterDirty = false; + $this->areOverlaysDirty = false; + $this->isCanvasCursorDirty = false; + $this->databaseScreen->clearDirty(); } /** - * Cycles option-based settings fields with the arrow keys. + * Draws the full editor shell. * - * @param int $step The cycle direction. * @return void */ - private function adjustDatabaseOptionField(int $step): void + private function renderFullScreen(): void { - $fields = $this->getDatabaseSettingsFields(); - $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - - if (! is_array($field)) { - return; + if (! $this->workspace instanceof ProjectWorkspace) { + throw new RuntimeException('The editor workspace is not loaded.'); } - $control = $this->getDatabaseFieldControl($field); + Console::cursor()->hide(); - if ($control instanceof InputControl && $control->type === InputControlType::INTEGER) { - $this->applyDatabaseFieldValueRecorded($field, $control->adjust((string) ($field['value'] ?? '0'), $step)); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + if ($this->isDatabaseOpen) { + $this->clearScreen(); + $this->drawDatabaseArea(includeRoot: true); + $this->renderModalOverlays($this->resolveLayout()); return; } - $options = $field['options'] ?? null; - - if (! is_array($options) || $options === []) { + if ($this->isCutscenesOpen) { + $this->clearScreen(); + $this->cutscenesScreen->drawAll(includeRoot: true); + $this->cutscenesScreen->clearDirty(); + $this->renderModalOverlays($this->resolveLayout()); return; } - // Options match case-insensitively but apply verbatim: the actor - // class picker must write `Vanguard`, not `vanguard`, because the - // engine matches the value against a `name` in classes.php. - $currentValue = mb_strtolower((string) ($field['value'] ?? '')); - $normalizedOptions = array_map(static fn(mixed $option): string => mb_strtolower((string) $option), $options); - $optionIndex = array_search($currentValue, $normalizedOptions, true); - $optionIndex = is_int($optionIndex) ? $optionIndex : 0; - $optionIndex = ListNavigation::step($optionIndex, $step, count($options)); - $this->applyDatabaseFieldValueRecorded($field, (string) $options[$optionIndex]); - $this->renderDatabasePanes(['list', 'settings', 'cue', 'frames', 'preview']); + $this->clearScreen(); + $this->drawHeader(); + $this->createAssetWindow()->render(); + $this->createCanvasWindow()->render(); + $this->createInspectorWindow()->render(); + $this->drawFooter(); + + $this->drawOverlays(); } /** - * Handles typed painting input inside the database preview. + * Clears the visible screen with a single escape sequence. + * + * Never shells out: `system("clear")` forks a subprocess (~5ms) on every + * call and is the difference between a repaint and a visible flash. * - * @param string $input The raw input. * @return void */ - private function handleDatabaseTypedSymbolInput(string $input): void + private function clearScreen(): void { - if (str_contains($input, "\033")) { - return; - } - - if (preg_match('/^\X/u', $input, $matches) !== 1) { - return; - } - - $symbol = $matches[0]; - - if ($symbol === "\n" || $symbol === "\r" || $symbol === "\t") { - return; - } - - $this->databaseSelectedPaintSymbol = $this->normalizeDatabaseSymbol($symbol); - $this->paintDatabasePreviewSymbol($this->databaseSelectedPaintSymbol, true); + $this->terminal->clearScreen(); } /** - * Paints the selected symbol onto the current animation frame. + * Returns the live terminal size. * - * @param string $symbol The symbol to paint. - * @return void + * @return array{width: int, height: int} */ - private function paintDatabasePreviewSymbol(string $symbol, bool $refreshSettings = false): void + private function getTerminalSize(): array { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $previewSize = $this->getDatabasePreviewSize(); - $origin = AnimationPreviewRenderer::resolveOrigin($animation->position, $previewSize['width'], $previewSize['height']); - $cellX = $this->databasePreviewCursorX - $origin['x']; - $cellY = $this->databasePreviewCursorY - $origin['y']; - $color = trim($symbol) === '' ? null : $this->databaseSelectedPaintColor; - $animationDatabase = $this->workspace->animationDatabase; - $animationIndex = $this->databaseSelectedAnimationIndex; - $frameIndex = $this->databaseSelectedFrameIndex; - $oldCell = $animation->getFrame($frameIndex)->getCellAt($cellX, $cellY); - $oldSymbol = $oldCell?->symbol ?? ' '; - $oldColor = $oldCell?->color; - - $animationDatabase->setFrameCell($animationIndex, $frameIndex, $cellX, $cellY, $symbol, $color); - - $newCell = $animation->getFrame($frameIndex)->getCellAt($cellX, $cellY); - - if (($newCell?->symbol ?? ' ') !== $oldSymbol || ($newCell?->color) !== $oldColor) { - $this->recordCommand(new GenericCommand( - 'Frame paint', - static fn() => $animationDatabase->setFrameCell($animationIndex, $frameIndex, $cellX, $cellY, $symbol, $color), - static fn() => $animationDatabase->setFrameCell($animationIndex, $frameIndex, $cellX, $cellY, $oldSymbol, $oldColor), - )); - } - - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; - $this->statusMessage = sprintf('Animation frame #%03d updated.', $this->databaseSelectedFrameIndex); - $this->renderDatabasePanes($refreshSettings ? ['settings', 'preview'] : ['preview']); + return $this->terminal->probeSize(); } /** - * Plays the selected animation inside the Database preview pane. + * Resolves the current editor layout metrics. * - * @return void + * @return array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} */ - private function playDatabaseAnimationPreview(): void + private function resolveLayout(): array { - $animation = $this->getSelectedAnimation(); + $size = $this->lastTerminalSize ?? $this->getTerminalSize(); - if (! $animation instanceof Animation) { - return; + if ($this->cachedLayout !== null && $this->cachedLayoutSize === $size) { + return $this->cachedLayout; } - if ($this->isDatabasePreviewPlaying) { - // Shift+P toggles: a second press stops the running preview. - $this->stopDatabaseAnimationPreview('Preview stopped.'); - return; - } + $width = max(80, $size['width']); + $height = max(24, $size['height']); + $leftWidth = 32; + $rightWidth = 34; + $gutter = 1; + $centerWidth = max(30, $width - $leftWidth - $rightWidth - ($gutter * 4)); + // The shell is three rows of header, a gutter, the content, a gutter + // and a four-row status window. Taking one row too few for that put + // the status window's last row past the bottom of the terminal, so a + // message landed on the footer instead of in the window. + $contentHeight = max(10, $height - 9); + + $this->cachedLayoutSize = $size; - $this->isDatabasePreviewPlaying = true; - $this->databasePlaybackFrameIndex = 1; - $this->databasePlaybackNextFrameAt = microtime(true); - $this->statusMessage = sprintf('Playing %s.', $animation->name); - $this->renderDatabasePanes(['preview']); + return $this->cachedLayout = [ + 'width' => $width, + 'height' => $height, + 'leftWidth' => $leftWidth, + 'rightWidth' => $rightWidth, + 'gutter' => $gutter, + 'centerWidth' => $centerWidth, + 'contentHeight' => $contentHeight, + ]; } /** - * Advances the non-blocking animation preview from the frame loop. - * - * Playback is a state ticked from update() rather than a blocking call so - * the editor keeps accepting input while an animation plays. + * Keeps the selected asset index within the current map list. * - * @return void + * @param int $index The index to clamp. + * @return int */ - private function tickDatabaseAnimationPreview(): void + private function clampSelection(int $index): int { - if (! $this->isDatabasePreviewPlaying) { - return; + if (! $this->workspace instanceof ProjectWorkspace || $this->workspace->mapIds === []) { + return 0; } - $animation = $this->getSelectedAnimation(); + return max(0, min(count($this->workspace->mapIds) - 1, $index)); + } - if (! $animation instanceof Animation) { - $this->stopDatabaseAnimationPreview('Preview stopped.'); + /** + * Clamps the current canvas offsets against the selected map dimensions. + * + * @return void + */ + private function clampCanvasOffsets(): void + { + if (! $this->workspace instanceof ProjectWorkspace) { + $this->canvasOffsetX = 0; + $this->canvasOffsetY = 0; return; } - $now = microtime(true); - - if ($now < $this->databasePlaybackNextFrameAt) { - return; - } + $selectedMap = $this->workspace->getMapByIndex($this->selectedAssetIndex); - if ($this->databasePlaybackFrameIndex > $animation->maxFrames) { - $this->stopDatabaseAnimationPreview('Preview complete.'); + if (! $selectedMap instanceof ProjectMap) { + $this->canvasOffsetX = 0; + $this->canvasOffsetY = 0; return; } - $cue = $animation->getCue($this->databasePlaybackFrameIndex); - $this->databasePlaybackFlashColor = $cue?->flashColor; - $this->renderDatabasePanes(['preview']); - $this->databasePlaybackFrameIndex++; - $this->databasePlaybackNextFrameAt = $now + self::PREVIEW_SECONDS_PER_FRAME; + $bounds = $this->getCanvasPreviewBounds(); + $viewportWidth = $bounds['width']; + $viewportHeight = $bounds['height']; + $this->canvasOffsetX = max(0, min(max(0, $selectedMap->getWidth() - $viewportWidth), $this->canvasOffsetX)); + $this->canvasOffsetY = max(0, min(max(0, $selectedMap->getHeight() - $viewportHeight), $this->canvasOffsetY)); } /** - * Ends the animation preview and restores the selected frame. + * Clamps the current editing cursor against the selected map. * - * @param string $statusMessage The status line to show. * @return void */ - private function stopDatabaseAnimationPreview(string $statusMessage): void + private function clampCursor(): void { - $this->isDatabasePreviewPlaying = false; - $this->databasePlaybackFlashColor = null; - $this->databasePlaybackFrameIndex = $this->databaseSelectedFrameIndex; - $this->statusMessage = $statusMessage; - $this->renderDatabasePanes(['preview']); - } + if (! $this->workspace instanceof ProjectWorkspace) { + $this->cursorX = 0; + $this->cursorY = 0; + return; + } - /** - * Centers the database preview cursor on the current animation target. - * - * @return void - */ - private function centerDatabasePreviewCursor(): void - { - $animation = $this->getSelectedAnimation(); - $previewSize = $this->getDatabasePreviewSize(); + $selectedMap = $this->workspace->getMapByIndex($this->selectedAssetIndex); - if (! $animation instanceof Animation) { - $this->databasePreviewCursorX = intdiv($previewSize['width'], 2); - $this->databasePreviewCursorY = intdiv($previewSize['height'], 2); + if (! $selectedMap instanceof ProjectMap) { + $this->cursorX = 0; + $this->cursorY = 0; return; } - $origin = AnimationPreviewRenderer::resolveOrigin($animation->position, $previewSize['width'], $previewSize['height']); - $this->databasePreviewCursorX = $origin['x']; - $this->databasePreviewCursorY = $origin['y']; + $this->cursorX = max(0, min(max(0, $selectedMap->getWidth() - 1), $this->cursorX)); + $this->cursorY = max(0, min(max(0, $selectedMap->getHeight() - 1), $this->cursorY)); } /** - * Returns the size of the database preview grid. + * Keeps the current cursor inside the visible viewport. * - * @return array{width: int, height: int} + * @return void */ - private function getDatabasePreviewSize(): array + private function syncViewportToCursor(): void { - $layout = $this->resolveDatabaseLayout($this->resolveLayout()); - - return [ - 'width' => max(10, $layout['previewWidth'] - 2 - (self::WINDOW_HORIZONTAL_PADDING * 2)), - 'height' => max(6, $layout['previewHeight'] - 2), - ]; - } + $bounds = $this->getCanvasPreviewBounds(); + $viewportWidth = $bounds['width']; + $viewportHeight = $bounds['height']; - /** - * Normalizes a paint symbol down to one visible grapheme. - * - * @param string $symbol The raw symbol. - * @return string - */ - private function normalizeDatabaseSymbol(string $symbol): string - { - if (trim($symbol) === '') { - return ' '; + if ($this->cursorX < $this->canvasOffsetX) { + $this->canvasOffsetX = $this->cursorX; + } elseif ($this->cursorX >= $this->canvasOffsetX + $viewportWidth) { + $this->canvasOffsetX = $this->cursorX - $viewportWidth + 1; } - if (preg_match('/^\X/u', $symbol, $matches) !== 1) { - return ' '; + if ($this->cursorY < $this->canvasOffsetY) { + $this->canvasOffsetY = $this->cursorY; + } elseif ($this->cursorY >= $this->canvasOffsetY + $viewportHeight) { + $this->canvasOffsetY = $this->cursorY - $viewportHeight + 1; } - - return $matches[0]; } /** - * Normalizes a stored animation color. + * Pads or truncates a set of lines to the available content height. * - * @param string|null $color The raw color value. - * @return string|null + * @param string[] $lines The lines to fit. + * @param int $availableWidth The available content width. + * @param int $availableLines The available content height. + * @return string[] */ - private function normalizeDatabaseColor(?string $color): ?string + private function fitLines(array $lines, int $availableWidth, int $availableLines): array { - $normalized = strtolower(trim((string) $color)); - - if ($normalized === '' || $normalized === 'none') { - return null; - } + // ANSI-aware: styled lines (the coloured canvas preview) measure by + // visible width, so escape bytes never eat real content. + $lines = array_map( + static fn(string $line): string => EditorWindow::truncateToWidth($line, $availableWidth), + array_slice($lines, 0, $availableLines) + ); - return $normalized; + return array_pad($lines, $availableLines, ''); } /** - * Builds the map's runtime metadata rows: the music it plays and the - * random encounters it offers. + * Word-wraps lines to the given display width, keeping blank lines. * - * Both are read from what the map actually holds. An absent rate or tile - * mode is shown as the engine's default in parentheses and is not - * written until an author sets one, so opening a map never puts a - * default into a file. + * Words longer than the width are split rather than dropped, so no text + * silently vanishes on a narrow terminal. * - * @return array> + * @param string[] $lines The lines to wrap. + * @param int $availableWidth The display width to wrap at. + * @return string[] */ - private function mapRuntimeFields(ProjectMap $map): array + private function wrapLines(array $lines, int $availableWidth): array { - $bgm = $map->getMapDataField(['bgm']); - $bgm = is_string($bgm) ? $bgm : ''; - $known = $this->referenceCatalog()->valuesFor('bgm'); - $missing = $bgm !== '' && ! in_array($bgm, $known, true); - $fields = [ - [ - 'label' => 'Audio', - 'value' => '', - 'editable' => false, - ], - [ - // A track is chosen from what the project has, never spelled. - // A track the project no longer has stays visible and says - // so, rather than being quietly swapped for a valid one. - 'label' => ' Background Music', - 'value' => match (true) { - $bgm === '' => self::MAP_BGM_NONE, - $missing => $bgm . ' · not in assets/Audio/BGM', - default => $bgm, - }, - 'reference' => 'bgm', - 'target' => 'map-data', - 'path' => ['bgm'], - 'field' => 'bgm', - ], - ]; + $availableWidth = max(1, $availableWidth); + $wrapped = []; - $fields = [...$fields, ...$this->mapBgmVariantFields($map, $known)]; + foreach ($lines as $line) { + if (mb_strwidth($line) <= $availableWidth) { + $wrapped[] = $line; - $encounters = MapEncounters::fromMap($map); - $fields[] = [ - 'label' => 'Encounters', - 'value' => $encounters->summary(), - 'editable' => false, - ]; + continue; + } - if (! $encounters->isSupported()) { - $fields[] = [ - 'label' => ' ! Read-only', - 'value' => (string) $encounters->unsupportedReason(), - 'editable' => false, - ]; + $current = ''; - return $fields; - } + foreach (explode(' ', $line) as $word) { + while (mb_strwidth($word) > $availableWidth) { + if ($current !== '') { + $wrapped[] = $current; + $current = ''; + } - $rows = $encounters->rows(); - $fields[] = [ - // The Inspector's own list heading, so Shift+O and Del mean here - // what they mean on every other list in the pane. - 'label' => sprintf(' Troops · %d', count($rows)), - 'value' => '', - 'editable' => false, - 'target' => 'map-encounters', - 'encounterList' => ['index' => max(0, count($rows) - 1)], - ]; + $head = mb_strimwidth($word, 0, $availableWidth, ''); + $wrapped[] = $head; + $word = mb_substr($word, mb_strlen($head)); + } - foreach ($rows as $index => $row) { - $weight = $row['weight']; - $fields[] = [ - 'label' => ' Troop', - 'value' => $row['name'], - 'reference' => 'troops', - 'target' => 'map-encounters', - 'field' => 'troop', - 'index' => $index, - 'encounterList' => ['index' => $index], - ]; - $usable = is_numeric($weight) && (int) $weight >= 1; - $fields[] = [ - 'label' => ' Weight', - 'value' => (is_scalar($weight) ? (string) $weight : '') . ($usable ? '' : ' · the engine drops a troop it cannot weigh'), - 'control' => new InputControl(InputControlType::INTEGER, $usable ? (string) (int) $weight : '1'), - 'target' => 'map-encounters', - 'field' => 'weight', - 'index' => $index, - 'encounterList' => ['index' => $index], - ]; - } + $candidate = $current === '' ? $word : $current . ' ' . $word; - if ($rows === []) { - return $fields; - } + if (mb_strwidth($candidate) > $availableWidth) { + $wrapped[] = $current; + $current = $word; + } else { + $current = $candidate; + } + } - $rate = $encounters->authoredRate(); - $fields[] = [ - 'label' => ' Rate', - 'value' => $rate === null ? sprintf('(engine default: %d)', MapEncounters::DEFAULT_RATE) : (string) $rate, - 'control' => new InputControl(InputControlType::INTEGER, (string) ($rate ?? MapEncounters::DEFAULT_RATE)), - 'target' => 'map-encounters', - 'field' => 'rate', - ]; - $tiles = $encounters->authoredTiles(); - $fields[] = [ - 'label' => ' Tiles', - 'value' => $tiles === null ? sprintf('(engine default: %s)', MapEncounters::DEFAULT_TILES) : $tiles, - 'options' => MapEncounters::TILE_MODES, - 'target' => 'map-encounters', - 'field' => 'tiles', - ]; + if ($current !== '') { + $wrapped[] = $current; + } + } - return $fields; + return $wrapped; } /** - * Builds the ordered Music Variants rows: the map's conditional music, - * evaluated by the engine in declaration order, first match wins. + * Resolves the usable content width inside a padded editor window. * - * @param string[] $known The project's BGM tracks. - * @return array> + * @param int $windowWidth The full window width including borders. + * @return int */ - private function mapBgmVariantFields(ProjectMap $map, array $known): array + private function getWindowContentWidth(int $windowWidth): int { - $variants = MapBgmVariants::fromMap($map); - $fields = [ - [ - 'label' => ' Music Variants', - 'value' => $variants->summary(), - 'editable' => false, - 'target' => 'map-bgm-variants', - 'bgmVariantList' => ['index' => max(0, $variants->count() - 1)], - ], - ]; - - if (! $variants->isSupported()) { - $fields[] = [ - 'label' => ' ! Read-only', - 'value' => (string) $variants->unsupportedReason(), - 'editable' => false, - ]; - - return $fields; - } - - foreach ($variants->rows() as $index => $row) { - $track = $variants->trackAt($index); - $raw = $variants->rawTrackAt($index); - $fields[] = [ - 'label' => sprintf(' Variant %d Track', $index + 1), - 'value' => match (true) { - $track === null && $raw !== null => var_export($raw, true) . ' · not a track name', - $track === null || $track === '' => '(no track yet)', - ! in_array($track, $known, true) => $track . ' · not in assets/Audio/BGM', - default => $track, - }, - 'reference' => 'bgm', - 'target' => 'map-bgm-variants', - 'field' => 'track', - 'index' => $index, - 'bgmVariantList' => ['index' => $index], - ]; - - $issue = $variants->conditionsIssueAt($index); - $conditions = $variants->conditionsAt($index); - $fields[] = [ - 'label' => sprintf(' Variant %d When', $index + 1), - 'value' => $issue !== null - ? '! ' . $issue - : ($conditions === [] ? '(always · shadows later variants)' : ConditionCodec::encodeAll($conditions)), - 'target' => 'map-bgm-variants', - 'field' => 'conditions', - 'index' => $index, - 'bgmVariantList' => ['index' => $index], - 'editable' => $issue === null, - 'mapConditions' => $issue === null, - ]; - } - - return $fields; + return max(1, $windowWidth - 2 - (self::WINDOW_HORIZONTAL_PADDING * 2)); } /** - * Writes one variant field through the variants model, which owns the - * list's shape and preserves every key it does not edit. + * Resolves the current pane color. * - * @param array $field The inspector field descriptor. + * @param string|null $pane The pane identifier to compare against focus. + * @return Color */ - private function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $value): void + private function resolvePaneColor(?string $pane = null): Color { - $variants = MapBgmVariants::fromMap($map); - - if (! $variants->isSupported()) { - $this->setStatus( - sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), - StatusLevel::WARN, - ); - - return; - } - - $index = (int) ($field['index'] ?? 0); - $block = match ((string) ($field['field'] ?? '')) { - 'track' => $variants->withTrackAt($index, (string) $value), - 'conditions' => is_array($value) ? $variants->withConditionsAt($index, $value) : null, - default => null, - }; - - if ($block === null) { - return; + if ($pane !== null && $this->focusedPane === $pane) { + // The focused pane wears the game's own menu selection color, so + // the editor reads as part of the product it builds. + return $this->theme->selectionColor; } - $this->applyMapDataValue($map, [MapBgmVariants::KEY], $block, sprintf('Music variant %d', $index + 1)); + return Color::WHITE; } /** - * Returns the variant row the inspector cursor is inside, or null. + * Adopts the opened project's border pack and selection color. * - * @return array{map: ProjectMap, variants: MapBgmVariants, index: int}|null + * @return void */ - private function selectedMapBgmVariantRow(): ?array + private function applyProjectTheme(): void { - $selectedMap = $this->getSelectedMap(); - $fields = $this->getInspectorFields(); - $field = $fields[$this->selectedInspectorFieldIndex] ?? null; - - if (! $selectedMap instanceof ProjectMap - || ! is_array($field) - || ! is_array($field['bgmVariantList'] ?? null)) { - return null; - } - - return [ - 'map' => $selectedMap, - 'variants' => MapBgmVariants::fromMap($selectedMap), - 'index' => (int) ($field['bgmVariantList']['index'] ?? 0), - ]; - } + $this->theme = EditorTheme::fromProject($this->projectRoot); + EditorWindow::useBorderPack($this->theme->borderPack); + } /** - * Appends a visibly incomplete music variant, when the cursor is on the - * variants list. + * Renders the live editing cursor when the canvas has focus. * - * @return bool True when the key meant this list. + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function addMapBgmVariant(): bool + private function renderCanvasCursor(array $layout): void { - $context = $this->selectedMapBgmVariantRow(); - - if ($context === null) { - return false; + if ( + $this->focusedPane !== self::FOCUS_CANVAS || + $this->isCharacterMapOpen || + $this->isDeleteConfirmationOpen || + $this->activeMousePaintButton !== null + ) { + Console::cursor()->hide(); + return; } - $variants = $context['variants']; - - if (! $variants->isSupported()) { - $this->setStatus(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); + $previewRow = $this->cursorY - $this->canvasOffsetY; + $previewColumn = $this->cursorX - $this->canvasOffsetX; + $bounds = $this->getCanvasPreviewBounds($layout); + $previewHeight = $bounds['height']; + $previewWidth = $bounds['width']; - return true; + if ($previewRow < 0 || $previewRow >= $previewHeight || $previewColumn < 0 || $previewColumn >= $previewWidth) { + Console::cursor()->hide(); + return; } - $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $variants->withVariantAdded(), 'Music variant add'); - $this->setStatus(sprintf('Added variant %d. Pick its track; it stays inactive until one is chosen.', $variants->count() + 1), StatusLevel::SUCCESS); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); - - return true; + Console::cursor()->show(); + Console::cursor()->moveTo( + $bounds['left'] + $previewColumn, + $bounds['top'] + $previewRow + ); } /** - * Removes the music variant the cursor is inside; removing the last one - * takes the bgmVariants key with it. + * Renders the character-map overlay. * - * @return bool True when the key meant this list. + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function removeMapBgmVariant(): bool + private function renderCharacterMapOverlay(array $layout): void { - $context = $this->selectedMapBgmVariantRow(); - - if ($context === null) { - return false; - } + $palette = $this->getCharacterPalette(); + $rows = []; - $variants = $context['variants']; + foreach (array_chunk($palette, self::CHARACTER_MAP_COLUMNS) as $rowIndex => $rowSymbols) { + $cells = []; - if (! $variants->isSupported()) { - $this->setStatus(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); + foreach ($rowSymbols as $columnIndex => $symbol) { + $index = ($rowIndex * self::CHARACTER_MAP_COLUMNS) + $columnIndex; + $label = $index === $this->characterPaletteIndex + ? sprintf('[%s]', $symbol === ' ' ? '␠' : $symbol) + : sprintf(' %s ', $symbol === ' ' ? '␠' : $symbol); + $cells[] = $label; + } - return true; + $rows[] = implode(' ', $cells); } - if ($variants->count() === 0) { - $this->setStatus('There is no music variant here to remove.', StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } + $overlayWidth = min(max(24, $layout['centerWidth'] - 6), max(24, $layout['width'] - 10)); + $overlayHeight = min(max(5, count($rows) + 2), max(5, $layout['height'] - 8)); + $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); + $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $variants->withVariantRemovedAt($context['index']), 'Music variant remove'); - $this->setStatus(sprintf('Removed variant %d.', $context['index'] + 1), StatusLevel::SUCCESS); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); + $window = new EditorWindow( + title: 'Character Map', + help: 'Enter:Insert Esc:Close', + position: ['x' => $left, 'y' => $top], + width: $overlayWidth, + height: $overlayHeight, + foregroundColor: Color::LIGHT_BLUE, + content: $this->fitLines($rows, max(1, $overlayWidth - 2), max(1, $overlayHeight - 2)), + ); - return true; + $window->render(); } /** - * Moves the music variant the cursor is inside up or down, because - * declaration order is runtime behavior: the first match wins. + * Renders the brush colour picker overlay. + * + * Each colour renders its own swatch in the actual 4-bit ANSI colour, + * so the palette previews exactly what the terminal will show. + * + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function moveMapBgmVariant(int $step): void + private function renderColorPickerOverlay(array $layout): void { - $context = $this->selectedMapBgmVariantRow(); - - if ($context === null) { - return; - } - - $variants = $context['variants']; - - if (! $variants->isSupported()) { - $this->setStatus(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); - - return; - } - - $from = $context['index']; - $block = $variants->withVariantMovedTo($from, $from + $step); + $sgr = [ + 'black' => 30, 'red' => 31, 'green' => 32, 'yellow' => 33, + 'blue' => 34, 'magenta' => 35, 'cyan' => 36, 'white' => 37, + 'gray' => 90, 'bright-red' => 91, 'bright-green' => 92, + 'bright-yellow' => 93, 'bright-blue' => 94, 'bright-magenta' => 95, + 'bright-cyan' => 96, 'bright-white' => 97, + ]; + $rows = []; - if ($block === null) { - return; + foreach ($this->getColorPalette() as $index => $entry) { + $swatch = is_string($entry['value']) && isset($sgr[$entry['value']]) + ? sprintf("\033[%dm■\033[0m", $sgr[$entry['value']]) + : '·'; + $rows[] = sprintf( + '%s %s %s', + $index === $this->colorPaletteIndex ? '>' : ' ', + $swatch, + $entry['label'], + ); } - $this->applyMapDataValue($context['map'], [MapBgmVariants::KEY], $block, 'Music variant move'); + $overlayWidth = 40; + $overlayHeight = min(count($rows) + 2, max(5, $layout['height'] - 6)); + $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); + $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - // The cursor follows the variant it was on. - $fields = $this->getInspectorFields(); + // Keep the selection visible when the terminal is too short for + // every row at once. + $visibleRows = max(1, $overlayHeight - 2); + $firstRow = max(0, min($this->colorPaletteIndex - intdiv($visibleRows, 2), count($rows) - $visibleRows)); - foreach ($fields as $fieldIndex => $field) { - if (($field['target'] ?? null) === 'map-bgm-variants' - && ($field['field'] ?? null) === 'track' - && ($field['index'] ?? null) === $from + $step) { - $this->selectedInspectorFieldIndex = $fieldIndex; - break; - } - } + $window = new EditorWindow( + title: 'Brush Colour', + help: 'Enter:Select Esc:Close', + position: ['x' => $left, 'y' => $top], + width: $overlayWidth, + height: $overlayHeight, + foregroundColor: Color::LIGHT_BLUE, + content: array_pad(array_slice($rows, $firstRow, $visibleRows), $visibleRows, ''), + ); - $this->setStatus(sprintf('Moved variant %s. The first matching variant wins.', $step < 0 ? 'up' : 'down'), StatusLevel::SUCCESS); - $this->renderSelectionDependentArea(); + $window->render(); } /** - * Opens the shared Condition editor on a music variant's conditions, - * hosted in the Inspector pane. + * Renders the delete confirmation overlay. * - * @param array $field The inspector field descriptor. + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function openMapVariantConditionEditor(array $field): void + private function renderDeleteConfirmationOverlay(array $layout): void { $selectedMap = $this->getSelectedMap(); + $mapLabel = $selectedMap instanceof ProjectMap ? $selectedMap->mapId : 'the selected map'; + $rows = [ + sprintf('Delete %s?', $mapLabel), + 'This removes the map folder and all associated split files.', + '', + 'Y: Delete', + 'Enter/Esc/N: Cancel (default)', + ]; + $overlayWidth = min(max(48, mb_strwidth($mapLabel) + 22), max(48, $layout['width'] - 10)); + $overlayHeight = 7; + $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); + $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - if (! $selectedMap instanceof ProjectMap) { - return; - } - - $variants = MapBgmVariants::fromMap($selectedMap); - $index = (int) ($field['index'] ?? 0); - - $this->mapConditionField = $field; - $this->conditionEditor->open( - (string) ($field['field'] ?? 'conditions'), - sprintf('Variant %d Conditions', $index + 1), - $variants->conditionsAt($index), + $window = new EditorWindow( + title: 'Delete Map', + help: 'Y:Delete Esc:Cancel', + position: ['x' => $left, 'y' => $top], + width: $overlayWidth, + height: $overlayHeight, + foregroundColor: Color::LIGHT_BLUE, + content: $this->fitLines($rows, $this->getWindowContentWidth($overlayWidth), max(1, $overlayHeight - 2)), ); - $this->statusMessage = 'Building conditions. An empty list always matches.'; - $this->renderInspectorArea(); + + $window->render(); } /** - * Builds the inspector field list for the current selection. + * Renders the Database entry delete confirmation. * - * @return array> + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function getInspectorFields(): array + private function renderDatabaseEntryDeleteConfirmationOverlay(array $layout): void { - $selectedMap = $this->getSelectedMap(); - - if (! $selectedMap instanceof ProjectMap) { - return []; - } + $pending = $this->pendingDatabaseDeletion; + $label = is_array($pending) ? $pending['label'] : 'the selected entry'; + $category = is_array($pending) ? $pending['category'] : 'entry'; + $rows = [ + sprintf('Delete %s from %s?', $label, $category), + 'The entry leaves the editor now; the file changes on the next save.', + 'Ctrl+Z restores it before then.', + '', + 'Y: Delete', + 'Enter/Esc/N: Cancel (default)', + ]; + $overlayWidth = min(max(56, mb_strwidth($label) + 30), max(56, $layout['width'] - 10)); + $overlayHeight = 8; - if ($this->isNpcInspectorHosting()) { - if ($this->selectedNpcIndex === null) { - return [[ - 'label' => 'NPCs · ' . $selectedMap->getNpcs()->count(), - 'value' => 'Enter on the canvas to select or create one.', - 'editable' => false, - ]]; - } + $window = new EditorWindow( + title: 'Delete Entry', + help: 'Y:Delete Esc:Cancel', + position: [ + 'x' => max(2, intdiv($layout['width'] - $overlayWidth, 2)), + 'y' => max(2, intdiv($layout['height'] - $overlayHeight, 2)), + ], + width: $overlayWidth, + height: $overlayHeight, + foregroundColor: Color::LIGHT_BLUE, + content: $this->fitLines($rows, $this->getWindowContentWidth($overlayWidth), max(1, $overlayHeight - 2)), + ); - return $this->getDatabaseSettingsFields(); - } + $window->render(); + } - $fields = [ - [ - 'label' => 'Name', - 'value' => $selectedMap->getDisplayName(), - 'control' => new InputControl(InputControlType::TEXT, $selectedMap->getDisplayName()), - 'target' => 'map', - 'field' => 'name', - ], - [ - 'label' => 'Region', - 'value' => $selectedMap->getRegion(), - 'control' => new InputControl(InputControlType::TEXT, $selectedMap->getRegion()), - 'target' => 'map', - 'field' => 'region', - ], - [ - 'label' => 'Description', - 'value' => $selectedMap->getDescription(), - 'control' => new InputControl(InputControlType::TEXT, $selectedMap->getDescription()), - 'target' => 'map', - 'field' => 'description', - ], - [ - 'label' => 'Size', - 'value' => '', - 'editable' => false, - ], - [ - 'label' => ' X', - 'value' => (string) $selectedMap->getWidth(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $selectedMap->getWidth()), - 'target' => 'map-size', - 'field' => 'width', - ], - [ - 'label' => ' Y', - 'value' => (string) $selectedMap->getHeight(), - 'control' => new InputControl(InputControlType::INTEGER, (string) $selectedMap->getHeight()), - 'target' => 'map-size', - 'field' => 'height', - ], - [ - 'label' => 'Events', - 'value' => (string) $selectedMap->getEventDefinitionCount(), - 'editable' => false, - ], - [ - 'label' => 'Triggers', - 'value' => (string) $selectedMap->getTriggerCount(), - 'editable' => false, - ], - ...$this->mapRuntimeFields($selectedMap), - ]; - - if ($this->editingMode !== self::MODE_EVENT) { - return $fields; - } - - $marker = $this->getFocusedEventMarker(); + private function renderLootDialogOverlay(array $layout): void + { + $entries = $this->lootDialogEntries; + $selectedEntry = $entries[$this->selectedLootIndex] ?? null; + $contentWidth = min(68, max(48, $layout['width'] - 12)); + $contentHeight = min(max(12, count($entries) + 7), max(12, $layout['height'] - 8)); + $availableRows = max(1, $contentHeight - 2); + $listRows = max(1, $availableRows - 4); + $rows = $this->buildLootDialogRows($entries, $listRows); + $left = max(2, intdiv($layout['width'] - $contentWidth, 2)); + $top = max(2, intdiv($layout['height'] - $contentHeight, 2)); - if ($marker === null) { - $fields[] = [ - 'label' => 'Event', - 'value' => 'Move cursor onto an event marker', - 'editable' => false, - ]; - return $fields; + if (is_array($selectedEntry)) { + $rows[] = ''; + $rows[] = sprintf('Name: %s', $selectedEntry['name']); + $rows[] = sprintf('Type: %s', $selectedEntry['type']); + $rows[] = $selectedEntry['description']; } - $definition = $selectedMap->getEventDefinition($marker); - $bounds = $selectedMap->getEventBounds($marker); - - $fields[] = [ - 'label' => 'Event', - 'value' => $marker, - 'editable' => false, - ]; - - $fields[] = [ - 'label' => 'Type', - 'value' => $this->resolveEventTypeLabel( - is_array($definition) && is_string($definition['class'] ?? null) - ? $definition['class'] - : null, + $window = new EditorWindow( + title: $this->getLootTypeLabel($this->lootDialogType ?? LootType::ITEM, plural: true) . $this->dialogFilter->describe(), + help: 'Enter:Select /:Filter Esc:Cancel', + position: ['x' => $left, 'y' => $top], + width: $contentWidth, + height: $contentHeight, + foregroundColor: Color::LIGHT_BLUE, + content: $this->fitLines( + $rows, + $this->getWindowContentWidth($contentWidth), + $availableRows, ), - 'editable' => true, - 'target' => 'event-type', - 'marker' => $marker, - ]; - - if ($bounds !== null) { - $fields[] = [ - 'label' => 'Position', - 'value' => '', - 'editable' => false, - ]; - $fields[] = [ - 'label' => ' X', - 'value' => (string) $bounds['x'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['x']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'x', - ]; - $fields[] = [ - 'label' => ' Y', - 'value' => (string) $bounds['y'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['y']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'y', - ]; - $fields[] = [ - 'label' => 'Size', - 'value' => '', - 'editable' => false, - ]; - $fields[] = [ - 'label' => ' X', - 'value' => (string) $bounds['width'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['width']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'width', - ]; - $fields[] = [ - 'label' => ' Y', - 'value' => (string) $bounds['height'], - 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds['height']), - 'target' => 'event-bounds', - 'marker' => $marker, - 'field' => 'height', - ]; - } - - if ($definition !== null) { - $fields = [...$fields, ...$this->buildEventDataFields($marker, $definition)]; - } + ); - return $fields; + $window->render(); } - /** - * Builds editable inspector fields for event data. - * - * @param string $marker The event marker. - * @param array $definition The event definition. - * @return array> - */ - private function buildEventDataFields(string $marker, array $definition): array + private function renderEventOptionDialogOverlay(array $layout): void { - $fields = []; - $eventData = $definition['data'] ?? []; + $entries = $this->eventOptionDialogEntries; + $selectedEntry = $entries[$this->selectedEventOptionIndex] ?? null; + $contentWidth = min(60, max(40, $layout["width"] - 14)); + $contentHeight = min(max(9, count($entries) + 6), max(9, $layout["height"] - 10)); + $availableRows = max(1, $contentHeight - 2); + $listRows = max(1, $availableRows - 3); + $rows = $this->buildEventOptionDialogRows($entries, $listRows); + $left = max(2, intdiv($layout["width"] - $contentWidth, 2)); + $top = max(2, intdiv($layout["height"] - $contentHeight, 2)); - if (is_array($eventData)) { - $fields = $this->decorateEventInspectorFields( - $marker, - $this->flattenInspectorFields($eventData, ['data']), - ); + if (is_array($selectedEntry)) { + $rows[] = ""; + $rows[] = (string) ($selectedEntry["description"] ?? ""); } - $rootFields = array_diff_key($definition, ['class' => true, 'data' => true]); - // Cue is a generic trigger capability, including for definitions - // created before the field existed. Supplying an empty editor-only - // default exposes the opt-in without changing the stored event until - // the author actually edits it. - $rootFields['cue'] ??= ['symbol' => '', 'color' => 'bright-yellow']; - - return [ - ...$fields, - ...$this->decorateEventInspectorFields( - $marker, - $this->flattenInspectorFields($rootFields, []), + $window = new EditorWindow( + title: $this->eventOptionDialogTitle . $this->dialogFilter->describe(), + help: "Enter:Select /:Filter Esc:Cancel", + position: ["x" => $left, "y" => $top], + width: $contentWidth, + height: $contentHeight, + foregroundColor: Color::LIGHT_BLUE, + content: $this->fitLines( + $rows, + $this->getWindowContentWidth($contentWidth), + $availableRows, ), - ]; - } - - /** - * Adds event context, pickers, and enum controls to flattened fields. - * - * @param array> $fields The raw fields. - * @return array> - */ - private function decorateEventInspectorFields(string $marker, array $fields): array - { - $decorated = []; - - foreach ($fields as $field) { - $field['marker'] = $marker; - $field['target'] = 'event'; - - $path = array_values((array) ($field['path'] ?? [])); - - if ($path === ['data', 'mode']) { - $field['options'] = ['action', 'auto']; - unset($field['control']); - } - - $reference = $this->resolveEventReferenceField($field); - - if (is_array($reference)) { - // A reference is chosen, never spelled: dropping the control - // is what stops the field being typed into, leaving the - // picker as the only way to set it. - $field['reference'] = $reference['category']; - unset($field['control']); - } - - $decorated[] = $field; - } + ); - return $decorated; + $window->render(); } /** - * Flattens nested scalar data into editable inspector fields. + * Renders the event type picker overlay. * - * @param array $data The data to flatten. - * @param array $path The current path. - * @return array> + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function flattenInspectorFields(array $data, array $path, ?array $list = null): array - { - $fields = []; - - foreach ($data as $key => $value) { - $segment = (string) $key; - $nextPath = [...$path, $segment]; - - if (is_array($value)) { - // A list of entries -- a shop's stock, an event's dialogue -- - // is something an author adds to and removes from, so every - // field inside one remembers which list it belongs to. - if ($this->isInspectorListValue($nextPath, $value)) { - $label = implode(' ', array_map( - static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), - ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath - )); - $fields[] = [ - 'label' => sprintf('%s · %d', $label, count($value)), - 'value' => '', - 'editable' => false, - 'list' => [ - 'path' => $nextPath, - 'index' => max(0, count($value) - 1), - 'blank' => $this->blankInspectorListEntry($nextPath), - ], - ]; - - foreach ($value as $index => $entry) { - $entryPath = [...$nextPath, (string) $index]; - $entryList = ['path' => $nextPath, 'index' => (int) $index]; - - $fields = [ - ...$fields, - ...(is_array($entry) - ? $this->flattenInspectorFields($entry, $entryPath, $entryList) - : $this->flattenInspectorFields([(string) $index => $entry], $nextPath, $entryList)), - ]; - } - - continue; - } + private function renderEventTypeDialogOverlay(array $layout): void + { + $definitions = EventTypeCatalog::all(); + $selectedDefinition = EventTypeCatalog::at($this->selectedEventTypeIndex); + $rows = []; - if (array_key_exists('x', $value) && array_key_exists('y', $value) && is_scalar($value['x']) && is_scalar($value['y'])) { - $label = implode(' ', array_map( - static fn(string $part): string => ucwords(str_replace(['_', '-'], ' ', $part)), - ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath - )); - $fields[] = [ - 'label' => $label, - 'value' => '', - 'editable' => false, - ]; - $fields[] = [ - 'label' => ' X', - 'value' => (string) $value['x'], - 'control' => new InputControl( - is_int($value['x']) ? InputControlType::INTEGER : InputControlType::TEXT, - (string) $value['x'], - ), - 'path' => [...$nextPath, 'x'], - ]; - $fields[] = [ - 'label' => ' Y', - 'value' => (string) $value['y'], - 'control' => new InputControl( - is_int($value['y']) ? InputControlType::INTEGER : InputControlType::TEXT, - (string) $value['y'], - ), - 'path' => [...$nextPath, 'y'], - ]; - continue; - } + foreach ($this->getVisibleEventTypeIndexes() as $index) { + $definition = $definitions[$index] ?? null; - $fields = [...$fields, ...$this->flattenInspectorFields($value, $nextPath, $list)]; + if ($definition === null) { continue; } - if (! is_scalar($value) && $value !== null) { - continue; - } + $rows[] = sprintf( + '%s %s', + $index === $this->selectedEventTypeIndex ? '>' : ' ', + $definition->label, + ); + } - $displayPath = ($nextPath[0] ?? null) === 'data' - ? array_slice($nextPath, 1) - : $nextPath; - $label = implode(' ', array_map( - static fn(string $part): string => ctype_digit($part) - ? '#' . ((int) $part + 1) - : ucwords(str_replace(['_', '-'], ' ', $part)), - $displayPath - )); - $stringValue = match (true) { - is_bool($value) => $value ? 'true' : 'false', - is_float($value) => InputControl::formatFloat($value), - default => (string) $value, - }; - $controlType = match (true) { - is_bool($value) => InputControlType::BOOLEAN, - is_int($value) => InputControlType::INTEGER, - is_float($value) => InputControlType::FLOAT, - default => InputControlType::TEXT, - }; - $leaf = [ - 'label' => $label, - 'value' => $stringValue, - 'control' => new InputControl($controlType, $stringValue), - 'path' => $nextPath, - ]; + $rows[] = ''; + $rows[] = $selectedDefinition->description; - if (is_array($list)) { - $leaf['list'] = $list; - } + $overlayWidth = min(56, max(40, mb_strwidth($selectedDefinition->description) + 6)); + $overlayWidth = min($overlayWidth, max(40, $layout['width'] - 10)); + $overlayHeight = min(max(8, count($rows) + 2), max(8, $layout['height'] - 8)); + $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); + $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - $fields[] = $leaf; - } + $window = new EditorWindow( + title: 'Event Type' . $this->dialogFilter->describe(), + help: 'Enter:Select /:Filter Esc:Cancel', + position: ['x' => $left, 'y' => $top], + width: $overlayWidth, + height: $overlayHeight, + foregroundColor: Color::LIGHT_BLUE, + content: $this->fitLines( + $rows, + $this->getWindowContentWidth($overlayWidth), + max(1, $overlayHeight - 2), + ), + ); - return $fields; + $window->render(); } /** - * Distinguishes authored lists from associative configuration blocks. - * - * Empty arrays need an explicit known-list name because PHP cannot tell - * an empty list from an empty map. + * Renders the destination picker overlay. * - * @param array $path The candidate path. - * @param array $value The candidate value. + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function isInspectorListValue(array $path, array $value): bool + private function renderDestinationDialogOverlay(array $layout): void { - if (! array_is_list($value) || array_key_exists('x', $value)) { - return false; - } + $entries = $this->getDestinationDialogEntries(); + $selectedEntry = $entries[$this->selectedDestinationIndex] ?? null; + $contentWidth = min(64, max(44, $layout['width'] - 12)); + $contentHeight = min(max(10, count($entries) + 5), max(10, $layout['height'] - 8)); + $availableRows = max(1, $contentHeight - 2); + $listRows = max(1, $availableRows - 2); + $rows = $this->buildDestinationDialogRows($entries, $listRows); + $left = max(2, intdiv($layout['width'] - $contentWidth, 2)); + $top = max(2, intdiv($layout['height'] - $contentHeight, 2)); - if ($value !== []) { - return true; + if (is_array($selectedEntry)) { + $rows[] = ''; + $rows[] = sprintf('Map ID: %s', $selectedEntry['mapId']); } - return in_array( - (string) ($path[array_key_last($path)] ?? ''), - ['conditions', 'sets', 'dialogue', 'items', 'script', 'steps', 'options'], - true, + $window = new EditorWindow( + title: 'Destination' . $this->dialogFilter->describe(), + help: 'Enter:Select /:Filter Esc:Cancel', + position: ['x' => $left, 'y' => $top], + width: $contentWidth, + height: $contentHeight, + foregroundColor: Color::LIGHT_BLUE, + content: $this->fitLines( + $rows, + $this->getWindowContentWidth($contentWidth), + $availableRows, + ), ); + + $window->render(); } /** - * Returns the structured first row for an empty inspector list. + * Renders the destination spawn-point instructions overlay. * - * @param array $path The list path. - * @return array + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void */ - private function blankInspectorListEntry(array $path): array + private function renderDestinationSpawnSelectionOverlay(array $layout): void { - return match ((string) ($path[array_key_last($path)] ?? '')) { - 'conditions' => ['type' => 'switch', 'name' => '', 'value' => true], - 'sets' => ['type' => 'switch', 'name' => '', 'value' => true], - 'script' => ['type' => 'text', 'name' => '', 'text' => ''], - 'steps' => ['direction' => 'down', 'count' => 1, 'faceOnly' => false], - 'options' => ['text' => '', 'then' => []], - 'items' => ['item' => '', 'price' => 0], - default => ['name' => '', 'text' => ''], - }; + $mapId = is_array($this->destinationSelectionContext) + ? $this->destinationSelectionContext['destinationMapId'] + : 'destination map'; + $window = new EditorWindow( + title: 'Select Spawn Point', + help: 'Enter:Confirm Esc:Cancel', + position: ['x' => 4, 'y' => 1], + width: min(54, max(40, $layout['width'] - 8)), + height: 5, + foregroundColor: Color::LIGHT_BLUE, + content: [ + sprintf('Choose a spawn point on %s.', $mapId), + sprintf('Current point: (%d, %d)', $this->cursorX, $this->cursorY), + '', + ], + ); + + $window->render(); } /** - * Draws the editor shell. + * Renders the confirmation dialog for the selected spawn point. * + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. * @return void */ - private function render(): void + private function renderDestinationSpawnConfirmationOverlay(array $layout): void { - if (! $this->isRunning) { - return; - } + $mapId = is_array($this->destinationSelectionContext) + ? $this->destinationSelectionContext['destinationMapId'] + : 'destination map'; + $window = new EditorWindow( + title: 'Confirm Spawn Point', + help: 'Enter:Apply Esc:Back', + position: [ + 'x' => max(2, intdiv($layout['width'] - 50, 2)), + 'y' => max(2, intdiv($layout['height'] - 7, 2)), + ], + width: 50, + height: 7, + foregroundColor: Color::LIGHT_BLUE, + content: [ + sprintf('Map: %s', $mapId), + sprintf('Spawn Point: (%d, %d)', $this->cursorX, $this->cursorY), + '', - if ($this->isFullRenderPending) { - $this->isFullRenderPending = false; - $this->clearDirtyRenderState(); - $this->renderFullScreen(); - return; - } + 'Apply this destination and return to the source event?', + '', + ], + ); - $this->flushDirtyPanels(); + $window->render(); } /** - * Repaints exactly the panels and overlays that input handlers marked - * dirty this tick. An idle frame writes zero bytes. + * Returns grouped destination entries for the picker. * - * @return void + * @return array */ - private function flushDirtyPanels(): void + private function getDestinationDialogEntries(): array { if (! $this->workspace instanceof ProjectWorkspace) { - return; + return []; } - if ($this->isDatabaseOpen) { - $this->databaseScreen->flush(); + $entries = []; - if ($this->areOverlaysDirty) { - $this->drawOverlays(); - } + foreach ($this->workspace->maps as $map) { + $entries[] = [ + 'region' => $map->getRegion(), + 'name' => $map->getDisplayName(), + 'mapId' => $map->mapId, + ]; + } - $this->clearDirtyRenderState(); - return; + return $entries; + } + + /** + * Resolves the selected destination index for a current map id. + * + * @param string $currentDestinationMap The currently stored destination map id. + * @return int + */ + private function resolveDestinationSelectionIndex(string $currentDestinationMap): int + { + foreach ($this->getDestinationDialogEntries() as $index => $entry) { + if ($entry['mapId'] === $currentDestinationMap) { + return $index; + } } - if ($this->isCutscenesOpen) { - $this->flushCutscenesScreen(); + return 0; + } - if ($this->areOverlaysDirty) { - $this->drawOverlays(); + /** + * Builds visible destination dialog rows with region headers. + * + * @param array $entries The selectable destinations. + * @param int $availableRows The available content rows. + * @return string[] + */ + private function buildDestinationDialogRows(array $entries, int $availableRows): array + { + $rows = []; + $selectedRowIndex = 0; + $currentRegion = null; + + foreach ($this->getVisibleDestinationIndexes() as $index) { + $entry = $entries[$index] ?? null; + + if (! is_array($entry)) { + continue; } - $this->clearDirtyRenderState(); - return; - } + if ($entry['region'] !== $currentRegion) { + $currentRegion = $entry['region']; + $rows[] = sprintf('[%s]', $currentRegion === '' ? 'Unknown' : $currentRegion); + } - foreach ([$this->assetsPanel, $this->canvasPanel, $this->inspectorPanel] as $panel) { - if ($panel->isDirty()) { - $panel->render(); + if ($index === $this->selectedDestinationIndex) { + $selectedRowIndex = count($rows); } - } - if ($this->workspace->hasUnsavedChanges() !== $this->paintedHeaderUnsaved) { - $this->drawHeader(); + $rows[] = sprintf( + '%s %s', + $index === $this->selectedDestinationIndex ? '>' : ' ', + $entry['name'], + ); } - if ($this->isFooterDirty) { - $this->drawFooter(); + if (count($rows) <= $availableRows) { + return $rows; } - if ($this->areOverlaysDirty) { - $this->drawOverlays(); - } elseif ($this->isCanvasCursorDirty) { - $this->renderCanvasCursor($this->resolveLayout()); - } + $startRow = max(0, min(count($rows) - $availableRows, $selectedRowIndex - intdiv($availableRows, 2))); - $this->clearDirtyRenderState(); + return array_slice($rows, $startRow, $availableRows); } - /** - * Clears every per-panel dirty flag (a full-screen redraw supersedes - * partial repaints). - * - * @return void - */ - private function clearDirtyRenderState(): void + private function loadLootDialogEntries(LootType $lootType): array { - $this->assetsPanel->clearDirty(); - $this->canvasPanel->clearDirty(); - $this->inspectorPanel->clearDirty(); - $this->isFooterDirty = false; - $this->areOverlaysDirty = false; - $this->isCanvasCursorDirty = false; - $this->databaseScreen->clearDirty(); + return match ($lootType) { + LootType::ITEM, + LootType::WEAPON, + LootType::ARMOR, + LootType::ACCESSORY => $this->loadInventoryLootDialogEntries($lootType), + LootType::SKILL, + LootType::SPELL => $this->loadSkillLootDialogEntries($lootType), + default => [], + }; } /** - * Draws the full editor shell. + * Loads inventory-backed loot entries for the requested loot type. * - * @return void + * @param LootType $lootType The loot type being edited. + * @return array */ - private function renderFullScreen(): void + private function loadInventoryLootDialogEntries(LootType $lootType): array { - if (! $this->workspace instanceof ProjectWorkspace) { - throw new RuntimeException('The editor workspace is not loaded.'); + // The items, weapons and armors as their pages hold them, unsaved edits included. + $items = []; + foreach (InventoryCatalog::CATEGORIES as $category) { + foreach ($this->workspace?->getRecordDatabase($category)?->getRecords() ?? [] as $record) { + $items[] = InventoryCatalog::readDefinition($record); + } } - Console::cursor()->hide(); + $entries = []; - if ($this->isDatabaseOpen) { - $this->clearScreen(); - $this->drawDatabaseArea(includeRoot: true); - $this->renderModalOverlays($this->resolveLayout()); - return; - } + foreach ($items as $item) { + if (! is_object($item) || ! isset($item->name) || ! $this->matchesLootInventoryType($item, $lootType)) { + continue; + } - if ($this->isCutscenesOpen) { - $this->clearScreen(); - $this->cutscenesScreen->drawAll(includeRoot: true); - $this->cutscenesScreen->clearDirty(); - $this->renderModalOverlays($this->resolveLayout()); - return; + // What a chest stores is the stable definition id, the identity + // the runtime resolves; the name is what the author reads. + $identity = InventoryCatalog::definitionId($item->id ?? null, (string) $item->name); + + $entries[] = [ + 'name' => (string) $item->name, + 'value' => $identity ?? (string) $item->name, + 'description' => (string) ($item->description ?? ''), + 'icon' => trim((string) ($item->icon ?? '')), + 'type' => $this->getLootTypeLabel($lootType), + ]; } - $this->clearScreen(); - $this->drawHeader(); - $this->createAssetWindow()->render(); - $this->createCanvasWindow()->render(); - $this->createInspectorWindow()->render(); - $this->drawFooter(); + usort($entries, static fn(array $left, array $right): int => strcmp($left['name'], $right['name'])); - $this->drawOverlays(); + return $entries; } /** - * Clears the visible screen with a single escape sequence. - * - * Never shells out: `system("clear")` forks a subprocess (~5ms) on every - * call and is the difference between a repaint and a visible flash. + * Loads ability or spell loot entries from the project's skill catalogue, + * wherever each skill is authored. * - * @return void + * @param LootType $lootType The loot type being edited: abilities for skills, spells for spells. + * @return array */ - private function clearScreen(): void + private function loadSkillLootDialogEntries(LootType $lootType): array { - $this->terminal->clearScreen(); + $catalog = $this->workspace?->loadSkillCatalog() ?? SkillCatalog::load($this->projectRoot . '/assets'); + $skills = $lootType === LootType::SPELL ? $catalog->getSpells() : $catalog->getAbilities(); + $entries = []; + + foreach ($skills as $skill) { + $entries[] = [ + 'name' => $skill->name, + 'description' => $skill->description, + 'icon' => trim($skill->icon), + 'type' => $lootType === LootType::SPELL ? 'Spell' : 'Skill', + ]; + } + + usort($entries, static fn(array $left, array $right): int => strcmp($left['name'], $right['name'])); + + return $entries; } /** - * Returns the live terminal size. + * Returns whether the given inventory object matches the selected loot type. * - * @return array{width: int, height: int} + * @param object $item The inventory object to inspect. + * @param LootType $lootType The loot type being edited. + * @return bool */ - private function getTerminalSize(): array + private function matchesLootInventoryType(object $item, LootType $lootType): bool { - return $this->terminal->probeSize(); + return match ($lootType) { + LootType::ITEM => $item instanceof Item + && ! $item instanceof Weapon + && ! $item instanceof Armor + && ! $item instanceof Accessory, + LootType::WEAPON => $item instanceof Weapon, + LootType::ARMOR => $item instanceof Armor, + LootType::ACCESSORY => $item instanceof Accessory, + default => false, + }; } - /** - * Resolves the current editor layout metrics. - * - * @return array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} - */ - private function resolveLayout(): array + private function resolveLootSelectionIndex(string $currentLoot): int { - $size = $this->lastTerminalSize ?? $this->getTerminalSize(); + $current = mb_strtolower(trim($currentLoot)); - if ($this->cachedLayout !== null && $this->cachedLayoutSize === $size) { - return $this->cachedLayout; + if ($current === '') { + return 0; } - $width = max(80, $size['width']); - $height = max(24, $size['height']); - $leftWidth = 32; - $rightWidth = 34; - $gutter = 1; - $centerWidth = max(30, $width - $leftWidth - $rightWidth - ($gutter * 4)); - // The shell is three rows of header, a gutter, the content, a gutter - // and a four-row status window. Taking one row too few for that put - // the status window's last row past the bottom of the terminal, so a - // message landed on the footer instead of in the window. - $contentHeight = max(10, $height - 9); - - $this->cachedLayoutSize = $size; + // The stored value may be an id, a display name, or a declared + // alias, so it is resolved before it is looked for. A value that + // resolves to nothing leaves the cursor at the top, which is the + // honest answer for a reference nothing answers to. + $resolved = $this->workspace instanceof ProjectWorkspace + ? InventoryCatalog::fromWorkspace($this->workspace)->definitionIdFor($currentLoot) + : null; - return $this->cachedLayout = [ - 'width' => $width, - 'height' => $height, - 'leftWidth' => $leftWidth, - 'rightWidth' => $rightWidth, - 'gutter' => $gutter, - 'centerWidth' => $centerWidth, - 'contentHeight' => $contentHeight, - ]; - } + foreach ($this->lootDialogEntries as $index => $entry) { + $value = mb_strtolower(trim((string) ($entry['value'] ?? $entry['name']))); - /** - * Keeps the selected asset index within the current map list. - * - * @param int $index The index to clamp. - * @return int - */ - private function clampSelection(int $index): int - { - if (! $this->workspace instanceof ProjectWorkspace || $this->workspace->mapIds === []) { - return 0; + if ($value === $current || mb_strtolower(trim($entry['name'])) === $current || ($resolved !== null && $value === $resolved)) { + return $index; + } } - return max(0, min(count($this->workspace->mapIds) - 1, $index)); + return 0; } - /** - * Clamps the current canvas offsets against the selected map dimensions. - * - * @return void - */ - private function clampCanvasOffsets(): void + private function buildLootDialogRows(array $entries, int $availableRows): array { - if (! $this->workspace instanceof ProjectWorkspace) { - $this->canvasOffsetX = 0; - $this->canvasOffsetY = 0; - return; - } + $rows = []; + $selectedRowIndex = 0; + $groupByType = count(array_unique(array_column($entries, 'type'))) > 1; + $currentType = null; - $selectedMap = $this->workspace->getMapByIndex($this->selectedAssetIndex); + foreach ($this->getVisibleLootIndexes() as $index) { + $entry = $entries[$index] ?? null; - if (! $selectedMap instanceof ProjectMap) { - $this->canvasOffsetX = 0; - $this->canvasOffsetY = 0; - return; + if (! is_array($entry)) { + continue; + } + + if ($groupByType && $entry['type'] !== $currentType) { + $currentType = $entry['type']; + $rows[] = sprintf('[%s]', $currentType === '' ? 'Unknown' : $currentType); + } + + if ($index === $this->selectedLootIndex) { + $selectedRowIndex = count($rows); + } + + $prefix = $index === $this->selectedLootIndex ? '>' : ' '; + $icon = $entry['icon'] === '' ? '-' : $entry['icon']; + $rows[] = sprintf('%s %s %s', $prefix, $icon, $entry['name']); } - $layout = $this->resolveLayout(); - $viewportWidth = max(1, $layout['centerWidth'] - 2); - $viewportHeight = max(1, $layout['contentHeight'] - 4); - $this->canvasOffsetX = max(0, min(max(0, $selectedMap->getWidth() - $viewportWidth), $this->canvasOffsetX)); - $this->canvasOffsetY = max(0, min(max(0, $selectedMap->getHeight() - $viewportHeight), $this->canvasOffsetY)); + if (count($rows) <= $availableRows) { + return $rows; + } + + $startRow = max(0, min(count($rows) - $availableRows, $selectedRowIndex - intdiv($availableRows, 2))); + + return array_slice($rows, $startRow, $availableRows); } + /** - * Clamps the current editing cursor against the selected map. + * Writes one value into the map's data as one undo step (see + * {@see MapInspector::writeMapData()}); nothing is recorded when the data + * already held it. * - * @return void + * @param list $path */ - private function clampCursor(): void + private function applyMapDataValue(ProjectMap $map, array $path, mixed $value, string $label): void { - if (! $this->workspace instanceof ProjectWorkspace) { - $this->cursorX = 0; - $this->cursorY = 0; - return; - } - - $selectedMap = $this->workspace->getMapByIndex($this->selectedAssetIndex); + $command = $this->createMapInspector()->writeMapData($map, $path, $value, $label); - if (! $selectedMap instanceof ProjectMap) { - $this->cursorX = 0; - $this->cursorY = 0; - return; + if ($command !== null) { + $this->recordCommand($command); } - - $this->cursorX = max(0, min(max(0, $selectedMap->getWidth() - 1), $this->cursorX)); - $this->cursorY = max(0, min(max(0, $selectedMap->getHeight() - 1), $this->cursorY)); } - /** - * Keeps the current cursor inside the visible viewport. + * Adds an entry to the list the inspector cursor is inside + * ({@see MapInspector::addListEntry()}). * * @return void */ - private function syncViewportToCursor(): void + private function addInspectorListItem(): void { - $layout = $this->resolveLayout(); - $viewportWidth = max(1, $layout['centerWidth'] - 2); - $viewportHeight = max(1, $layout['contentHeight'] - 4); - - if ($this->cursorX < $this->canvasOffsetX) { - $this->canvasOffsetX = $this->cursorX; - } elseif ($this->cursorX >= $this->canvasOffsetX + $viewportWidth) { - $this->canvasOffsetX = $this->cursorX - $viewportWidth + 1; - } - - if ($this->cursorY < $this->canvasOffsetY) { - $this->canvasOffsetY = $this->cursorY; - } elseif ($this->cursorY >= $this->canvasOffsetY + $viewportHeight) { - $this->canvasOffsetY = $this->cursorY - $viewportHeight + 1; - } + $this->editInspectorList( + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->addListEntry($map, $field), + 'Nothing here is a list to add to.', + ); } /** - * Pads or truncates a set of lines to the available content height. + * Removes the entry the inspector cursor is inside + * ({@see MapInspector::removeListEntry()}). * - * @param string[] $lines The lines to fit. - * @param int $availableWidth The available content width. - * @param int $availableLines The available content height. - * @return string[] + * @return void */ - private function fitLines(array $lines, int $availableWidth, int $availableLines): array + private function removeInspectorListItem(): void { - // ANSI-aware: styled lines (the coloured canvas preview) measure by - // visible width, so escape bytes never eat real content. - $lines = array_map( - static fn(string $line): string => EditorWindow::truncateToWidth($line, $availableWidth), - array_slice($lines, 0, $availableLines) + $this->editInspectorList( + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->removeListEntry($map, $field), + 'Nothing here is a list entry to remove.', ); - - return array_pad($lines, $availableLines, ''); } /** - * Word-wraps lines to the given display width, keeping blank lines. + * Applies a list edit to the row the inspector cursor is on, recording + * it and saying what changed. * - * Words longer than the width are split rather than dropped, so no text - * silently vanishes on a narrow terminal. - * - * @param string[] $lines The lines to wrap. - * @param int $availableWidth The display width to wrap at. - * @return string[] + * @param Closure(MapInspector, ProjectMap, array): ?InspectorListEdit $edit + * @param string $nothingHere What to say when there is no row to edit. + * @return void */ - private function wrapLines(array $lines, int $availableWidth): array + private function editInspectorList(Closure $edit, string $nothingHere): void { - $availableWidth = max(1, $availableWidth); - $wrapped = []; - - foreach ($lines as $line) { - if (mb_strwidth($line) <= $availableWidth) { - $wrapped[] = $line; + $selectedMap = $this->getSelectedMap(); + $field = $this->getInspectorFields()[$this->selectedInspectorFieldIndex] ?? null; - continue; - } + if (! $selectedMap instanceof ProjectMap || ! is_array($field)) { + $this->setStatus($nothingHere, StatusLevel::WARN); - $current = ''; + return; + } - foreach (explode(' ', $line) as $word) { - while (mb_strwidth($word) > $availableWidth) { - if ($current !== '') { - $wrapped[] = $current; - $current = ''; - } + try { + $result = $edit($this->createMapInspector(), $selectedMap, $field); + } catch (InspectorRefusal $refusal) { + $this->setStatus($refusal->getMessage(), StatusLevel::WARN); + $this->renderInspectorArea(); - $head = mb_strimwidth($word, 0, $availableWidth, ''); - $wrapped[] = $head; - $word = mb_substr($word, mb_strlen($head)); - } + return; + } catch (Throwable $throwable) { + $this->setErrorStatus($throwable, 'List edit'); + $this->renderInspectorArea(); - $candidate = $current === '' ? $word : $current . ' ' . $word; + return; + } - if (mb_strwidth($candidate) > $availableWidth) { - $wrapped[] = $current; - $current = $word; - } else { - $current = $candidate; - } - } + if ($result === null) { + return; + } - if ($current !== '') { - $wrapped[] = $current; - } + if ($result->command !== null) { + $this->recordCommand($result->command); } - return $wrapped; + $this->setStatus($result->summary, StatusLevel::INFO); + $this->clampInspectorSelection(); + $this->renderSelectionDependentArea(); } - /** - * Resolves the usable content width inside a padded editor window. + * The picker an event data row is chosen from (see + * {@see MapInspector::findEventReference()}). * - * @param int $windowWidth The full window width including borders. - * @return int + * @param array $field + * @return array{category: string, title: string}|null */ - private function getWindowContentWidth(int $windowWidth): int + private function resolveEventReferenceField(array $field): ?array { - return max(1, $windowWidth - 2 - (self::WINDOW_HORIZONTAL_PADDING * 2)); + return MapInspector::findEventReference($field); } - /** - * Resolves the current pane color. + * Opens the picker for an event field that names another resource. * - * @param string|null $pane The pane identifier to compare against focus. - * @return Color + * @param string $marker The event marker. + * @param array $path The path within the event definition. + * @param string $title What to call the picker. + * @param string $category The kind of reference. + * @param string $currentValue What the field is set to now. + * @return void */ - private function resolvePaneColor(?string $pane = null): Color - { - if ($pane !== null && $this->focusedPane === $pane) { - // The focused pane wears the game's own menu selection color, so - // the editor reads as part of the product it builds. - return $this->theme->selectionColor; + private function openEventReferenceDialog( + string $marker, + array $path, + string $title, + string $category, + string $currentValue, + ): void { + if (! $this->workspace instanceof ProjectWorkspace) { + return; } - return Color::WHITE; + $referenceCatalog = $this->referenceCatalog(); + $referenceLabels = $referenceCatalog->labelsFor($category); + $entries = array_map( + static fn(string $value): array => [ + 'label' => $referenceLabels[$value] ?? $value, + 'value' => $value, + 'description' => '', + ], + $referenceCatalog->valuesFor($category), + ); + + if ($entries === []) { + // Nothing to choose from is worth saying. Opening an empty dialog + // reads as a broken editor, and falling through to typing would + // put back the guesswork the picker exists to remove. + $this->setStatus( + sprintf('This project has no %s to choose from.', mb_strtolower($title)), + StatusLevel::WARN + ); + $this->renderFooter(); + + return; + } + + $this->openEventOptionDialog($marker, $path, $title, $entries, $currentValue); } /** - * Adopts the opened project's border pack and selection color. + * Opens the picker for an inspector field that names a project resource + * outside an event: a map's music, a map encounter's troop. * - * @return void + * @param array $field The inspector field descriptor. + * @return bool Whether a picker was opened. */ - private function applyProjectTheme(): void + private function openInspectorReferenceDialog(array $field): bool { - $this->theme = EditorTheme::fromProject($this->projectRoot); - EditorWindow::useBorderPack($this->theme->borderPack); + $category = (string) ($field['reference'] ?? ''); + $target = (string) ($field['target'] ?? ''); + + if ($category === '' || ! in_array($target, ['map', 'map-data', 'map-kind', MapInspector::ROLE_TARGET, MapInspector::PLACE_TYPE_TARGET, MapInspector::SKIT_BACKDROP_TARGET, 'map-encounters', 'map-bgm-variants', EventRecordPane::TARGET], true)) { + return false; + } + + if (! $this->workspace instanceof ProjectWorkspace) { + return false; + } + + $title = trim((string) ($field['label'] ?? 'Reference')); + $catalog = $this->referenceCatalog(); + $labels = $catalog->labelsFor($category); + $entries = array_map( + static fn(string $value): array => [ + 'label' => $labels[$value] ?? $value, + 'value' => $value, + 'description' => '', + ], + $catalog->valuesFor($category), + ); + + if ($category === 'bgm' && $target === 'map-data') { + // Silence is a choice a map can make, and the only way back from + // a track once one is set. A variant is different: an empty + // track is a skipped variant, not silence, so the variant picker + // offers only real tracks and removal un-authors the variant. + array_unshift($entries, [ + 'label' => MapInspector::MAP_BGM_NONE, + 'value' => '', + 'description' => 'The map plays whatever was already playing.', + ]); + } + + if (($field['optional'] ?? false) === true || ($field['allowsNone'] ?? false) === true) { + // Optional data is cleared by choosing nothing, which removes it. + array_unshift($entries, [ + 'label' => 'None', + 'value' => '', + 'description' => 'Unset: the game uses its default.', + ]); + } + + $unset = ($field['optional'] ?? false) === true && (string) ($field['value'] ?? '') === ''; + if ($entries === [] || (($unset || ($category === 'bgm' && $target === 'map-data')) && count($entries) === 1)) { + $this->setStatus( + sprintf('This project has no %s to choose from.', mb_strtolower($title)), + StatusLevel::WARN, + ); + $this->renderFooter(); + + return true; + } + + $this->optionDialogField = $field; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = $title; + $this->eventOptionDialogEntries = $entries; + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex((string) ($field['selectedValue'] ?? $field['value'] ?? '')); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = sprintf('Choose %s.', mb_strtolower($title)); + $this->renderSelectionDependentArea(); + + return true; } /** - * Renders the live editing cursor when the canvas has focus. + * Opens the list a row's value is chosen from, as it describes its choices; the chosen value applies through + * the row like any other inspector edit. * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void + * @param array $field */ - private function renderCanvasCursor(array $layout): void + private function openInspectorChoiceDialog(array $field): void { - if ( - $this->focusedPane !== self::FOCUS_CANVAS || - $this->isCharacterMapOpen || - $this->isDeleteConfirmationOpen || - $this->activeMousePaintButton !== null - ) { - Console::cursor()->hide(); - return; - } - - $previewRow = $this->cursorY - $this->canvasOffsetY; - $previewColumn = $this->cursorX - $this->canvasOffsetX; - $previewHeight = max(1, $layout['contentHeight'] - 4); - $previewWidth = $this->getWindowContentWidth($layout['centerWidth']); + $title = trim((string) ($field['label'] ?? 'Choice')); + $this->optionDialogField = $field; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = $title; + $this->eventOptionDialogEntries = array_values(array_map(static fn(array $choice): array => [ + 'label' => (string) ($choice['label'] ?? $choice['value'] ?? ''), + 'value' => (string) ($choice['value'] ?? ''), + 'description' => (string) ($choice['description'] ?? ''), + ], $field['choices'])); + $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex((string) ($field['value'] ?? '')); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = sprintf('Choose %s.', mb_strtolower($title)); + $this->renderSelectionDependentArea(); + } - if ($previewRow < 0 || $previewRow >= $previewHeight || $previewColumn < 0 || $previewColumn >= $previewWidth) { - Console::cursor()->hide(); - return; - } + private function isChestTypeField(array $field): bool + { + return ($field['target'] ?? null) === 'event' + && (($field['path'] ?? []) === ['data', 'chestType']); + } - $canvasLeft = 2 + $layout['leftWidth'] + $layout['gutter']; - $canvasTop = 5; - Console::cursor()->show(); - Console::cursor()->moveTo( - $canvasLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $previewColumn, - $canvasTop + 3 + $previewRow - ); + private function isLootTypeField(array $field): bool + { + return ($field['target'] ?? null) === 'event' + && (($field['path'] ?? []) === ['data', 'lootType']); } + private function getChestTypeOptionEntries(): array + { + return MapInspector::CHEST_TYPE_CHOICES; + } /** - * Renders the character-map overlay. + * Returns the editor-facing label for a loot type. * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void + * @param LootType $lootType The loot type to label. + * @param bool $plural Whether to return the plural form. + * @return string */ - private function renderCharacterMapOverlay(array $layout): void + private function getLootTypeLabel(LootType $lootType, bool $plural = false): string { - $palette = $this->getCharacterPalette(); - $rows = []; - - foreach (array_chunk($palette, self::CHARACTER_MAP_COLUMNS) as $rowIndex => $rowSymbols) { - $cells = []; + return match ($lootType) { + LootType::ITEM => $plural ? 'Items' : 'Item', + LootType::GOLD => 'Gold', + LootType::EXPERIENCE => 'Experience', + LootType::SKILL => $plural ? 'Skills' : 'Skill', + LootType::SPELL => $plural ? 'Spells' : 'Spell', + LootType::WEAPON => $plural ? 'Weapons' : 'Weapon', + LootType::ARMOR => 'Armor', + LootType::ACCESSORY => $plural ? 'Accessories' : 'Accessory', + }; + } - foreach ($rowSymbols as $columnIndex => $symbol) { - $index = ($rowIndex * self::CHARACTER_MAP_COLUMNS) + $columnIndex; - $label = $index === $this->characterPaletteIndex - ? sprintf('[%s]', $symbol === ' ' ? '␠' : $symbol) - : sprintf(' %s ', $symbol === ' ' ? '␠' : $symbol); - $cells[] = $label; + private function getLootTypeOptionEntries(): array + { + return MapInspector::listLootTypeChoices(); + } + private function resolveEventOptionSelectionIndex(string $currentValue): int + { + foreach ($this->eventOptionDialogEntries as $index => $entry) { + if (($entry["value"] ?? null) === $currentValue) { + return $index; } - - $rows[] = implode(' ', $cells); } - $overlayWidth = min(max(24, $layout['centerWidth'] - 6), max(24, $layout['width'] - 10)); - $overlayHeight = min(max(5, count($rows) + 2), max(5, $layout['height'] - 8)); - $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); - $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - - $window = new EditorWindow( - title: 'Character Map', - help: 'Enter:Insert Esc:Close', - position: ['x' => $left, 'y' => $top], - width: $overlayWidth, - height: $overlayHeight, - foregroundColor: Color::LIGHT_BLUE, - content: $this->fitLines($rows, max(1, $overlayWidth - 2), max(1, $overlayHeight - 2)), - ); - - $window->render(); + return 0; } - /** - * Renders the brush colour picker overlay. - * - * Each colour renders its own swatch in the actual 4-bit ANSI colour, - * so the palette previews exactly what the terminal will show. - * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void - */ - private function renderColorPickerOverlay(array $layout): void + private function buildEventOptionDialogRows(array $entries, int $availableRows): array { - $sgr = [ - 'black' => 30, 'red' => 31, 'green' => 32, 'yellow' => 33, - 'blue' => 34, 'magenta' => 35, 'cyan' => 36, 'white' => 37, - 'gray' => 90, 'bright-red' => 91, 'bright-green' => 92, - 'bright-yellow' => 93, 'bright-blue' => 94, 'bright-magenta' => 95, - 'bright-cyan' => 96, 'bright-white' => 97, - ]; $rows = []; + $selectedRowIndex = 0; + + foreach ($this->getVisibleEventOptionIndexes() as $index) { + $entry = $entries[$index] ?? null; + + if (! is_array($entry)) { + continue; + } + + if ($index === $this->selectedEventOptionIndex) { + $selectedRowIndex = count($rows); + } - foreach ($this->getColorPalette() as $index => $entry) { - $swatch = is_string($entry['value']) && isset($sgr[$entry['value']]) - ? sprintf("\033[%dm■\033[0m", $sgr[$entry['value']]) - : '·'; $rows[] = sprintf( - '%s %s %s', - $index === $this->colorPaletteIndex ? '>' : ' ', - $swatch, - $entry['label'], + "%s %s", + $index === $this->selectedEventOptionIndex ? ">" : " ", + (string) ($entry["label"] ?? ""), ); } - $overlayWidth = 40; - $overlayHeight = min(count($rows) + 2, max(5, $layout['height'] - 6)); - $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); - $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - - // Keep the selection visible when the terminal is too short for - // every row at once. - $visibleRows = max(1, $overlayHeight - 2); - $firstRow = max(0, min($this->colorPaletteIndex - intdiv($visibleRows, 2), count($rows) - $visibleRows)); + if (count($rows) <= $availableRows) { + return $rows; + } - $window = new EditorWindow( - title: 'Brush Colour', - help: 'Enter:Select Esc:Close', - position: ['x' => $left, 'y' => $top], - width: $overlayWidth, - height: $overlayHeight, - foregroundColor: Color::LIGHT_BLUE, - content: array_pad(array_slice($rows, $firstRow, $visibleRows), $visibleRows, ''), - ); + $startRow = max(0, min(count($rows) - $availableRows, $selectedRowIndex - intdiv($availableRows, 2))); - $window->render(); + return array_slice($rows, $startRow, $availableRows); } - /** - * Renders the delete confirmation overlay. + * Resolves a stored trigger class into a user-facing event type label. * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void + * @param string|null $className The stored trigger class name. + * @return string */ - private function renderDeleteConfirmationOverlay(array $layout): void + private function resolveEventTypeLabel(?string $className): string { - $selectedMap = $this->getSelectedMap(); - $mapLabel = $selectedMap instanceof ProjectMap ? $selectedMap->mapId : 'the selected map'; - $rows = [ - sprintf('Delete %s?', $mapLabel), - 'This removes the map folder and all associated split files.', - '', - 'Y: Delete', - 'Enter/Esc/N: Cancel (default)', - ]; - $overlayWidth = min(max(48, mb_strwidth($mapLabel) + 22), max(48, $layout['width'] - 10)); - $overlayHeight = 7; - $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); - $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - - $window = new EditorWindow( - title: 'Delete Map', - help: 'Y:Delete Esc:Cancel', - position: ['x' => $left, 'y' => $top], - width: $overlayWidth, - height: $overlayHeight, - foregroundColor: Color::LIGHT_BLUE, - content: $this->fitLines($rows, $this->getWindowContentWidth($overlayWidth), max(1, $overlayHeight - 2)), - ); - - $window->render(); - } + return EventTypeCatalog::describeClass($className); + } /** - * Renders the Database entry delete confirmation. + * Returns whether the frame list pane sizes itself to its lines and + * wraps the ones that still do not fit. * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void + * Categories whose frame list scrolls a per-entry selection keep a + * steady tuned width instead, so the row does not reflow as the cursor + * moves. Every other category's frame list fits the lines it is about + * to show: a pane holding prose is born readable rather than inheriting + * a width meant for frame numbers, and stays readable when its + * sentences change. + * + * @return bool */ - private function renderDatabaseEntryDeleteConfirmationOverlay(array $layout): void - { - $pending = $this->pendingDatabaseDeletion; - $label = is_array($pending) ? $pending['label'] : 'the selected entry'; - $category = is_array($pending) ? $pending['category'] : 'entry'; - $rows = [ - sprintf('Delete %s from %s?', $label, $category), - 'The entry leaves the editor now; the file changes on the next save.', - 'Ctrl+Z restores it before then.', - '', - 'Y: Delete', - 'Enter/Esc/N: Cancel (default)', - ]; - $overlayWidth = min(max(56, mb_strwidth($label) + 30), max(56, $layout['width'] - 10)); - $overlayHeight = 8; - - $window = new EditorWindow( - title: 'Delete Entry', - help: 'Y:Delete Esc:Cancel', - position: [ - 'x' => max(2, intdiv($layout['width'] - $overlayWidth, 2)), - 'y' => max(2, intdiv($layout['height'] - $overlayHeight, 2)), - ], - width: $overlayWidth, - height: $overlayHeight, - foregroundColor: Color::LIGHT_BLUE, - content: $this->fitLines($rows, $this->getWindowContentWidth($overlayWidth), max(1, $overlayHeight - 2)), - ); - - $window->render(); - } - - private function renderLootDialogOverlay(array $layout): void - { - $entries = $this->lootDialogEntries; - $selectedEntry = $entries[$this->selectedLootIndex] ?? null; - $contentWidth = min(68, max(48, $layout['width'] - 12)); - $contentHeight = min(max(12, count($entries) + 7), max(12, $layout['height'] - 8)); - $availableRows = max(1, $contentHeight - 2); - $listRows = max(1, $availableRows - 4); - $rows = $this->buildLootDialogRows($entries, $listRows); - $left = max(2, intdiv($layout['width'] - $contentWidth, 2)); - $top = max(2, intdiv($layout['height'] - $contentHeight, 2)); - - if (is_array($selectedEntry)) { - $rows[] = ''; - $rows[] = sprintf('Name: %s', $selectedEntry['name']); - $rows[] = sprintf('Type: %s', $selectedEntry['type']); - $rows[] = $selectedEntry['description']; - } - - $window = new EditorWindow( - title: $this->getLootTypeLabel($this->lootDialogType ?? LootType::ITEM, plural: true) . $this->dialogFilter->describe(), - help: 'Enter:Select /:Filter Esc:Cancel', - position: ['x' => $left, 'y' => $top], - width: $contentWidth, - height: $contentHeight, - foregroundColor: Color::LIGHT_BLUE, - content: $this->fitLines( - $rows, - $this->getWindowContentWidth($contentWidth), - $availableRows, - ), - ); - - $window->render(); - } - - private function renderEventOptionDialogOverlay(array $layout): void + private function framesPaneFitsContent(): bool { - $entries = $this->eventOptionDialogEntries; - $selectedEntry = $entries[$this->selectedEventOptionIndex] ?? null; - $contentWidth = min(60, max(40, $layout["width"] - 14)); - $contentHeight = min(max(9, count($entries) + 6), max(9, $layout["height"] - 10)); - $availableRows = max(1, $contentHeight - 2); - $listRows = max(1, $availableRows - 3); - $rows = $this->buildEventOptionDialogRows($entries, $listRows); - $left = max(2, intdiv($layout["width"] - $contentWidth, 2)); - $top = max(2, intdiv($layout["height"] - $contentHeight, 2)); - - if (is_array($selectedEntry)) { - $rows[] = ""; - $rows[] = (string) ($selectedEntry["description"] ?? ""); - } - - $window = new EditorWindow( - title: $this->eventOptionDialogTitle . $this->dialogFilter->describe(), - help: "Enter:Select /:Filter Esc:Cancel", - position: ["x" => $left, "y" => $top], - width: $contentWidth, - height: $contentHeight, - foregroundColor: Color::LIGHT_BLUE, - content: $this->fitLines( - $rows, - $this->getWindowContentWidth($contentWidth), - $availableRows, - ), + return ! ( + $this->isActorsDatabaseSelected() + || $this->isClassesDatabaseSelected() + || $this->isSkillsDatabaseSelected() + || $this->isQuestsDatabaseSelected() ); - - $window->render(); } /** - * Renders the event type picker overlay. + * Resolves the Database overlay layout. * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The base editor layout. + * @return array */ - private function renderEventTypeDialogOverlay(array $layout): void - { - $definitions = EventTypeCatalog::all(); - $selectedDefinition = EventTypeCatalog::at($this->selectedEventTypeIndex); - $rows = []; + /** + * What the settings and cue panes may take of the Database's right side. + */ + private const int DATABASE_SETTINGS_MINIMUM_WIDTH = 34; + private const int DATABASE_SETTINGS_MAXIMUM_WIDTH = 96; + private const int DATABASE_CUE_MINIMUM_WIDTH = 22; + private const int DATABASE_CUE_MAXIMUM_WIDTH = 48; - foreach ($this->getVisibleEventTypeIndexes() as $index) { - $definition = $definitions[$index] ?? null; + /** + * What a content-fitted frame list may take of the Database's right side. + */ + private const int DATABASE_FRAMES_MINIMUM_WIDTH = 10; + private const int DATABASE_FRAMES_MAXIMUM_WIDTH = 48; - if ($definition === null) { - continue; + private function resolveDatabaseLayout(array $layout): array + { + $rootX = 2; + $rootY = 1; + $rootWidth = $layout['width'] - 2; + $rootHeight = $layout['height'] - 1; + $innerX = $rootX + 1; + $innerY = $rootY + 2; + $innerWidth = $rootWidth - 2; + $innerHeight = $rootHeight - 4; + $gutter = 1; + $minimumListWidth = 20; + $minimumRightWidth = 34; + $categoryContentWidth = max( + mb_strwidth('Tab:Next Pane'), + ...array_map( + static fn(DatabaseCategoryDefinition $category): int => mb_strwidth($category->label) + 2, + DatabaseCatalog::all() + ) + ); + $maximumCategoryWidth = max( + 18, + $innerWidth - $minimumListWidth - $minimumRightWidth - ($gutter * 2) + ); + $categoryWidth = max( + 18, + min(24, $maximumCategoryWidth, $categoryContentWidth + 4), + ); + $listWidth = max( + $minimumListWidth, + min(24, $innerWidth - $categoryWidth - $minimumRightWidth - ($gutter * 2)) + ); + $rightWidth = max(30, $innerWidth - $categoryWidth - $listWidth - ($gutter * 2)); + $topHeight = $this->isClassesDatabaseSelected() + ? 17 + : ($this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() ? 18 : ($this->isActorsDatabaseSelected() ? 12 : 10)); + if ($this->framesPaneFitsContent()) { + $widestFrameLine = 0; + + foreach ($this->getDatabaseFrameLines() as $frameLine) { + $widestFrameLine = max($widestFrameLine, mb_strwidth($frameLine)); } - $rows[] = sprintf( - '%s %s', - $index === $this->selectedEventTypeIndex ? '>' : ' ', - $definition->label, + $framesWidth = max( + self::DATABASE_FRAMES_MINIMUM_WIDTH, + min( + self::DATABASE_FRAMES_MAXIMUM_WIDTH, + $rightWidth - 24 - $gutter, + $widestFrameLine + 2 + (self::WINDOW_HORIZONTAL_PADDING * 2) + ) ); + } elseif ($this->isClassesDatabaseSelected()) { + $framesWidth = 24; + } elseif ($this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected()) { + $framesWidth = max(30, min($rightWidth - 24 - $gutter, 34)); + } else { + $framesWidth = 18; } - $rows[] = ''; - $rows[] = $selectedDefinition->description; - - $overlayWidth = min(56, max(40, mb_strwidth($selectedDefinition->description) + 6)); - $overlayWidth = min($overlayWidth, max(40, $layout['width'] - 10)); - $overlayHeight = min(max(8, count($rows) + 2), max(8, $layout['height'] - 8)); - $left = max(2, intdiv($layout['width'] - $overlayWidth, 2)); - $top = max(2, intdiv($layout['height'] - $overlayHeight, 2)); - - $window = new EditorWindow( - title: 'Event Type' . $this->dialogFilter->describe(), - help: 'Enter:Select /:Filter Esc:Cancel', - position: ['x' => $left, 'y' => $top], - width: $overlayWidth, - height: $overlayHeight, - foregroundColor: Color::LIGHT_BLUE, - content: $this->fitLines( - $rows, - $this->getWindowContentWidth($overlayWidth), - max(1, $overlayHeight - 2), - ), + $previewWidth = max(20, $rightWidth - $framesWidth - $gutter); + $previewHeight = max(8, $innerHeight - $topHeight - $gutter); + // The settings pane holds label-and-value lines that truncate, and the + // cue beside it holds short summaries. So the cue takes what its + // content needs and the settings pane takes the rest: a wider + // terminal should widen the pane doing the reading, not the one with + // room to spare. + $cueWidth = min(self::DATABASE_CUE_MAXIMUM_WIDTH, max(self::DATABASE_CUE_MINIMUM_WIDTH, intdiv($rightWidth, 3))); + $settingsWidth = min( + self::DATABASE_SETTINGS_MAXIMUM_WIDTH, + max(self::DATABASE_SETTINGS_MINIMUM_WIDTH, $rightWidth - $cueWidth - $gutter) ); + $cueWidth = max(self::DATABASE_CUE_MINIMUM_WIDTH, $rightWidth - $settingsWidth - $gutter); - $window->render(); - } + // Narrow enough that neither pane can have its minimum: share out + // what there is rather than draw past the edge. + if ($settingsWidth + $cueWidth + $gutter > $rightWidth) { + $cueWidth = max(1, intdiv($rightWidth - $gutter, 3)); + $settingsWidth = max(1, $rightWidth - $cueWidth - $gutter); + } - /** - * Renders the destination picker overlay. - * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void - */ - private function renderDestinationDialogOverlay(array $layout): void - { - $entries = $this->getDestinationDialogEntries(); - $selectedEntry = $entries[$this->selectedDestinationIndex] ?? null; - $contentWidth = min(64, max(44, $layout['width'] - 12)); - $contentHeight = min(max(10, count($entries) + 5), max(10, $layout['height'] - 8)); - $availableRows = max(1, $contentHeight - 2); - $listRows = max(1, $availableRows - 2); - $rows = $this->buildDestinationDialogRows($entries, $listRows); - $left = max(2, intdiv($layout['width'] - $contentWidth, 2)); - $top = max(2, intdiv($layout['height'] - $contentHeight, 2)); - - if (is_array($selectedEntry)) { - $rows[] = ''; - $rows[] = sprintf('Map ID: %s', $selectedEntry['mapId']); - } - - $window = new EditorWindow( - title: 'Destination' . $this->dialogFilter->describe(), - help: 'Enter:Select /:Filter Esc:Cancel', - position: ['x' => $left, 'y' => $top], - width: $contentWidth, - height: $contentHeight, - foregroundColor: Color::LIGHT_BLUE, - content: $this->fitLines( - $rows, - $this->getWindowContentWidth($contentWidth), - $availableRows, - ), - ); - - $window->render(); - } - - /** - * Renders the destination spawn-point instructions overlay. - * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void - */ - private function renderDestinationSpawnSelectionOverlay(array $layout): void - { - $mapId = is_array($this->destinationSelectionContext) - ? $this->destinationSelectionContext['destinationMapId'] - : 'destination map'; - $window = new EditorWindow( - title: 'Select Spawn Point', - help: 'Enter:Confirm Esc:Cancel', - position: ['x' => 4, 'y' => 1], - width: min(54, max(40, $layout['width'] - 8)), - height: 5, - foregroundColor: Color::LIGHT_BLUE, - content: [ - sprintf('Choose a spawn point on %s.', $mapId), - sprintf('Current point: (%d, %d)', $this->cursorX, $this->cursorY), - '', - ], - ); - - $window->render(); - } - - /** - * Renders the confirmation dialog for the selected spawn point. - * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void - */ - private function renderDestinationSpawnConfirmationOverlay(array $layout): void - { - $mapId = is_array($this->destinationSelectionContext) - ? $this->destinationSelectionContext['destinationMapId'] - : 'destination map'; - $window = new EditorWindow( - title: 'Confirm Spawn Point', - help: 'Enter:Apply Esc:Back', - position: [ - 'x' => max(2, intdiv($layout['width'] - 50, 2)), - 'y' => max(2, intdiv($layout['height'] - 7, 2)), - ], - width: 50, - height: 7, - foregroundColor: Color::LIGHT_BLUE, - content: [ - sprintf('Map: %s', $mapId), - sprintf('Spawn Point: (%d, %d)', $this->cursorX, $this->cursorY), - '', - - 'Apply this destination and return to the source event?', - '', - ], - ); - - $window->render(); - } - - /** - * Returns grouped destination entries for the picker. - * - * @return array - */ - private function getDestinationDialogEntries(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return []; - } - - $entries = []; - - foreach ($this->workspace->maps as $map) { - $entries[] = [ - 'region' => $map->getRegion(), - 'name' => $map->getDisplayName(), - 'mapId' => $map->mapId, - ]; - } - - return $entries; - } - - /** - * Resolves the selected destination index for a current map id. - * - * @param string $currentDestinationMap The currently stored destination map id. - * @return int - */ - private function resolveDestinationSelectionIndex(string $currentDestinationMap): int - { - foreach ($this->getDestinationDialogEntries() as $index => $entry) { - if ($entry['mapId'] === $currentDestinationMap) { - return $index; - } - } - - return 0; - } - - /** - * Builds visible destination dialog rows with region headers. - * - * @param array $entries The selectable destinations. - * @param int $availableRows The available content rows. - * @return string[] - */ - private function buildDestinationDialogRows(array $entries, int $availableRows): array - { - $rows = []; - $selectedRowIndex = 0; - $currentRegion = null; - - foreach ($this->getVisibleDestinationIndexes() as $index) { - $entry = $entries[$index] ?? null; - - if (! is_array($entry)) { - continue; - } - - if ($entry['region'] !== $currentRegion) { - $currentRegion = $entry['region']; - $rows[] = sprintf('[%s]', $currentRegion === '' ? 'Unknown' : $currentRegion); - } - - if ($index === $this->selectedDestinationIndex) { - $selectedRowIndex = count($rows); - } - - $rows[] = sprintf( - '%s %s', - $index === $this->selectedDestinationIndex ? '>' : ' ', - $entry['name'], - ); - } - - if (count($rows) <= $availableRows) { - return $rows; - } - - $startRow = max(0, min(count($rows) - $availableRows, $selectedRowIndex - intdiv($availableRows, 2))); - - return array_slice($rows, $startRow, $availableRows); - } - - private function loadLootDialogEntries(LootType $lootType): array - { - return match ($lootType) { - LootType::ITEM, - LootType::WEAPON, - LootType::ARMOR, - LootType::ACCESSORY => $this->loadInventoryLootDialogEntries($lootType), - LootType::SKILL => $this->loadSkillLootDialogEntries(), - LootType::SPELL => $this->loadSpellLootDialogEntries(), - default => [], - }; - } - - /** - * Loads inventory-backed loot entries for the requested loot type. - * - * @param LootType $lootType The loot type being edited. - * @return array - */ - private function loadInventoryLootDialogEntries(LootType $lootType): array - { - $itemsPath = $this->projectRoot . '/assets/Data/items.php'; - - if (! is_file($itemsPath)) { - return []; - } - - $items = require $itemsPath; - - if (! is_array($items)) { - return []; - } - - $entries = []; - - foreach ($items as $item) { - if (! is_object($item) || ! isset($item->name) || ! $this->matchesLootInventoryType($item, $lootType)) { - continue; - } - - // What a chest stores is the stable definition id, the identity - // the runtime resolves; the name is what the author reads. - $identity = InventoryCatalog::definitionId($item->id ?? null, (string) $item->name); - - $entries[] = [ - 'name' => (string) $item->name, - 'value' => $identity ?? (string) $item->name, - 'description' => (string) ($item->description ?? ''), - 'icon' => trim((string) ($item->icon ?? '')), - 'type' => $this->getLootTypeLabel($lootType), - ]; - } - - usort($entries, static fn(array $left, array $right): int => strcmp($left['name'], $right['name'])); - - return $entries; - } - - /** - * Loads skill-based loot entries. - * - * @return array - */ - private function loadSkillLootDialogEntries(): array - { - $skillsPath = $this->projectRoot . '/assets/Data/skills.php'; - - if (! is_file($skillsPath)) { - return []; - } - - $skills = require $skillsPath; - - if (! is_array($skills)) { - return []; - } - - $entries = []; - - foreach ($skills as $skill) { - if (! is_object($skill) || ! isset($skill->name)) { - continue; - } - - $entries[] = [ - 'name' => (string) $skill->name, - 'description' => (string) ($skill->description ?? ''), - 'icon' => trim((string) ($skill->icon ?? '')), - 'type' => 'Skill', - ]; - } - - usort($entries, static fn(array $left, array $right): int => strcmp($left['name'], $right['name'])); - - return $entries; - } - - /** - * Loads magic/spell loot entries. - * - * @return array - */ - private function loadSpellLootDialogEntries(): array - { - $magicPath = $this->projectRoot . '/assets/Data/magic.php'; - - if (! is_file($magicPath)) { - return []; - } - - $spells = require $magicPath; - - if (! is_array($spells)) { - return []; - } - - $entries = []; - - foreach ($spells as $spell) { - if (! is_object($spell) || ! isset($spell->name)) { - continue; - } - - $entries[] = [ - 'name' => (string) $spell->name, - 'description' => (string) ($spell->description ?? ''), - 'icon' => trim((string) ($spell->icon ?? '')), - 'type' => 'Spell', - ]; - } - - usort($entries, static fn(array $left, array $right): int => strcmp($left['name'], $right['name'])); - - return $entries; - } - - /** - * Returns whether the given inventory object matches the selected loot type. - * - * @param object $item The inventory object to inspect. - * @param LootType $lootType The loot type being edited. - * @return bool - */ - private function matchesLootInventoryType(object $item, LootType $lootType): bool - { - return match ($lootType) { - LootType::ITEM => $item instanceof Item - && ! $item instanceof Weapon - && ! $item instanceof Armor - && ! $item instanceof Accessory, - LootType::WEAPON => $item instanceof Weapon, - LootType::ARMOR => $item instanceof Armor, - LootType::ACCESSORY => $item instanceof Accessory, - default => false, - }; - } - - private function resolveLootSelectionIndex(string $currentLoot): int - { - $current = mb_strtolower(trim($currentLoot)); - - if ($current === '') { - return 0; - } - - // The stored value may be an id, a display name, or a declared - // alias, so it is resolved before it is looked for. A value that - // resolves to nothing leaves the cursor at the top, which is the - // honest answer for a reference nothing answers to. - $resolved = $this->workspace instanceof ProjectWorkspace - ? InventoryCatalog::fromWorkspace($this->workspace)->definitionIdFor($currentLoot) - : null; - - foreach ($this->lootDialogEntries as $index => $entry) { - $value = mb_strtolower(trim((string) ($entry['value'] ?? $entry['name']))); - - if ($value === $current || mb_strtolower(trim($entry['name'])) === $current || ($resolved !== null && $value === $resolved)) { - return $index; - } - } - - return 0; - } - - private function buildLootDialogRows(array $entries, int $availableRows): array - { - $rows = []; - $selectedRowIndex = 0; - $groupByType = count(array_unique(array_column($entries, 'type'))) > 1; - $currentType = null; - - foreach ($this->getVisibleLootIndexes() as $index) { - $entry = $entries[$index] ?? null; - - if (! is_array($entry)) { - continue; - } - - if ($groupByType && $entry['type'] !== $currentType) { - $currentType = $entry['type']; - $rows[] = sprintf('[%s]', $currentType === '' ? 'Unknown' : $currentType); - } - - if ($index === $this->selectedLootIndex) { - $selectedRowIndex = count($rows); - } - - $prefix = $index === $this->selectedLootIndex ? '>' : ' '; - $icon = $entry['icon'] === '' ? '-' : $entry['icon']; - $rows[] = sprintf('%s %s %s', $prefix, $icon, $entry['name']); - } - - if (count($rows) <= $availableRows) { - return $rows; - } - - $startRow = max(0, min(count($rows) - $availableRows, $selectedRowIndex - intdiv($availableRows, 2))); - - return array_slice($rows, $startRow, $availableRows); - } - - - /** - * The encounter row the inspector cursor is on, if any. - * - * @return array{map: ProjectMap, encounters: MapEncounters, index: int}|null - */ - private function selectedMapEncounterRow(): ?array - { - $map = $this->getSelectedMap(); - $field = $this->getInspectorFields()[$this->selectedInspectorFieldIndex] ?? null; - - if (! $map instanceof ProjectMap || ! is_array($field) || ($field['target'] ?? null) !== 'map-encounters') { - return null; - } - - $list = $field['encounterList'] ?? null; - - if (! is_array($list)) { - return null; - } - - return ['map' => $map, 'encounters' => MapEncounters::fromMap($map), 'index' => (int) $list['index']]; - } - - /** - * Adds a troop row to the map's encounter table, enabling encounters - * when it is the first one. - * - * The new row names a troop the table does not already use, because a - * blank row would be a troop the engine reads as unnamed and a duplicate - * would collapse into one key. - * - * @return bool Whether the cursor was on the encounter list. - */ - private function addMapEncounterTroop(): bool - { - $context = $this->selectedMapEncounterRow(); - - if ($context === null) { - return false; - } - - $encounters = $context['encounters']; - - if (! $encounters->isSupported()) { - $this->setStatus(sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $available = array_values(array_diff($this->referenceCatalog()->valuesFor('troops'), $encounters->troopNames())); - - if ($available === []) { - $this->setStatus( - $this->referenceCatalog()->valuesFor('troops') === [] - ? 'This project defines no troops to encounter.' - : 'Every troop this project defines is already in this table.', - StatusLevel::WARN, - ); - $this->renderInspectorArea(); - - return true; - } - - try { - $block = $encounters->withTroopAdded($available[0], $context['index']); - } catch (Throwable $throwable) { - $this->setErrorStatus($throwable, 'Encounter troop'); - $this->renderInspectorArea(); - - return true; - } - - $this->applyMapDataValue($context['map'], [MapEncounters::KEY], $block, 'Encounter troop add'); - $this->setStatus(sprintf('Added %s to the encounter table.', $available[0]), StatusLevel::SUCCESS); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); - - return true; - } - - /** - * Removes the troop row the cursor is on. Removing the last one disables - * encounters and takes the empty block with it. - * - * @return bool Whether the cursor was on the encounter list. - */ - private function removeMapEncounterTroop(): bool - { - $context = $this->selectedMapEncounterRow(); - - if ($context === null) { - return false; - } - - $encounters = $context['encounters']; - - if (! $encounters->isSupported()) { - $this->setStatus(sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason()), StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $rows = $encounters->rows(); - - if (! array_key_exists($context['index'], $rows)) { - $this->setStatus('No encounter troop to remove.', StatusLevel::WARN); - $this->renderInspectorArea(); - - return true; - } - - $removed = $rows[$context['index']]['name']; - $block = $encounters->withTroopRemovedAt($context['index']); - $this->applyMapDataValue($context['map'], [MapEncounters::KEY], $block, 'Encounter troop remove'); - $this->setStatus( - $block === null - ? sprintf('Removed %s; this map no longer has random encounters.', $removed) - : sprintf('Removed %s from the encounter table.', $removed), - StatusLevel::SUCCESS, - ); - $this->clampInspectorSelection(); - $this->renderSelectionDependentArea(); - - return true; - } - - /** - * Writes one nested map-data value, recording it for undo. - * - * @param array $path The nested data path. - */ - private function applyMapDataValue(ProjectMap $map, array $path, mixed $value, string $label): void - { - $hadValue = $map->hasMapDataField($path); - $oldValue = $map->getMapDataField($path); - $map->setMapDataField($path, $value); - - if ($map->getMapDataField($path) === $oldValue && $map->hasMapDataField($path) === $hadValue) { - return; - } - - $this->recordCommand(new GenericCommand( - sprintf('%s edit', $label), - static fn() => $map->setMapDataField($path, $value), - static fn() => $map->setMapDataField($path, $hadValue ? $oldValue : null), - )); - } - - /** - * Writes one encounter field through the encounters model, which owns - * the block's shape, its defaults and its duplicate rule. - * - * @param array $field The inspector field descriptor. - */ - private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $value): void - { - $encounters = MapEncounters::fromMap($map); - - if (! $encounters->isSupported()) { - $this->setStatus( - sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason()), - StatusLevel::WARN, - ); - - return; - } - - $index = (int) ($field['index'] ?? 0); - $block = match ((string) ($field['field'] ?? '')) { - 'rate' => $encounters->withRate((int) $value), - 'tiles' => $encounters->withTiles((string) $value), - 'weight' => $encounters->withWeightAt($index, (int) $value), - 'troop' => $encounters->withTroopAt($index, (string) $value), - default => null, - }; - - if ($block === null && ! $encounters->isDeclared()) { - return; - } - - $this->applyMapDataValue($map, [MapEncounters::KEY], $block, 'Encounters'); - } - - /** - * Returns the list the inspector cursor is inside, if any. - * - * @return array{path: array, index: int}|null The list. - */ - private function selectedInspectorList(): ?array - { - $fields = $this->getInspectorFields(); - $field = $fields[$this->selectedInspectorFieldIndex] ?? null; - - if (! is_array($field) || ($field['target'] ?? null) !== 'event') { - return null; - } - - $list = $field['list'] ?? null; - - return is_array($list) ? $list : null; - } - - /** - * Adds an entry to the list the inspector cursor is inside. - * - * The new entry is shaped like the one it follows -- the same keys, their - * values cleared -- because an author adding a second shop line means - * another line like the first, not an empty hole they have to describe. - * - * @return void - */ - private function addInspectorListItem(): void - { - if ($this->addMapEncounterTroop()) { - return; - } - - if ($this->addMapBgmVariant()) { - return; - } - - $selectedMap = $this->getSelectedMap(); - $list = $this->selectedInspectorList(); - $marker = $this->selectedEventMarkerForList(); - - if (! $selectedMap instanceof ProjectMap || $list === null || $marker === '') { - $this->setStatus('Nothing here is a list to add to.', StatusLevel::WARN); - - return; - } - - $entries = $selectedMap->getEventField($marker, $list['path']); - $entries = is_array($entries) ? array_values($entries) : []; - $template = $entries[$list['index']] ?? ($list['blank'] ?? ($entries === [] ? '' : end($entries))); - $position = min(count($entries), $list['index'] + 1); - - array_splice($entries, $position, 0, [self::blankLike($template)]); - - $this->applyInspectorListChange($selectedMap, $marker, $list['path'], $entries, 'Add list entry'); - $this->setStatus(sprintf('Added %s %d.', $this->describeListPath($list['path']), $position + 1), StatusLevel::SUCCESS); - } - - /** - * Removes the entry the inspector cursor is inside. - * - * @return void - */ - private function removeInspectorListItem(): void - { - if ($this->removeMapEncounterTroop()) { - return; - } - - if ($this->removeMapBgmVariant()) { - return; - } - - $selectedMap = $this->getSelectedMap(); - $list = $this->selectedInspectorList(); - $marker = $this->selectedEventMarkerForList(); - - if (! $selectedMap instanceof ProjectMap || $list === null || $marker === '') { - $this->setStatus('Nothing here is a list entry to remove.', StatusLevel::WARN); - - return; - } - - $entries = $selectedMap->getEventField($marker, $list['path']); - $entries = is_array($entries) ? array_values($entries) : []; - - if (! array_key_exists($list['index'], $entries)) { - return; - } - - array_splice($entries, $list['index'], 1); - - $this->applyInspectorListChange($selectedMap, $marker, $list['path'], $entries, 'Remove list entry'); - $this->clampInspectorSelection(); - $this->setStatus(sprintf('Removed %s %d.', $this->describeListPath($list['path']), $list['index'] + 1), StatusLevel::SUCCESS); - } - - /** - * Writes a changed list back, recording it so it can be undone. - * - * @param ProjectMap $map The map. - * @param string $marker The event marker. - * @param array $path The list's path. - * @param array $entries The list as it should be. - * @param string $label What to call the change. - * @return void - */ - private function applyInspectorListChange(ProjectMap $map, string $marker, array $path, array $entries, string $label): void - { - $previous = $map->getEventField($marker, $path); - $previous = is_array($previous) ? array_values($previous) : []; - - $map->setEventField($marker, $path, $entries); - $this->recordCommand(new GenericCommand( - $label, - static fn() => $map->setEventField($marker, $path, $entries), - static fn() => $map->setEventField($marker, $path, $previous), - )); - $this->renderSelectionDependentArea(); - } - - /** - * Returns the marker of the event the inspector cursor is in. - * - * @return string The marker, or an empty string. - */ - private function selectedEventMarkerForList(): string - { - $fields = $this->getInspectorFields(); - $field = $fields[$this->selectedInspectorFieldIndex] ?? null; - - return is_array($field) ? (string) ($field['marker'] ?? '') : ''; - } - - /** - * Returns an entry shaped like the given one with nothing filled in. - * - * @param mixed $template The entry to copy the shape of. - * @return mixed The blank entry. - */ - private static function blankLike(mixed $template): mixed - { - if (is_array($template)) { - return array_map(self::blankLike(...), $template); - } - - return match (true) { - is_int($template) => 0, - is_float($template) => 0.0, - is_bool($template) => false, - default => '', - }; - } - - /** - * Names a list for a status message. - * - * @param array $path The list's path. - * @return string The name, in the singular. - */ - private static function describeListPath(array $path): string - { - $leaf = (string) ($path[array_key_last($path)] ?? 'entry'); - $leaf = str_replace(['_', '-'], ' ', $leaf); - - return mb_strtolower(rtrim($leaf, 's')); - } - - /** - * Determines whether an event field names another resource. - * - * A door's destination and a chest's loot have flows of their own; this - * covers the rest, so a track, a sound, or a shop's stock is chosen from - * what the project actually has rather than spelled from memory. - * - * @param array $field The inspector field descriptor. - * @return array{category: string, title: string}|null The kind of - * reference and what to call the picker, or null when the field names - * nothing. - */ - private function resolveEventReferenceField(array $field): ?array - { - if (($field['target'] ?? null) !== 'event') { - return null; - } - - $path = array_values((array) ($field['path'] ?? [])); - - if (($path[0] ?? null) !== 'data' || count($path) < 2) { - return null; - } - - $leaf = (string) $path[array_key_last($path)]; - - return match (true) { - $path === ['data', 'scriptId'] => ['category' => 'common_events', 'title' => 'Event Script'], - $path === ['data', 'cinematicId'] => ['category' => 'cinematics', 'title' => 'Cinematic'], - $leaf === 'bgm' => ['category' => 'bgm', 'title' => 'Music'], - $leaf === 'sfx' => ['category' => 'sfx', 'title' => 'Sound Effect'], - // A shop's stock is data.items.N.item. The leaf alone would also - // match an unrelated event that happened to call a field "item". - $leaf === 'item' && ($path[1] ?? null) === 'items' - => ['category' => 'inventory', 'title' => 'Item'], - default => null, - }; - } - - /** - * Opens the picker for an event field that names another resource. - * - * @param string $marker The event marker. - * @param array $path The path within the event definition. - * @param string $title What to call the picker. - * @param string $category The kind of reference. - * @param string $currentValue What the field is set to now. - * @return void - */ - private function openEventReferenceDialog( - string $marker, - array $path, - string $title, - string $category, - string $currentValue, - ): void { - if (! $this->workspace instanceof ProjectWorkspace) { - return; - } - - $referenceCatalog = $this->referenceCatalog(); - $referenceLabels = $referenceCatalog->labelsFor($category); - $entries = array_map( - static fn(string $value): array => [ - 'label' => $referenceLabels[$value] ?? $value, - 'value' => $value, - 'description' => '', - ], - $referenceCatalog->valuesFor($category), - ); - - if ($entries === []) { - // Nothing to choose from is worth saying. Opening an empty dialog - // reads as a broken editor, and falling through to typing would - // put back the guesswork the picker exists to remove. - $this->setStatus( - sprintf('This project has no %s to choose from.', mb_strtolower($title)), - StatusLevel::WARN - ); - $this->renderFooter(); - - return; - } - - $this->openEventOptionDialog($marker, $path, $title, $entries, $currentValue); - } - - /** - * Opens the picker for an inspector field that names a project resource - * outside an event: a map's music, a map encounter's troop. - * - * @param array $field The inspector field descriptor. - * @return bool Whether a picker was opened. - */ - private function openInspectorReferenceDialog(array $field): bool - { - $category = (string) ($field['reference'] ?? ''); - $target = (string) ($field['target'] ?? ''); - - if ($category === '' || ! in_array($target, ['map-data', 'map-encounters', 'map-bgm-variants'], true)) { - return false; - } - - if (! $this->workspace instanceof ProjectWorkspace) { - return false; - } - - $title = trim((string) ($field['label'] ?? 'Reference')); - $catalog = $this->referenceCatalog(); - $labels = $catalog->labelsFor($category); - $entries = array_map( - static fn(string $value): array => [ - 'label' => $labels[$value] ?? $value, - 'value' => $value, - 'description' => '', - ], - $catalog->valuesFor($category), - ); - - if ($category === 'bgm' && $target === 'map-data') { - // Silence is a choice a map can make, and the only way back from - // a track once one is set. A variant is different: an empty - // track is a skipped variant, not silence, so the variant picker - // offers only real tracks and removal un-authors the variant. - array_unshift($entries, [ - 'label' => self::MAP_BGM_NONE, - 'value' => '', - 'description' => 'The map plays whatever was already playing.', - ]); - } - - if ($entries === [] || ($category === 'bgm' && count($entries) === 1 && $target === 'map-data')) { - $this->setStatus( - sprintf('This project has no %s to choose from.', mb_strtolower($title)), - StatusLevel::WARN, - ); - $this->renderFooter(); - - return true; - } - - $this->optionDialogField = $field; - $this->eventOptionDialogMarker = null; - $this->eventOptionDialogPath = null; - $this->eventOptionDialogTitle = $title; - $this->eventOptionDialogEntries = $entries; - $this->selectedEventOptionIndex = $this->resolveEventOptionSelectionIndex((string) ($field['value'] ?? '')); - $this->isEventOptionDialogOpen = true; - $this->statusMessage = sprintf('Choose %s.', mb_strtolower($title)); - $this->renderSelectionDependentArea(); - - return true; - } - - private function isChestTypeField(array $field): bool - { - return ($field['target'] ?? null) === 'event' - && (($field['path'] ?? []) === ['data', 'chestType']); - } - - private function isLootTypeField(array $field): bool - { - return ($field['target'] ?? null) === 'event' - && (($field['path'] ?? []) === ['data', 'lootType']); - } - - private function getChestTypeOptionEntries(): array - { - return [ - [ - 'label' => 'Common', - 'value' => ChestType::COMMON->value, - 'description' => 'Standard chest presentation for ordinary treasure.', - ], - [ - 'label' => 'Rare', - 'value' => ChestType::RARE->value, - 'description' => 'Highlights a chest that should feel less common.', - ], - [ - 'label' => 'Epic', - 'value' => ChestType::EPIC->value, - 'description' => 'Marks a chest carrying high-value treasure.', - ], - [ - 'label' => 'Legendary', - 'value' => ChestType::LEGENDARY->value, - 'description' => 'Reserved for the most special chest rewards.', - ], - ]; - } - - /** - * Returns the editor-facing label for a loot type. - * - * @param LootType $lootType The loot type to label. - * @param bool $plural Whether to return the plural form. - * @return string - */ - private function getLootTypeLabel(LootType $lootType, bool $plural = false): string - { - return match ($lootType) { - LootType::ITEM => $plural ? 'Items' : 'Item', - LootType::GOLD => 'Gold', - LootType::EXPERIENCE => 'Experience', - LootType::SKILL => $plural ? 'Skills' : 'Skill', - LootType::SPELL => $plural ? 'Spells' : 'Spell', - LootType::WEAPON => $plural ? 'Weapons' : 'Weapon', - LootType::ARMOR => 'Armor', - LootType::ACCESSORY => $plural ? 'Accessories' : 'Accessory', - }; - } - - private function getLootTypeOptionEntries(): array - { - return [ - [ - 'label' => 'Item', - 'value' => LootType::ITEM->value, - 'description' => 'Rewards an item from the project item database.', - ], - [ - 'label' => 'Gold', - 'value' => LootType::GOLD->value, - 'description' => 'Awards currency directly when the chest is opened.', - ], - [ - 'label' => 'Experience', - 'value' => LootType::EXPERIENCE->value, - 'description' => 'Awards experience directly when claimed.', - ], - [ - 'label' => 'Skill', - 'value' => LootType::SKILL->value, - 'description' => 'Rewards a learnable skill identifier.', - ], - [ - 'label' => 'Spell', - 'value' => LootType::SPELL->value, - 'description' => 'Rewards a spell identifier.', - ], - [ - 'label' => 'Weapon', - 'value' => LootType::WEAPON->value, - 'description' => 'Rewards a weapon identifier.', - ], - [ - 'label' => 'Armor', - 'value' => LootType::ARMOR->value, - 'description' => 'Rewards an armor identifier.', - ], - [ - 'label' => 'Accessory', - 'value' => LootType::ACCESSORY->value, - 'description' => 'Rewards an accessory identifier.', - ], - ]; - } - - private function resolveEventOptionSelectionIndex(string $currentValue): int - { - foreach ($this->eventOptionDialogEntries as $index => $entry) { - if (($entry["value"] ?? null) === $currentValue) { - return $index; - } - } - - return 0; - } - - private function buildEventOptionDialogRows(array $entries, int $availableRows): array - { - $rows = []; - $selectedRowIndex = 0; - - foreach ($this->getVisibleEventOptionIndexes() as $index) { - $entry = $entries[$index] ?? null; - - if (! is_array($entry)) { - continue; - } - - if ($index === $this->selectedEventOptionIndex) { - $selectedRowIndex = count($rows); - } - - $rows[] = sprintf( - "%s %s", - $index === $this->selectedEventOptionIndex ? ">" : " ", - (string) ($entry["label"] ?? ""), - ); - } - - if (count($rows) <= $availableRows) { - return $rows; - } - - $startRow = max(0, min(count($rows) - $availableRows, $selectedRowIndex - intdiv($availableRows, 2))); - - return array_slice($rows, $startRow, $availableRows); - } - /** - * Resolves a stored trigger class into a user-facing event type label. - * - * @param string|null $className The stored trigger class name. - * @return string - */ - private function resolveEventTypeLabel(?string $className): string - { - if ($className === null || $className === '') { - return 'Unset'; - } - - foreach (EventTypeCatalog::all() as $definition) { - if ($definition->className === $className) { - return $definition->label; - } - } - - return basename(str_replace('\\', '/', $className)); - } - - /** - * Returns whether the frame list pane sizes itself to its lines and - * wraps the ones that still do not fit. - * - * Categories whose frame list scrolls a per-entry selection keep a - * steady tuned width instead, so the row does not reflow as the cursor - * moves. Every other category's frame list fits the lines it is about - * to show: a pane holding prose is born readable rather than inheriting - * a width meant for frame numbers, and stays readable when its - * sentences change. - * - * @return bool - */ - private function framesPaneFitsContent(): bool - { - return ! ( - $this->isActorsDatabaseSelected() - || $this->isClassesDatabaseSelected() - || $this->isSkillsDatabaseSelected() - || $this->isQuestsDatabaseSelected() - ); - } - - /** - * Resolves the Database overlay layout. - * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The base editor layout. - * @return array - */ - /** - * What the settings and cue panes may take of the Database's right side. - */ - private const int DATABASE_SETTINGS_MINIMUM_WIDTH = 34; - private const int DATABASE_SETTINGS_MAXIMUM_WIDTH = 96; - private const int DATABASE_CUE_MINIMUM_WIDTH = 22; - private const int DATABASE_CUE_MAXIMUM_WIDTH = 48; - - /** - * What a content-fitted frame list may take of the Database's right side. - */ - private const int DATABASE_FRAMES_MINIMUM_WIDTH = 10; - private const int DATABASE_FRAMES_MAXIMUM_WIDTH = 48; - - private function resolveDatabaseLayout(array $layout): array - { - $rootX = 2; - $rootY = 1; - $rootWidth = $layout['width'] - 2; - $rootHeight = $layout['height'] - 1; - $innerX = $rootX + 1; - $innerY = $rootY + 2; - $innerWidth = $rootWidth - 2; - $innerHeight = $rootHeight - 4; - $gutter = 1; - $minimumListWidth = 20; - $minimumRightWidth = 34; - $categoryContentWidth = max( - mb_strwidth('Tab:Next Pane'), - ...array_map( - static fn(DatabaseCategoryDefinition $category): int => mb_strwidth($category->label) + 2, - DatabaseCatalog::all() - ) - ); - $maximumCategoryWidth = max( - 18, - $innerWidth - $minimumListWidth - $minimumRightWidth - ($gutter * 2) - ); - $categoryWidth = max( - 18, - min(24, $maximumCategoryWidth, $categoryContentWidth + 4), - ); - $listWidth = max( - $minimumListWidth, - min(24, $innerWidth - $categoryWidth - $minimumRightWidth - ($gutter * 2)) - ); - $rightWidth = max(30, $innerWidth - $categoryWidth - $listWidth - ($gutter * 2)); - $topHeight = $this->isClassesDatabaseSelected() - ? 17 - : ($this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() ? 18 : ($this->isActorsDatabaseSelected() ? 12 : 10)); - if ($this->framesPaneFitsContent()) { - $widestFrameLine = 0; - - foreach ($this->getDatabaseFrameLines() as $frameLine) { - $widestFrameLine = max($widestFrameLine, mb_strwidth($frameLine)); - } - - $framesWidth = max( - self::DATABASE_FRAMES_MINIMUM_WIDTH, - min( - self::DATABASE_FRAMES_MAXIMUM_WIDTH, - $rightWidth - 24 - $gutter, - $widestFrameLine + 2 + (self::WINDOW_HORIZONTAL_PADDING * 2) - ) - ); - } elseif ($this->isClassesDatabaseSelected()) { - $framesWidth = 24; - } elseif ($this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected()) { - $framesWidth = max(30, min($rightWidth - 24 - $gutter, 34)); - } else { - $framesWidth = 18; - } - - $previewWidth = max(20, $rightWidth - $framesWidth - $gutter); - $previewHeight = max(8, $innerHeight - $topHeight - $gutter); - // The settings pane holds label-and-value lines that truncate, and the - // cue beside it holds short summaries. So the cue takes what its - // content needs and the settings pane takes the rest: a wider - // terminal should widen the pane doing the reading, not the one with - // room to spare. - $cueWidth = min(self::DATABASE_CUE_MAXIMUM_WIDTH, max(self::DATABASE_CUE_MINIMUM_WIDTH, intdiv($rightWidth, 3))); - $settingsWidth = min( - self::DATABASE_SETTINGS_MAXIMUM_WIDTH, - max(self::DATABASE_SETTINGS_MINIMUM_WIDTH, $rightWidth - $cueWidth - $gutter) - ); - $cueWidth = max(self::DATABASE_CUE_MINIMUM_WIDTH, $rightWidth - $settingsWidth - $gutter); - - // Narrow enough that neither pane can have its minimum: share out - // what there is rather than draw past the edge. - if ($settingsWidth + $cueWidth + $gutter > $rightWidth) { - $cueWidth = max(1, intdiv($rightWidth - $gutter, 3)); - $settingsWidth = max(1, $rightWidth - $cueWidth - $gutter); - } - - // Narrow enough that the frame list and preview cannot both have - // their minimum: share out what there is rather than draw past the - // edge. - if ($framesWidth + $previewWidth + $gutter > $rightWidth) { - $framesWidth = max(1, intdiv($rightWidth - $gutter, 2)); - $previewWidth = max(1, $rightWidth - $framesWidth - $gutter); - } - - return [ - 'rootX' => $rootX, - 'rootY' => $rootY, - 'rootWidth' => $rootWidth, - 'rootHeight' => $rootHeight, - 'innerX' => $innerX, - 'innerY' => $innerY, - 'innerWidth' => $innerWidth, - 'innerHeight' => $innerHeight, - 'gutter' => $gutter, - 'categoryWidth' => $categoryWidth, - 'listWidth' => $listWidth, - 'rightWidth' => $rightWidth, - 'topHeight' => $topHeight, - 'framesWidth' => $framesWidth, - 'previewWidth' => $previewWidth, - 'previewHeight' => $previewHeight, - 'settingsWidth' => $settingsWidth, - 'cueWidth' => $cueWidth, - ]; - } - - /** - * Renders the Database overlay. - * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The base editor layout. - * @return void - */ - private function renderDatabaseOverlay(array $layout): void - { - $this->databaseScreen->drawAll($this->resolveDatabaseLayout($layout), true); - } - - /** - * Marks the whole Database overlay for repainting on the next render pass. - * - * @param bool $includeRoot Whether to include the outer Database frame. - * @return void - */ - private function renderDatabaseArea(bool $includeRoot = false): void - { - $this->databaseScreen->markAllDirty($includeRoot); - } - - /** - * Paints the whole Database overlay immediately (full-redraw path). - * - * @param bool $includeRoot Whether to include the outer Database frame. - * @return void - */ - private function drawDatabaseArea(bool $includeRoot = false): void - { - $this->databaseScreen->drawAll(includeRoot: $includeRoot); - } - - /** - * Marks panes whose visual state depends on Database focus. - * - * @return void - */ - private function renderDatabaseFocusDependentArea(): void - { - $this->markDatabasePanesDirty(['categories', 'list', 'settings', 'frames', 'preview']); - } - - /** - * Queues specific Database panes for repainting on the next render pass. - * - * @param string[] $panes The pane identifiers to repaint. - * @param array|null $layout Ignored; the flush recomputes the layout (kept for signature compatibility). - * @param bool $includeRoot Whether to repaint the outer Database frame. - * @return void - */ - private function renderDatabasePanes(array $panes, ?array $layout = null, bool $includeRoot = false): void - { - if ($this->isCutscenesOpen) { - // The sub-editors ask for Database panes; in the Cutscenes screen - // the same request means its own panes of the same names. - $this->cutscenesScreen->markDirty( - array_values(array_intersect($panes, \Ichiloto\Editor\UI\CutscenesScreen::PANES)) ?: [\Ichiloto\Editor\UI\CutscenesScreen::PANE_SETTINGS], - $includeRoot, - ); - - return; - } - - if ($this->isNpcInspectorHosting()) { - // The sub-editors ask for Database panes; in the Inspector the - // same request means the inspector and canvas. - $this->requestFullRender(); - - return; - } - - $this->markDatabasePanesDirty($panes, $includeRoot); - } - - /** - * Unions panes into the Database dirty set. - * - * @param string[] $panes The pane identifiers to queue. - * @param bool $includeRoot Whether to queue the outer Database frame. - * @return void - */ - private function markDatabasePanesDirty(array $panes, bool $includeRoot = false): void - { - $this->databaseScreen->markDirty($panes, $includeRoot); - } - - /** - * Paints specific Database panes without clearing the full editor. - * - * @param string[] $panes The pane identifiers to render. - * @param array|null $layout The optional Database layout. - * @param bool $includeRoot Whether to render the outer Database frame. - * @return void - */ - private function drawDatabasePanes(array $panes, ?array $layout = null, bool $includeRoot = false): void - { - $this->databaseScreen->draw($panes, $layout, $includeRoot); - } - - /** - * Renders the animation preview content inside the preview window. - * - * @param array $layout The Database layout. - * @return void - */ - private function renderDatabasePreview(array $layout): void - { - if (! $this->isAnimationsDatabaseSelected()) { - return; - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - return; - } - - $previewLeft = $layout['innerX'] + $layout['categoryWidth'] + $layout['listWidth'] + $layout['framesWidth'] + ($layout['gutter'] * 4); - $previewTop = $layout['innerY'] + $layout['topHeight'] + $layout['gutter']; - $previewWidth = max(10, $layout['previewWidth'] - 2 - (self::WINDOW_HORIZONTAL_PADDING * 2)); - $previewHeight = $this->getDatabasePreviewSize()['height']; - $frameIndex = $this->isDatabasePreviewPlaying ? $this->databasePlaybackFrameIndex : $this->databaseSelectedFrameIndex; - $preview = AnimationPreviewRenderer::build($animation, $frameIndex, $previewWidth, $previewHeight); - - foreach ($preview['cells'] as $cell) { - $x = $previewLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $cell['x']; - $y = $previewTop + 1 + $cell['y']; - Console::cursor()->moveTo($x, $y); - echo $this->resolveAnimationColor($cell['color'])->value . $cell['symbol'] . Color::RESET->value; - } - - if ($this->databaseFocus === self::DATABASE_FOCUS_PREVIEW && ! $this->isDatabasePreviewPlaying) { - Console::cursor()->moveTo( - $previewLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $this->databasePreviewCursorX, - $previewTop + 1 + $this->databasePreviewCursorY, - ); - echo Color::LIGHT_BLUE->value . '▣' . Color::RESET->value; - } - } - - /** - * Renders the live cursor for editing Database settings. - * - * @param array $layout The Database layout. - * @return void - */ - private function renderDatabaseEditCursor(array $layout): void - { - $fields = $this->getDatabaseSettingsFields(); - $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - - if (! is_array($field)) { - Console::cursor()->hide(); - return; - } - - $label = (string) ($field['label'] ?? 'Field'); - $leftText = sprintf('> %s: ', $label); - $settingsContentWidth = $this->getWindowContentWidth($layout['settingsWidth']); - $availableValueWidth = max(1, $settingsContentWidth - mb_strwidth($leftText)); - $visibleStart = max(0, $this->databaseEditCursorIndex - $availableValueWidth + 1); - $visibleValue = mb_substr($this->databaseEditBuffer, $visibleStart, $availableValueWidth); - $visibleCursorIndex = max(0, min($this->databaseEditCursorIndex - $visibleStart, mb_strlen($visibleValue))); - $cursorOffset = min( - $settingsContentWidth - 1, - mb_strwidth($leftText . mb_substr($visibleValue, 0, $visibleCursorIndex)) - ); - $row = $this->recordPaneLayout($fields)->rowOfField($this->databaseSelectedSettingIndex); - - if ($row === null) { - Console::cursor()->hide(); - return; - } - - $settingsLeft = $layout['innerX'] + $layout['categoryWidth'] + $layout['listWidth'] + ($layout['gutter'] * 2); - $settingsTop = $layout['innerY']; - Console::cursor()->show(); - Console::cursor()->moveTo( - $settingsLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $cursorOffset, - $settingsTop + 1 + $row, - ); - } - - /** - * Renders the live cursor for editing a hosted record field in the - * Inspector: the same caret as the Database settings pane, at the - * Inspector's own position, on the row the pane layout gives the field. - * - * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. - * @return void - */ - private function renderHostedEditCursor(array $layout): void - { - $fields = $this->getDatabaseSettingsFields(); - $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - $row = $this->recordPaneLayout($fields)->rowOfField($this->databaseSelectedSettingIndex); - - if (! is_array($field) || $row === null || $this->focusedPane !== self::FOCUS_INSPECTOR) { - Console::cursor()->hide(); - return; - } - - $leftText = sprintf('> %s: ', (string) ($field['label'] ?? 'Field')); - $contentWidth = $this->getWindowContentWidth($layout['rightWidth']); - $availableValueWidth = max(1, $contentWidth - mb_strwidth($leftText)); - $visibleStart = max(0, $this->databaseEditCursorIndex - $availableValueWidth + 1); - $visibleValue = mb_substr($this->databaseEditBuffer, $visibleStart, $availableValueWidth); - $visibleCursorIndex = max(0, min($this->databaseEditCursorIndex - $visibleStart, mb_strlen($visibleValue))); - $cursorOffset = min($contentWidth - 1, mb_strwidth($leftText . mb_substr($visibleValue, 0, $visibleCursorIndex))); - $inspectorLeft = 2 + $layout['leftWidth'] + $layout['centerWidth'] + ($layout['gutter'] * 2); - $inspectorTop = 5; - Console::cursor()->show(); - Console::cursor()->moveTo( - $inspectorLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $cursorOffset, - $inspectorTop + 1 + $row, - ); - } - - /** - * Creates the Database root window. - * - * @param array $layout The Database layout. - * @return EditorWindow - */ - private function createDatabaseRootWindow(array $layout): EditorWindow - { - return new EditorWindow( - title: 'Database', - help: 'Esc:Close ?:Help Ctrl+P:Palette Ctrl+S:Save', - position: ['x' => $layout['rootX'], 'y' => $layout['rootY']], - width: $layout['rootWidth'], - height: $layout['rootHeight'], - foregroundColor: Color::WHITE, - content: array_fill(0, max(1, $layout['rootHeight'] - 2), ''), - ); - } - - /** - * Creates the Database category window. - * - * @param array $layout The Database layout. - * @return EditorWindow - */ - private function createDatabaseCategoryWindow(array $layout): EditorWindow - { - $lines = []; - - foreach (DatabaseCatalog::all() as $index => $category) { - $prefix = $index === $this->databaseCategoryIndex ? '> ' : ' '; - $dirty = $this->isDatabaseCategoryDirty($category->key) ? ' *' : ''; - $lines[] = $prefix . $category->label . $dirty; + // Narrow enough that the frame list and preview cannot both have + // their minimum: share out what there is rather than draw past the + // edge. + if ($framesWidth + $previewWidth + $gutter > $rightWidth) { + $framesWidth = max(1, intdiv($rightWidth - $gutter, 2)); + $previewWidth = max(1, $rightWidth - $framesWidth - $gutter); } - return new EditorWindow( - title: 'Categories', - help: 'Tab:Next Pane', - position: ['x' => $layout['innerX'], 'y' => $layout['innerY']], - width: $layout['categoryWidth'], - height: $layout['innerHeight'], - foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_CATEGORIES), - content: $this->fitLines( - $lines, - $this->getWindowContentWidth($layout['categoryWidth']), - $layout['innerHeight'] - 2 - ), - ); - } - - /** - * Creates the Database animation list window. - * - * @param array $layout The Database layout. - * @return EditorWindow - */ - private function createDatabaseListWindow(array $layout): EditorWindow - { - $supportsEntries = $this->isActorsDatabaseSelected() - || $this->isClassesDatabaseSelected() - || $this->isSkillsDatabaseSelected() - || $this->isQuestsDatabaseSelected() - || $this->isAnimationsDatabaseSelected(); - - return new EditorWindow( - // The category dirty marker rides the title so categories whose - // entries carry no per-entry flag (animations, system) still - // show unsaved state where the author is looking. - title: $this->getSelectedDatabaseCategory() - . ($this->isDatabaseCategoryDirty($this->getSelectedDatabaseCategoryDefinition()->key) ? ' *' : '') - . $this->databaseFilter->describe(), - help: $supportsEntries - ? $this->fitHelp( - $layout['listWidth'], - 'Shift+A:New /:Filter Del:Delete', - 'Shift+A:New /:Filter Del', - 'Shift+A:New /:Filter', - '/:Filter', - ) - : '', - position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["gutter"], "y" => $layout["innerY"]], - width: $layout["listWidth"], - height: $layout["innerHeight"], - foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_LIST), - content: $this->fitLines( - $this->getDatabaseListLines(), - $this->getWindowContentWidth($layout["listWidth"]), - $layout["innerHeight"] - 2 - ), - ); - } - - /** - * Creates the Database settings window. - * - * @param array $layout The Database layout. - * @return EditorWindow - */ - private function createDatabaseSettingsWindow(array $layout): EditorWindow - { - return new EditorWindow( - // Inside a frame the title is the trail back out of it. - title: $this->databaseCommandFramePath === [] - ? 'General Settings' - : ($this->getSelectedRecordDatabase()?->describeFramePath($this->databaseCommandFramePath) - ?? ProjectRecordDatabase::describeFrame($this->databaseCommandFramePath)), - help: match (true) { - $this->referencePicker->isOpen() => $this->fitHelp( - $layout['settingsWidth'], - 'Enter:Choose Type:Filter Esc:Cancel', - 'Enter:Choose Esc:Cancel', - 'Enter:Choose', - ), - $this->databaseCommandFramePath !== [] - && ! $this->isDatabaseEditing - && ! $this->conditionEditor->isOpen() - && ! $this->affinityEditor->isOpen() => $this->fitHelp( - $layout['settingsWidth'], - 'Enter:Edit/Open Esc:Back Shift+O:Add Shift+X/Del:Remove', - 'Enter:Open Esc:Back Shift+O/Del:Add/Del', - 'Esc:Back ?:Help', - ), - $this->worldWriteEditor->isOpen() => $this->fitHelp( - $layout['settingsWidth'], - 'a:Add d:Delete t/T:Type n:Name x:Toggle v:Value Enter:Done Esc:Cancel', - 'a/d:Add/Del t:Type n:Name x:Toggle Enter:Done', - 'a/d:Add/Del t/n/x:Edit ?:Help', - '?:Help', - ), - $this->battleEntryPredicateEditor->isOpen() => $this->fitHelp( - $layout['settingsWidth'], - 'a:Add d:Delete n:Actor x/X:Presence Enter:Done Esc:Cancel', - 'a/d:Add/Del n:Actor x:Presence Enter:Done', - 'a/d:Add/Del n/x:Edit ?:Help', - '?:Help', - ), - $this->affinityEditor->isOpen() => $this->fitHelp( - $layout['settingsWidth'], - 'a:Add d:Delete n:Element x/X:Effect Enter:Done Esc:Cancel', - 'a/d:Add/Del n:Element x:Effect Enter:Done', - 'a/d:Add/Del n/x:Edit ?:Help', - '?:Help', - ), - $this->conditionEditor->isOpen() => $this->fitHelp( - $layout['settingsWidth'], - 'a:Add d:Del t:Type n:Name x:Value !:Not Enter:Done Esc:Cancel', - 'a:Add d:Del t:Type n:Name x:Value !:Not Enter:Done', - 'a/d:Add/Del t:Type n:Name x:Value !:Not', - 'a/d:Add/Del t/n/x:Edit ?:Help', - '?:Help', - ), - $this->isDatabaseEditing => 'Enter:Apply Esc:Cancel', - $this->hasDatabaseSubList() => $this->fitHelp( - $layout['settingsWidth'], - 'Enter:Edit Shift+O:Add Shift+X/Del:Remove', - 'Enter:Edit Shift+O:Add Del:Remove', - 'Enter:Edit Shift+O/Del:Add/Del', - 'Enter:Edit ?:Help', - 'Enter:Edit', - ), - default => 'Enter:Edit', - }, - position: ['x' => $layout['innerX'] + $layout['categoryWidth'] + $layout['listWidth'] + ($layout['gutter'] * 2), 'y' => $layout['innerY']], - width: $layout['settingsWidth'], - height: $layout['topHeight'], - foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_SETTINGS), - content: $this->fitLines( - $this->getDatabaseSettingsLines(), - $this->getWindowContentWidth($layout['settingsWidth']), - $layout['topHeight'] - 2 - ), - ); - } - - /** - * Creates the cue summary window. - * - * @param array $layout The Database layout. - * @return EditorWindow - */ - private function createDatabaseCueWindow(array $layout): EditorWindow - { - return new EditorWindow( - title: $this->isActorsDatabaseSelected() ? "Collections" : ($this->isClassesDatabaseSelected() ? "Experience Curve" : ($this->isSkillsDatabaseSelected() ? "Effects" : ($this->isQuestsDatabaseSelected() ? "Objectives" : ($this->isSystemDatabaseSelected() ? "Battle Settings" : ($this->isBattleEntryRulesDatabaseSelected() ? "Execution Order" : "SE and Flash Timing"))))), - help: $this->isQuestsDatabaseSelected() - ? $this->fitHelp($layout['cueWidth'], 'Shift+O:Add Shift+X:Del', 'Shift+O/X:Add/Del', '?:Help') - : '', - position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + $layout["settingsWidth"] + ($layout["gutter"] * 3), "y" => $layout["innerY"]], - width: $layout["cueWidth"], - height: $layout["topHeight"], - foregroundColor: Color::WHITE, - content: $this->fitLines( - $this->getDatabaseCueLines(), - $this->getWindowContentWidth($layout["cueWidth"]), - $layout["topHeight"] - 2 - ), - ); - } - - /** - * Creates the frame list window. - * - * @param array $layout The Database layout. - * @return EditorWindow - */ - private function createDatabaseFramesWindow(array $layout): EditorWindow - { - return new EditorWindow( - title: $this->isActorsDatabaseSelected() ? "Stats" : ($this->isClassesDatabaseSelected() ? "Stat Curves" : ($this->isSkillsDatabaseSelected() ? "Scope" : ($this->isQuestsDatabaseSelected() ? "Rewards" : ($this->isSystemDatabaseSelected() ? "Notes" : "Frames")))), - help: $this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected() ? "" : "Up/Down:Frame", - position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + ($layout["gutter"] * 2), "y" => $layout["innerY"] + $layout["topHeight"] + $layout["gutter"]], - width: $layout["framesWidth"], - height: $layout["previewHeight"], - foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_FRAMES), - content: $this->fitLines( - $this->framesPaneFitsContent() - ? $this->wrapLines($this->getDatabaseFrameLines(), $this->getWindowContentWidth($layout["framesWidth"])) - : $this->getDatabaseFrameLines(), - $this->getWindowContentWidth($layout["framesWidth"]), - $layout["previewHeight"] - 2 - ), - ); + return [ + 'rootX' => $rootX, + 'rootY' => $rootY, + 'rootWidth' => $rootWidth, + 'rootHeight' => $rootHeight, + 'innerX' => $innerX, + 'innerY' => $innerY, + 'innerWidth' => $innerWidth, + 'innerHeight' => $innerHeight, + 'gutter' => $gutter, + 'categoryWidth' => $categoryWidth, + 'listWidth' => $listWidth, + 'rightWidth' => $rightWidth, + 'topHeight' => $topHeight, + 'framesWidth' => $framesWidth, + 'previewWidth' => $previewWidth, + 'previewHeight' => $previewHeight, + 'settingsWidth' => $settingsWidth, + 'cueWidth' => $cueWidth, + ]; } /** - * Creates the animation preview window. + * Renders the Database overlay. * - * @param array $layout The Database layout. - * @return EditorWindow + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The base editor layout. + * @return void */ - private function createDatabasePreviewWindow(array $layout): EditorWindow + private function renderDatabaseOverlay(array $layout): void { - return new EditorWindow( - title: "Preview", - help: $this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected() ? "" : "Type:Paint Shift+P:Play", - position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + $layout["framesWidth"] + ($layout["gutter"] * 3), "y" => $layout["innerY"] + $layout["topHeight"] + $layout["gutter"]], - width: $layout["previewWidth"], - height: $layout["previewHeight"], - foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_PREVIEW), - content: ($this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected()) - ? $this->fitLines( - $this->getDatabasePreviewLines(), - $this->getWindowContentWidth($layout["previewWidth"]), - $layout["previewHeight"] - 2 - ) - : array_fill(0, max(1, $layout["previewHeight"] - 2), ""), - ); + $this->databaseScreen->drawAll($this->resolveDatabaseLayout($layout), true); } /** - * Returns the list lines for the active Database category. + * Marks the whole Database overlay for repainting on the next render pass. * - * @return string[] + * @param bool $includeRoot Whether to include the outer Database frame. + * @return void */ - private function getDatabaseListLines(): array + private function renderDatabaseArea(bool $includeRoot = false): void { - if ($this->isActorsDatabaseSelected()) { - return $this->getDatabaseActorListLines(); - } - - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassListLines(); - } - - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillListLines(); - } - - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestListLines(); - } - - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemListLines(); - } - - if ($this->isAnimationsDatabaseSelected()) { - return $this->getDatabaseAnimationListLines(); - } - - if ($this->getSelectedRecordDatabase() instanceof ProjectRecordDatabase) { - return $this->getDatabaseRecordListLines(); - } - - $category = $this->getSelectedDatabaseCategoryDefinition(); - - return [ - $category->description, - '', - 'Editor coming soon.', - ]; + $this->databaseScreen->markAllDirty($includeRoot); } /** - * Returns the list lines for a schema-driven category. - * - * Every category backed by a record schema lists the same way: the - * project's entries, marked where one is unsaved, narrowed by the filter, - * and telling an author how to add the first one when there are none. + * Paints the whole Database overlay immediately (full-redraw path). * - * @return string[] + * @param bool $includeRoot Whether to include the outer Database frame. + * @return void */ - private function getDatabaseRecordListLines(): array + private function drawDatabaseArea(bool $includeRoot = false): void { - $database = $this->getSelectedRecordDatabase(); - - if (! $database instanceof ProjectRecordDatabase) { - return []; - } - - $labels = $database->getEntryLabels(); - $category = $this->getSelectedDatabaseCategoryDefinition(); - - if ($labels === []) { - return $database->isEditable() - ? [sprintf('No %s yet.', $category->label), '', 'Shift+A to create one.'] - : [ - sprintf('No %s in this project.', strtolower($category->label)), - '', - $database->getReadOnlyReason() ?? '', - ]; - } - - $selectedIndex = $this->getSelectedRecordIndex(); - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $label = $labels[$index] ?? null; - - if ($label === null) { - continue; - } - - $prefix = $index === $selectedIndex ? '> ' : ' '; - $dirty = $database->getRecordByIndex($index)?->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%s%s', $prefix, $label, $dirty); - } - - if ($lines === []) { - return ['No matches.']; - } - - // A read-only category says so once, rather than leaving an author - // wondering why nothing they type sticks. - if (! $database->isEditable() && ($reason = $database->getReadOnlyReason()) !== null) { - $lines[] = ''; - $lines[] = $reason; - } - - return $lines; + $this->databaseScreen->drawAll(includeRoot: $includeRoot); } /** - * Returns the actor list lines. + * Marks panes whose visual state depends on Database focus. * - * @return string[] + * @return void */ - private function getDatabaseActorListLines(): array + private function renderDatabaseFocusDependentArea(): void { - $actors = $this->workspace?->actorDatabase->getActors() ?? []; - - if ($actors === []) { - return ['No actors yet.', '', 'Shift+A to create one.']; - } - - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $actor = $actors[$index] ?? null; - - if (! $actor instanceof ProjectActor) { - continue; - } - - $prefix = $index === $this->databaseSelectedActorIndex ? '> ' : ' '; - $dirty = $actor->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%s%s', $prefix, $actor->getName(), $dirty); - } - - return $lines === [] ? ['No matches.'] : $lines; + $this->markDatabasePanesDirty(['categories', 'list', 'settings', 'frames', 'preview']); } /** - * Returns the class list lines. + * Queues specific Database panes for repainting on the next render pass. * - * @return string[] + * @param string[] $panes The pane identifiers to repaint. + * @param array|null $layout Ignored; the flush recomputes the layout (kept for signature compatibility). + * @param bool $includeRoot Whether to repaint the outer Database frame. + * @return void */ - private function getDatabaseClassListLines(): array + private function renderDatabasePanes(array $panes, ?array $layout = null, bool $includeRoot = false): void { - $classes = $this->workspace?->classDatabase->getClasses() ?? []; + if ($this->isCutscenesOpen) { + // The sub-editors ask for Database panes; in the Cutscenes screen + // the same request means its own panes of the same names. + $this->cutscenesScreen->markDirty( + array_values(array_intersect($panes, \Ichiloto\Editor\UI\CutscenesScreen::PANES)) ?: [\Ichiloto\Editor\UI\CutscenesScreen::PANE_SETTINGS], + $includeRoot, + ); - if ($classes === []) { - return ['No classes yet.', '', 'Shift+A to create one.']; + return; } - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $class = $classes[$index] ?? null; - - if (! $class instanceof ProjectClass) { - continue; - } + if ($this->isNpcInspectorHosting()) { + // The sub-editors ask for Database panes; in the Inspector the + // same request means the inspector and canvas. + $this->requestFullRender(); - $prefix = $index === $this->databaseSelectedClassIndex ? '> ' : ' '; - $dirty = $class->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%04d %s%s', $prefix, $class->id, $class->getName(), $dirty); + return; } - return $lines === [] ? ['No matches.'] : $lines; + $this->markDatabasePanesDirty($panes, $includeRoot); } + /** - * Returns the skill list lines. + * Unions panes into the Database dirty set. * - * @return string[] + * @param string[] $panes The pane identifiers to queue. + * @param bool $includeRoot Whether to queue the outer Database frame. + * @return void */ - private function getDatabaseSkillListLines(): array + private function markDatabasePanesDirty(array $panes, bool $includeRoot = false): void { - $skills = $this->workspace?->skillDatabase->getSkills() ?? []; - - if ($skills === []) { - return ["No skills yet.", "", "Shift+A to create one."]; - } - - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $skill = $skills[$index] ?? null; - - if (! $skill instanceof ProjectSkill) { - continue; - } - - $prefix = $index === $this->databaseSelectedSkillIndex ? "> " : " "; - $dirty = $skill->isDirty() ? " *" : ""; - $lines[] = sprintf("%s%04d %s%s", $prefix, $skill->id, $skill->getName(), $dirty); - } - - return $lines; + $this->databaseScreen->markDirty($panes, $includeRoot); } - /** - * Returns what an objective of the given type points at. - * - * A flag names a switch or story event the world sets, which is authored - * text rather than a record, so it stays typed. + * Paints specific Database panes without clearing the full editor. * - * @param string $type The objective type. - * @return string|null The kind of reference, or null when it is free text. + * @param string[] $panes The pane identifiers to render. + * @param array|null $layout The optional Database layout. + * @param bool $includeRoot Whether to render the outer Database frame. + * @return void */ - private static function questObjectiveReference(string $type): ?string + private function drawDatabasePanes(array $panes, ?array $layout = null, bool $includeRoot = false): void { - return match (QuestObjectiveType::tryFrom($type)) { - // Collecting is not limited to consumables: a quest may ask for - // a weapon or a piece of armor, and the runtime resolves all - // three from one catalogue. - QuestObjectiveType::COLLECT => 'inventory', - QuestObjectiveType::DEFEAT => 'enemies', - QuestObjectiveType::REACH_MAP => 'maps', - QuestObjectiveType::TALK_TO => 'actors', - default => null, - }; + $this->databaseScreen->draw($panes, $layout, $includeRoot); } /** - * Returns the quest list lines. + * Renders the live cursor for editing Database settings. * - * @return string[] + * @param array $layout The Database layout. + * @return void */ - private function getDatabaseQuestListLines(): array + private function renderDatabaseEditCursor(array $layout): void { - $quests = $this->workspace?->questDatabase->getQuests() ?? []; + $fields = $this->getDatabaseSettingsFields(); + $field = $fields[$this->databaseSelectedSettingIndex] ?? null; - if ($quests === []) { - return ['No quests yet.', '', 'Shift+A to create one.']; + if (! is_array($field)) { + Console::cursor()->hide(); + return; } - $lines = []; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $quest = $quests[$index] ?? null; - - if (! $quest instanceof ProjectQuest) { - continue; - } + $label = (string) ($field['label'] ?? 'Field'); + $leftText = sprintf('> %s: ', $label); + $settingsContentWidth = $this->getWindowContentWidth($layout['settingsWidth']); + $availableValueWidth = max(1, $settingsContentWidth - mb_strwidth($leftText)); + $visibleStart = max(0, $this->databaseEditCursorIndex - $availableValueWidth + 1); + $visibleValue = mb_substr($this->databaseEditBuffer, $visibleStart, $availableValueWidth); + $visibleCursorIndex = max(0, min($this->databaseEditCursorIndex - $visibleStart, mb_strlen($visibleValue))); + $cursorOffset = min( + $settingsContentWidth - 1, + mb_strwidth($leftText . mb_substr($visibleValue, 0, $visibleCursorIndex)) + ); + $row = $this->recordPaneLayout($fields)->rowOfField($this->databaseSelectedSettingIndex); - $prefix = $index === $this->databaseSelectedQuestIndex ? '> ' : ' '; - $dirty = $quest->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%s%s', $prefix, $quest->getName(), $dirty); + if ($row === null) { + Console::cursor()->hide(); + return; } - return $lines === [] ? ['No matches.'] : $lines; + $settingsLeft = $layout['innerX'] + $layout['categoryWidth'] + $layout['listWidth'] + ($layout['gutter'] * 2); + $settingsTop = $layout['innerY']; + Console::cursor()->show(); + Console::cursor()->moveTo( + $settingsLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $cursorOffset, + $settingsTop + 1 + $row, + ); } /** - * Returns the animation list lines. - * - * @return string[] - */ - private function getDatabaseAnimationListLines(): array - { - $animations = $this->workspace?->animationDatabase->getAnimations() ?? []; - - if ($animations === []) { - return ['No animations yet.', '', 'Shift+A to create one.']; - } - - $lines = []; - $isDirty = $this->workspace?->animationDatabase->isDirty() === true; - - foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { - $animation = $animations[$index] ?? null; - - if (! $animation instanceof Animation) { - continue; - } + * Renders the live cursor for editing a hosted record field in the + * Inspector: the same caret as the Database settings pane, at the + * Inspector's own position, on the row the pane layout gives the field. + * + * @param array{width: int, height: int, leftWidth: int, rightWidth: int, gutter: int, centerWidth: int, contentHeight: int} $layout The active layout. + * @return void + */ + private function renderHostedEditCursor(array $layout): void + { + $fields = $this->getDatabaseSettingsFields(); + $field = $fields[$this->databaseSelectedSettingIndex] ?? null; + $row = $this->recordPaneLayout($fields)->rowOfField($this->databaseSelectedSettingIndex); - $prefix = $index === $this->databaseSelectedAnimationIndex ? '> ' : ' '; - // The animation database tracks dirtiness per file, not per - // entry, so the marker is honest about the whole list. - $lines[] = sprintf('%s%04d %s%s', $prefix, $animation->id, $animation->name, $isDirty ? ' *' : ''); + if (! is_array($field) || $row === null || $this->focusedPane !== self::FOCUS_INSPECTOR) { + Console::cursor()->hide(); + return; } - return $lines === [] ? ['No matches.'] : $lines; + $leftText = sprintf('> %s: ', (string) ($field['label'] ?? 'Field')); + $contentWidth = $this->getWindowContentWidth($layout['rightWidth']); + $availableValueWidth = max(1, $contentWidth - mb_strwidth($leftText)); + $visibleStart = max(0, $this->databaseEditCursorIndex - $availableValueWidth + 1); + $visibleValue = mb_substr($this->databaseEditBuffer, $visibleStart, $availableValueWidth); + $visibleCursorIndex = max(0, min($this->databaseEditCursorIndex - $visibleStart, mb_strlen($visibleValue))); + $cursorOffset = min($contentWidth - 1, mb_strwidth($leftText . mb_substr($visibleValue, 0, $visibleCursorIndex))); + $inspectorLeft = 2 + $layout['leftWidth'] + $layout['centerWidth'] + ($layout['gutter'] * 2); + $inspectorTop = 5; + Console::cursor()->show(); + Console::cursor()->moveTo( + $inspectorLeft + 1 + self::WINDOW_HORIZONTAL_PADDING + $cursorOffset, + $inspectorTop + 1 + $row, + ); } /** - * Returns the system list lines. + * Creates the Database root window. * - * @return string[] + * @param array $layout The Database layout. + * @return EditorWindow */ - private function getDatabaseSystemListLines(): array + private function createDatabaseRootWindow(array $layout): EditorWindow { - $dirty = $this->workspace?->systemDatabase->isDirty() === true ? ' *' : ''; - - return [sprintf('> Project System%s', $dirty)]; + return new EditorWindow( + title: 'Database', + help: 'Esc:Close ?:Help Ctrl+P:Palette Ctrl+S:Save', + position: ['x' => $layout['rootX'], 'y' => $layout['rootY']], + width: $layout['rootWidth'], + height: $layout['rootHeight'], + foregroundColor: Color::WHITE, + content: array_fill(0, max(1, $layout['rootHeight'] - 2), ''), + ); } /** - * Returns the current Database settings lines. + * Creates the Database category window. * - * @return string[] + * @param array $layout The Database layout. + * @return EditorWindow */ - private function getDatabaseSettingsLines(): array + private function createDatabaseCategoryWindow(array $layout): EditorWindow { - $fields = $this->getDatabaseSettingsFields(); - - if ($fields === []) { - $category = $this->getSelectedDatabaseCategoryDefinition(); - - if (! $category->isImplemented) { - return [ - 'Planned Editor', - '', - $category->description, - '', - 'This section will live in the Database window too.', - ]; - } - - return ['Select an entry to edit settings.']; - } - - if ($this->referencePicker->isOpen()) { - return $this->buildReferencePickerRows(); - } - - if ($this->conditionEditor->isOpen()) { - return $this->buildConditionEditorRows(); - } - - if ($this->affinityEditor->isOpen()) { - return $this->buildAffinityEditorRows(); - } - - if ($this->worldWriteEditor->isOpen()) { - return $this->buildWorldWriteEditorRows(); - } + $lines = []; - if ($this->battleEntryPredicateEditor->isOpen()) { - return $this->buildBattleEntryPredicateEditorRows(); + foreach (DatabaseCatalog::all() as $index => $category) { + $prefix = $index === $this->databaseCategoryIndex ? '> ' : ' '; + $dirty = $this->isDatabaseCategoryDirty($category->key) ? ' *' : ''; + $lines[] = $prefix . $category->label . $dirty; } - return $this->recordPaneLayout($fields)->visibleLines(); + return new EditorWindow( + title: 'Categories', + help: 'Tab:Next Pane', + position: ['x' => $layout['innerX'], 'y' => $layout['innerY']], + width: $layout['categoryWidth'], + height: $layout['innerHeight'], + foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_CATEGORIES), + content: $this->fitLines( + $lines, + $this->getWindowContentWidth($layout['categoryWidth']), + $layout['innerHeight'] - 2 + ), + ); } /** - * Returns the record pane's width, height and cursor visibility: the - * Database settings pane's own, or the Inspector's while it hosts the - * pane. Every row builder of the pane measures against these. + * Creates the Database animation list window. * - * @return array{width: int, rows: int, cursorShown: bool} + * @param array $layout The Database layout. + * @return EditorWindow */ - private function recordPaneMetrics(): array + private function createDatabaseListWindow(array $layout): EditorWindow { - $layout = $this->resolveLayout(); - - if ($this->isCutscenesOpen) { - return $this->cutsceneRecordPaneMetrics(); - } - - if ($this->isNpcInspectorHosting() || $this->mapConditionField !== null) { - return [ - 'width' => $this->getWindowContentWidth($layout['rightWidth']), - 'rows' => max(1, $layout['contentHeight'] - 2), - 'cursorShown' => $this->focusedPane === self::FOCUS_INSPECTOR, - ]; - } - - $database = $this->resolveDatabaseLayout($layout); + // Every category that can take a new entry says how; record categories + // ask their database rather than being listed here. + $supportsEntries = $this->isActorsDatabaseSelected() + || $this->isSkillsDatabaseSelected() + || $this->isQuestsDatabaseSelected() + || ($this->getSelectedRecordDatabase()?->supportsRecordCreation() ?? false); - return [ - 'width' => $this->getWindowContentWidth($database['settingsWidth']), - 'rows' => max(1, $database['topHeight'] - 2), - 'cursorShown' => $this->databaseFocus === self::DATABASE_FOCUS_SETTINGS, - ]; + return new EditorWindow( + // The category dirty marker rides the title so categories whose + // entries carry no per-entry flag (animations, system) still + // show unsaved state where the author is looking. + title: $this->getSelectedDatabaseCategory() + . ($this->isDatabaseCategoryDirty($this->getSelectedDatabaseCategoryDefinition()->key) ? ' *' : '') + . $this->databaseFilter->describe(), + help: $supportsEntries + ? $this->fitHelp( + $layout['listWidth'], + 'Shift+A:New /:Filter Del:Delete', + 'Shift+A:New /:Filter Del', + 'Shift+A:New /:Filter', + '/:Filter', + ) + : '', + position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["gutter"], "y" => $layout["innerY"]], + width: $layout["listWidth"], + height: $layout["innerHeight"], + foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_LIST), + content: $this->fitLines( + $this->getDatabaseListLines(), + $this->getWindowContentWidth($layout["listWidth"]), + $layout["innerHeight"] - 2 + ), + ); } /** - * Lays out the record pane: each field as its wrapped lines, the field - * being edited as one horizontally scrolled line, scrolled so the - * selected field stays in view. The visible lines, the edit caret and - * anything else that turns a field into a row all read this one layout. + * Creates the Database settings window. * - * @param array>|null $fields The fields, or null to read them. - * @return SettingsPaneLayout The layout. + * @param array $layout The Database layout. + * @return EditorWindow */ - private function recordPaneLayout(?array $fields = null): SettingsPaneLayout + private function createDatabaseSettingsWindow(array $layout): EditorWindow { - $fields ??= $this->getDatabaseSettingsFields(); - $metrics = $this->recordPaneMetrics(); - $rows = []; - - foreach ($fields as $index => $field) { - $prefix = $metrics['cursorShown'] && $index === $this->databaseSelectedSettingIndex ? '> ' : ' '; - $label = (string) ($field['label'] ?? 'Field'); - $value = (string) ($field['value'] ?? ''); - $editing = $this->isDatabaseEditing && $index === $this->databaseSelectedSettingIndex; - - if ($editing) { - // The edited row keeps its single-line window around the - // caret, exactly as before, so the caret arithmetic holds. - $availableValueWidth = max(1, $metrics['width'] - mb_strwidth(sprintf('%s%s: ', $prefix, $label))); - $visibleStart = max(0, $this->databaseEditCursorIndex - $availableValueWidth + 1); - $value = mb_substr($this->databaseEditBuffer, $visibleStart, $availableValueWidth); - } - - $rows[] = [ - 'prefix' => $prefix, - 'label' => $label, - 'value' => $value, - 'editable' => $editing || $this->isInspectorFieldInteractive($field), - 'singleLine' => $editing, - ]; - } - - return SettingsPaneLayout::layout($rows, $metrics['width'], $metrics['rows'], $this->databaseSelectedSettingIndex); + return new EditorWindow( + // Inside a frame the title is the trail back out of it. + title: $this->databaseCommandFramePath === [] + ? 'General Settings' + : ($this->getSelectedRecordDatabase()?->describeFramePath($this->databaseCommandFramePath) + ?? ProjectRecordDatabase::describeFrame($this->databaseCommandFramePath)), + help: match (true) { + $this->referencePicker->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'Enter:Choose Type:Filter Esc:Cancel', + 'Enter:Choose Esc:Cancel', + 'Enter:Choose', + ), + $this->databaseCommandFramePath !== [] + && ! $this->isDatabaseEditing + && ! $this->conditionEditor->isOpen() + && ! $this->affinityEditor->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'Enter:Edit/Open Esc:Back Shift+O:Add Shift+X/Del:Remove', + 'Enter:Open Esc:Back Shift+O/Del:Add/Del', + 'Esc:Back ?:Help', + ), + $this->worldWriteEditor->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'a:Add d:Delete t/T:Type n:Name x:Toggle v:Value Enter:Done Esc:Cancel', + 'a/d:Add/Del t:Type n:Name x:Toggle Enter:Done', + 'a/d:Add/Del t/n/x:Edit ?:Help', + '?:Help', + ), + $this->battleEntryPredicateEditor->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'a:Add d:Delete n:Actor x/X:Presence Enter:Done Esc:Cancel', + 'a/d:Add/Del n:Actor x:Presence Enter:Done', + 'a/d:Add/Del n/x:Edit ?:Help', + '?:Help', + ), + $this->animationConversion->isOpen() && $this->animationConversion->getMode() === AnimationConversionEditor::MODE_REVIEW => $this->fitHelp( + $layout['settingsWidth'], + 'Up/Down:Read Enter:Write these files Esc:Back to choices', + 'Up/Down:Read Enter:Write Esc:Back', + 'Enter:Write Esc:Back', + ), + $this->animationConversion->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'Up/Down:Row Left/Right:Choose Type:Text Enter:Preview Esc:Cancel', + 'Left/Right:Choose Enter:Preview Esc:Cancel', + 'Enter:Preview Esc:Cancel', + '?:Help', + ), + $this->affinityEditor->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'a:Add d:Delete n:Element x/X:Effect Enter:Done Esc:Cancel', + 'a/d:Add/Del n:Element x:Effect Enter:Done', + 'a/d:Add/Del n/x:Edit ?:Help', + '?:Help', + ), + $this->conditionEditor->isOpen() => $this->fitHelp( + $layout['settingsWidth'], + 'a:Add d:Del t:Type n:Name e:Event x:Value !:Not Enter:Done Esc:Cancel', + 'a:Add d:Del t:Type n:Name e:Event x:Value !:Not Enter:Done', + 'a/d:Add/Del t:Type n:Name e:Event x:Value !:Not', + 'a/d:Add/Del t/n/x:Edit ?:Help', + '?:Help', + ), + $this->isDatabaseEditing => 'Enter:Apply Esc:Cancel', + $this->getSelectedDatabaseCategoryDefinition()->key === 'troops' => $this->fitHelp( + $layout['settingsWidth'], + 'Enter:Edit Shift+O:Add Shift+X/Del:Remove Shift+C:Clear Shift+N:Auto-name', + 'Enter:Edit Shift+O:Add Del:Remove Shift+C:Clear Shift+N:Name', + 'Enter:Edit Shift+O/Del:Add/Del Shift+C/N', + 'Enter:Edit ?:Help', + 'Enter:Edit', + ), + $this->hasDatabaseSubList() => $this->fitHelp( + $layout['settingsWidth'], + 'Enter:Edit Shift+O:Add Shift+X/Del:Remove', + 'Enter:Edit Shift+O:Add Del:Remove', + 'Enter:Edit Shift+O/Del:Add/Del', + 'Enter:Edit ?:Help', + 'Enter:Edit', + ), + default => 'Enter:Edit', + }, + position: ['x' => $layout['innerX'] + $layout['categoryWidth'] + $layout['listWidth'] + ($layout['gutter'] * 2), 'y' => $layout['innerY']], + width: $layout['settingsWidth'], + height: $layout['topHeight'], + foregroundColor: $this->resolveDatabasePaneColor(self::DATABASE_FOCUS_SETTINGS), + content: $this->fitLines( + $this->getDatabaseSettingsLines(), + $this->getWindowContentWidth($layout['settingsWidth']), + $layout['topHeight'] - 2 + ), + ); } /** - * Returns the rows shown while a reference is being chosen. + * Creates the cue summary window. * - * @return string[] The rows. + * @param array $layout The Database layout. + * @return EditorWindow */ - private function buildReferencePickerRows(): array + private function createDatabaseCueWindow(array $layout): EditorWindow { - $matches = $this->referencePicker->matches(); - $selectedIndex = $this->referencePicker->selectedIndex(); - $filter = $this->referencePicker->filter(); - - $lines = [ - sprintf('%s: %s', $this->referencePicker->label(), $filter === '' ? 'type to filter' : $filter), - '', - ]; - - if ($matches === []) { - $lines[] = ' Nothing matches.'; - - return $lines; - } - - foreach ($matches as $index => $value) { - $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $this->referencePicker->labelFor($value)); - } - - // Two header lines, then the rows in whatever height the pane has. - $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); - - return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; + return new EditorWindow( + title: $this->getDatabasePanes()['cue']['title'] ?? 'SE and Flash Timing', + help: $this->isQuestsDatabaseSelected() + ? $this->fitHelp($layout['cueWidth'], 'Shift+O:Add Shift+X:Del', 'Shift+O/X:Add/Del', '?:Help') + : '', + position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + $layout["settingsWidth"] + ($layout["gutter"] * 3), "y" => $layout["innerY"]], + width: $layout["cueWidth"], + height: $layout["topHeight"], + foregroundColor: Color::WHITE, + content: $this->fitLines( + $this->getDatabaseCueLines(), + $this->getWindowContentWidth($layout["cueWidth"]), + $layout["topHeight"] - 2 + ), + ); } /** - * Returns the cue summary lines for the active Database entry. + * Creates the frame list window. * - * @return string[] + * @param array $layout The Database layout. + * @return EditorWindow */ - private function getDatabaseCueLines(): array + private function createDatabaseFramesWindow(array $layout): EditorWindow { - if ($this->isActorsDatabaseSelected()) { - return $this->getDatabaseActorCollectionLines(); - } - - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassExperienceLines(); - } - - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillCueLines(); - } - - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestCueLines(); - } - - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemCueLines(); - } - - if ($this->isBattleEntryRulesDatabaseSelected()) { - return $this->getDatabaseBattleEntryCueLines(); - } - - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - $category = $this->getSelectedDatabaseCategoryDefinition(); - - if (! $category->isImplemented) { - return ['No timing data yet.']; - } - - return ['No animation selected.']; - } - - $lines = ['No. SE Flash']; - $hasCue = false; - - for ($frameIndex = 1; $frameIndex <= $animation->maxFrames; $frameIndex++) { - $cue = $animation->getCue($frameIndex); - - if (! $cue instanceof AnimationCue || $cue->isEmpty()) { - continue; - } - - $hasCue = true; - $lines[] = sprintf( - '#%03d %-8s %s', - $frameIndex, - $cue->soundEffect !== '' ? $cue->soundEffect : '-', - $cue->flashColor !== null - ? sprintf('%s (%d)', ucfirst($cue->flashColor), $cue->flashDurationFrames) - : '-' - ); - } - - if (! $hasCue) { - $lines[] = ''; - $lines[] = 'No cues on this animation.'; - } - - return $lines; + return new EditorWindow( + title: $this->getDatabasePanes()['frames']['title'] ?? 'Frames', + help: $this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected() ? "" : "Up/Down:Frame", + position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + ($layout["gutter"] * 2), "y" => $layout["innerY"] + $layout["topHeight"] + $layout["gutter"]], + width: $layout["framesWidth"], + height: $layout["previewHeight"], + foregroundColor: Color::WHITE, + content: $this->fitLines( + $this->framesPaneFitsContent() + ? $this->wrapLines($this->getDatabaseFrameLines(), $this->getWindowContentWidth($layout["framesWidth"])) + : $this->getDatabaseFrameLines(), + $this->getWindowContentWidth($layout["framesWidth"]), + $layout["previewHeight"] - 2 + ), + ); } /** - * Returns the system battle summary lines. + * Creates the animation preview window. * - * @return string[] + * @param array $layout The Database layout. + * @return EditorWindow */ - private function getDatabaseSystemCueLines(): array + private function createDatabasePreviewWindow(array $layout): EditorWindow { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No system settings loaded.']; - } - - $system = $this->workspace->systemDatabase; - - return [ - sprintf('Engine: %s', $system->getBattleEngine()), - sprintf('ATB Mode: %s', $system->getAtbMode()), - sprintf('Base Fill Rate: %d', $system->getAtbBaseFillRate()), - sprintf('Speed Factor: %d%%', $system->getAtbSpeedFactorPercent()), - ]; + return new EditorWindow( + title: "Preview", + help: $this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected() ? "" : "Type:Paint Shift+P:Play", + position: ["x" => $layout["innerX"] + $layout["categoryWidth"] + $layout["listWidth"] + $layout["framesWidth"] + ($layout["gutter"] * 3), "y" => $layout["innerY"] + $layout["topHeight"] + $layout["gutter"]], + width: $layout["previewWidth"], + height: $layout["previewHeight"], + foregroundColor: Color::WHITE, + content: ($this->isActorsDatabaseSelected() || $this->isClassesDatabaseSelected() || $this->isSkillsDatabaseSelected() || $this->isQuestsDatabaseSelected() || $this->isSystemDatabaseSelected()) + ? $this->fitLines( + $this->getDatabasePreviewLines(), + $this->getWindowContentWidth($layout["previewWidth"]), + $layout["previewHeight"] - 2 + ) + : array_fill(0, max(1, $layout["previewHeight"] - 2), ""), + ); } /** - * Returns the class experience summary lines. + * Returns the list lines for the active Database category. * * @return string[] */ - private function getDatabaseClassExperienceLines(): array + private function getDatabaseListLines(): array { - $class = $this->getSelectedClass(); - - if (! $class instanceof ProjectClass) { - return ['No class selected.']; + if ($this->isActorsDatabaseSelected()) { + return $this->getDatabaseActorListLines(); } - $curve = $class->getExperienceCurve(); - - return [ - sprintf('Base: %d', $curve['baseValue']), - sprintf('Extra: %d', $curve['extraValue']), - sprintf('Accel A: %d', $curve['accelerationA']), - sprintf('Accel B: %d', $curve['accelerationB']), - '', - sprintf('Initial Lv: %d', $class->getInitialLevel()), - sprintf('Max Lv: %d', $class->getMaxLevel()), - sprintf('Traits: %d', count($class->getTraits())), - ]; - } - - /** - * Returns the actor collection summary lines. - * - * @return string[] - */ - private function getDatabaseActorCollectionLines(): array - { - $actor = $this->getSelectedActor(); - - if (! $actor instanceof ProjectActor) { - return ['No actor selected.']; + if ($this->getSelectedRecordDatabase() instanceof ProjectRecordDatabase) { + return $this->getDatabaseRecordListLines(); } - $abilities = $actor->getAbilities(); - $magic = $actor->getMagic(); + $category = $this->getSelectedDatabaseCategoryDefinition(); return [ - 'Abilities', - sprintf('Learned: %d', count($abilities['learned'] ?? [])), - sprintf('Learnables: %d', count($abilities['learnables'] ?? [])), - sprintf('Sort: %s', (string) ($abilities['sortOrder'] ?? 'A-Z')), + $category->description, '', - 'Magic', - sprintf('Learned: %d', count($magic['learned'] ?? [])), - sprintf('Learnables: %d', count($magic['learnables'] ?? [])), - sprintf('Sort: %s', (string) ($magic['sortOrder'] ?? 'A-Z')), + 'Editor coming soon.', ]; } /** - * Returns the quest objective summary lines. + * Returns the list lines for a schema-driven category. * - * @return string[] - */ - private function getDatabaseQuestCueLines(): array - { - $quest = $this->getSelectedQuest(); - - if (! $quest instanceof ProjectQuest) { - return ['No quest selected.']; - } - - return $quest->getObjectiveSummaryLines(); - } - - /** - * Returns the skill effect summary lines. + * Every category backed by a record schema lists the same way: the + * project's entries, marked where one is unsaved, narrowed by the filter, + * and telling an author how to add the first one when there are none. * * @return string[] */ - private function getDatabaseSkillCueLines(): array + private function getDatabaseRecordListLines(): array { - $skill = $this->getSelectedSkill(); + $database = $this->getSelectedRecordDatabase(); - if (! $skill instanceof ProjectSkill) { - return ["No skill selected."]; + if (! $database instanceof ProjectRecordDatabase) { + return []; } - return $skill->getEffectSummaryLines(); - } - + $labels = $database->getEntryLabels(); + $category = $this->getSelectedDatabaseCategoryDefinition(); - /** - * Returns the execution-order lines for the battle-entry rules cue. - * - * The runtime runs matching rules in priority then declaration order; - * this presents that deterministic order beside the settings pane, with - * the selected rule marked. - * - * @return string[] - */ - private function getDatabaseBattleEntryCueLines(): array - { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No project loaded.']; + if ($labels === []) { + return $database->isEditable() + ? [sprintf('No %s yet.', $category->label), '', 'Shift+A to create one.'] + : [ + sprintf('No %s in this project.', strtolower($category->label)), + '', + $database->getReadOnlyReason() ?? '', + ]; } - $database = $this->workspace->getRecordDatabase(self::DATABASE_CATEGORY_BATTLE_ENTRY_RULES); + $selectedIndex = $this->getSelectedRecordIndex(); + $lines = []; - if (! $database instanceof ProjectRecordDatabase) { - return ['No rules loaded.']; - } + foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { + $label = $labels[$index] ?? null; - $rules = []; + if ($label === null) { + continue; + } - foreach ($database->getRecords() as $index => $record) { - $priority = $record->get('priority'); - $rules[] = [ - 'index' => $index, - 'priority' => is_int($priority) ? $priority : 0, - 'id' => trim(strval($record->get('id') ?? '')) ?: '(no id)', - ]; + $prefix = $index === $selectedIndex ? '> ' : ' '; + $record = $database->getRecordByIndex($index); + $dirty = $record?->isDirty() ? ' *' : ''; + // Entries identified by a number (classes, animations), or kept in + // numbered files (skills), list it before their name. + $identity = $database->schema->identityKey === null ? null : $record?->get($database->schema->identityKey); + $number = match (true) { + is_int($identity) => sprintf('%04d ', $identity), + $database->schema->numberedFiles && preg_match('/\A(\d+)-/', (string) $record?->recordId, $match) === 1 => $match[1] . ' ', + default => '', + }; + $lines[] = sprintf('%s%s%s%s', $prefix, $number, $label, $dirty); } - if ($rules === []) { - return ['No rules yet.', '', 'Battles begin unchanged.']; + if ($lines === []) { + return ['No matches.']; } - usort( - $rules, - static fn(array $left, array $right): int => - [$left['priority'], $left['index']] <=> [$right['priority'], $right['index']], - ); - - $selectedIndex = $this->getSelectedRecordIndex(); - $lines = ['Runs in this order:']; - - foreach ($rules as $position => $rule) { - $lines[] = sprintf( - '%s%2d. %s%s', - $rule['index'] === $selectedIndex ? '> ' : ' ', - $position + 1, - $rule['id'], - $rule['priority'] !== 0 ? sprintf(' (p %d)', $rule['priority']) : '', - ); + // A read-only category says so once, rather than leaving an author + // wondering why nothing they type sticks. + if (! $database->isEditable() && ($reason = $database->getReadOnlyReason()) !== null) { + $lines[] = ''; + $lines[] = $reason; } return $lines; } /** - * Returns the frame list lines. + * Returns the actor list lines. * * @return string[] */ - private function getDatabaseFrameLines(): array + private function getDatabaseActorListLines(): array { - if ($this->isActorsDatabaseSelected()) { - return $this->getDatabaseActorStatLines(); - } - - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassCurveLines(); - } - - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemFrameLines(); - } - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillFrameLines(); - } + $actors = $this->workspace?->actorDatabase->getActors() ?? []; - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestFrameLines(); + if ($actors === []) { + return ['No actors yet.', '', 'Shift+A to create one.']; } + $lines = []; - $animation = $this->getSelectedAnimation(); - - if (! $animation instanceof Animation) { - $category = $this->getSelectedDatabaseCategoryDefinition(); - - return $category->isImplemented ? ['-'] : ['No entry frames.']; - } + foreach ($this->getVisibleDatabaseEntryIndexes() as $index) { + $actor = $actors[$index] ?? null; - $lines = []; + if (! $actor instanceof ProjectActor) { + continue; + } - for ($frameIndex = 1; $frameIndex <= $animation->maxFrames; $frameIndex++) { - $prefix = $frameIndex === $this->databaseSelectedFrameIndex ? '> ' : ' '; - $lines[] = sprintf('%s#%03d', $prefix, $frameIndex); + $prefix = $index === $this->databaseSelectedActorIndex ? '> ' : ' '; + $dirty = $actor->isDirty() ? ' *' : ''; + $lines[] = sprintf('%s%s%s', $prefix, $actor->getName(), $dirty); } - return $lines; + return $lines === [] ? ['No matches.'] : $lines; } /** - * Returns the class curve summary lines. + * Returns the current Database settings lines. * * @return string[] */ - private function getDatabaseClassCurveLines(): array + private function getDatabaseSettingsLines(): array { - $class = $this->getSelectedClass(); + $fields = $this->getDatabaseSettingsFields(); - if (! $class instanceof ProjectClass) { - return ['No class selected.']; - } + if ($fields === []) { + $category = $this->getSelectedDatabaseCategoryDefinition(); - $labelMap = [ - 'totalHp' => 'HP', - 'totalMp' => 'MP', - 'attack' => 'ATK', - 'defence' => 'DEF', - 'magicAttack' => 'MAT', - 'magicDefence' => 'MDF', - 'speed' => 'SPD', - 'grace' => 'GRC', - 'evasion' => 'EVA', - ]; - $lines = []; + if (! $category->isImplemented) { + return [ + 'Planned Editor', + '', + $category->description, + '', + 'This section will live in the Database window too.', + ]; + } - foreach ($class->getParameterCurves() as $key => $curve) { - $lines[] = sprintf( - '%-3s %d +%d / %d', - $labelMap[$key] ?? strtoupper($key), - $curve['baseValue'], - $curve['extraGrowth'], - $curve['flatIncrement'], - ); + return ['Select an entry to edit settings.']; } - return $lines; - } - - /** - * Returns the quest reward and prerequisite summary lines. - * - * @return string[] - */ - private function getDatabaseQuestFrameLines(): array - { - $quest = $this->getSelectedQuest(); + if ($this->referencePicker->isOpen()) { + return $this->buildReferencePickerRows(); + } - if (! $quest instanceof ProjectQuest) { - return ['No quest selected.']; + if ($this->conditionEditor->isOpen()) { + return $this->buildConditionEditorRows(); } - return [ - sprintf('Gold: %d', $quest->getRewardGold()), - sprintf('EXP: %d', $quest->getRewardExperience()), - sprintf('Items: %s', $quest->getRewardItemsString() === '' ? '-' : $quest->getRewardItemsString()), - '', - 'Prereqs', - ...$quest->getPrerequisiteSummaryLines(), - ]; - } + if ($this->affinityEditor->isOpen()) { + return $this->buildAffinityEditorRows(); + } - /** - * Returns the skill scope summary lines. - * - * @return string[] - */ - private function getDatabaseSkillFrameLines(): array - { - $skill = $this->getSelectedSkill(); + if ($this->animationConversion->isOpen()) { + return $this->buildAnimationConversionRows(); + } - if (! $skill instanceof ProjectSkill) { - return ["No skill selected."]; + if ($this->worldWriteEditor->isOpen()) { + return $this->buildWorldWriteEditorRows(); } - $scope = $skill->getScope(); - $invocation = $skill->getInvocation(); + if ($this->battleEntryPredicateEditor->isOpen()) { + return $this->buildBattleEntryPredicateEditorRows(); + } - return [ - sprintf("Side: %s", (string) ($scope["side"] ?? "Enemy")), - sprintf("Number: %s", (string) ($scope["number"] ?? "One")), - sprintf("Status: %s", (string) ($scope["status"] ?? "Alive")), - sprintf("Targets: %s", ($scope["targetCount"] ?? null) === null ? "Auto" : (string) $scope["targetCount"]), - "", - sprintf("Occasion: %s", $skill->getOccasion()), - sprintf("Repeat: %d", (int) ($invocation["repeat"] ?? 1)), - sprintf("AP Gain: %d", (int) ($invocation["apGain"] ?? 10)), - ]; + return $this->recordPaneLayout($fields)->visibleLines(); } /** - * Returns system behavior notes. + * Returns the record pane's width, height and cursor visibility: the + * Database settings pane's own, or the Inspector's while it hosts the + * pane. Every row builder of the pane measures against these. * - * @return string[] + * @return array{width: int, rows: int, cursorShown: bool} */ - private function getDatabaseSystemFrameLines(): array + private function recordPaneMetrics(): array { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No system settings loaded.']; - } + $layout = $this->resolveLayout(); - $system = $this->workspace->systemDatabase; + if ($this->isCutscenesOpen) { + return $this->cutsceneRecordPaneMetrics(); + } - if ($system->getBattleEngine() !== 'active_time') { + if ($this->isNpcInspectorHosting() || $this->mapConditionField !== null || $this->mapWriteField !== null) { return [ - 'Traditional turn-based battles.', - 'ATB settings are stored but inactive.', - 'Switch Battle Engine to active_time', - 'to enable gauge-driven turns.', + 'width' => $this->getWindowContentWidth($layout['rightWidth']), + 'rows' => max(1, $layout['contentHeight'] - 2), + 'cursorShown' => $this->focusedPane === self::FOCUS_INSPECTOR, ]; } + $database = $this->resolveDatabaseLayout($layout); + return [ - 'Active Time Battle is enabled.', - 'Mode: wait', - 'This first slice uses wait-mode flow', - 'during command selection and resolution.', + 'width' => $this->getWindowContentWidth($database['settingsWidth']), + 'rows' => max(1, $database['topHeight'] - 2), + 'cursorShown' => $this->databaseFocus === self::DATABASE_FOCUS_SETTINGS, ]; } /** - * Returns the actor stat summary lines. + * Lays out the record pane: each field as its wrapped lines, the field + * being edited as one horizontally scrolled line, scrolled so the + * selected field stays in view. The visible lines, the edit caret and + * anything else that turns a field into a row all read this one layout. * - * @return string[] + * @param array>|null $fields The fields, or null to read them. + * @return SettingsPaneLayout The layout. */ - private function getDatabaseActorStatLines(): array + private function recordPaneLayout(?array $fields = null): SettingsPaneLayout { - $actor = $this->getSelectedActor(); + $fields ??= $this->getDatabaseSettingsFields(); + $metrics = $this->recordPaneMetrics(); + $rows = []; - if (! $actor instanceof ProjectActor) { - return ['No actor selected.']; + foreach ($fields as $index => $field) { + $prefix = $metrics['cursorShown'] && $index === $this->databaseSelectedSettingIndex ? '> ' : ' '; + $label = (string) ($field['label'] ?? 'Field'); + $value = (string) ($field['value'] ?? ''); + $editing = $this->isDatabaseEditing && $index === $this->databaseSelectedSettingIndex; + + if ($editing) { + // The edited row keeps its single-line window around the + // caret, exactly as before, so the caret arithmetic holds. + $availableValueWidth = max(1, $metrics['width'] - mb_strwidth(sprintf('%s%s: ', $prefix, $label))); + $visibleStart = max(0, $this->databaseEditCursorIndex - $availableValueWidth + 1); + $value = mb_substr($this->databaseEditBuffer, $visibleStart, $availableValueWidth); + } + + $rows[] = [ + 'prefix' => $prefix, + 'label' => $label, + 'value' => $value, + 'editable' => $editing || $this->isInspectorFieldInteractive($field), + 'singleLine' => $editing, + ]; } - return [ - sprintf('HP %d/%d', $actor->getStat('currentHp'), $actor->getStat('totalHp')), - sprintf('MP %d/%d', $actor->getStat('currentMp'), $actor->getStat('totalMp')), - sprintf('AP %d/%d', $actor->getStat('currentAp'), $actor->getStat('totalAp')), - sprintf('ATK %d', $actor->getStat('attack')), - sprintf('DEF %d', $actor->getStat('defence')), - sprintf('MAT %d', $actor->getStat('magicAttack')), - sprintf('MDF %d', $actor->getStat('magicDefence')), - sprintf('SPD %d', $actor->getStat('speed')), - sprintf('GRC %d', $actor->getStat('grace')), - sprintf('EVA %d', $actor->getStat('evasion')), - sprintf('ACC %d', $actor->getStat('accuracy')), - sprintf('CRT %d', $actor->getStat('critical')), - ]; + return SettingsPaneLayout::layout($rows, $metrics['width'], $metrics['rows'], $this->databaseSelectedSettingIndex); } /** - * Returns the preview lines for the active Database entry. + * Returns the rows shown while a reference is being chosen. * - * @return string[] + * @return string[] The rows. */ - private function getDatabasePreviewLines(): array + private function buildReferencePickerRows(): array { - if ($this->isActorsDatabaseSelected()) { - $actor = $this->getSelectedActor(); - - if (! $actor instanceof ProjectActor) { - return ["No actor selected."]; - } - - $images = $actor->getImages(); - $battleLines = $actor->getBattleSpriteLines(); + $matches = $this->referencePicker->matches(); + $selectedIndex = $this->referencePicker->selectedIndex(); + $filter = $this->referencePicker->filter(); - return [ - sprintf("Actor ID: %s", $actor->id), - sprintf("Field sprites: %d", count($images["field"] ?? [])), - sprintf("Dialog portraits: %d", count($images["dialog"] ?? [])), - "", - "Battle Sprite", - ...($battleLines !== [] ? $battleLines : ["(no battle sprite configured)"]), - ]; - } + $lines = [ + sprintf('%s: %s', $this->referencePicker->label(), $filter === '' ? 'type to filter' : $filter), + '', + ]; - if ($this->isClassesDatabaseSelected()) { - return $this->getDatabaseClassPreviewLines(); - } + if ($matches === []) { + $lines[] = ' Nothing matches.'; - if ($this->isSkillsDatabaseSelected()) { - return $this->getDatabaseSkillPreviewLines(); + return $lines; } - if ($this->isQuestsDatabaseSelected()) { - return $this->getDatabaseQuestPreviewLines(); + foreach ($matches as $index => $value) { + $lines[] = sprintf('%s%s', $index === $selectedIndex ? '> ' : ' ', $this->referencePicker->labelFor($value)); } - if ($this->isSystemDatabaseSelected()) { - return $this->getDatabaseSystemPreviewLines(); - } + // Two header lines, then the rows in whatever height the pane has. + $visibleRows = max(1, $this->recordPaneMetrics()['rows'] - 2); - return []; + return [...array_slice($lines, 0, 2), ...ScrollWindow::slice(array_slice($lines, 2), $selectedIndex, $visibleRows)]; } /** - * Returns the preview lines for the selected quest. + * Returns the cue summary lines for the active Database entry. * * @return string[] */ - private function getDatabaseQuestPreviewLines(): array + private function getDatabaseCueLines(): array { - $quest = $this->getSelectedQuest(); - - if (! $quest instanceof ProjectQuest) { - return ['No quest selected.']; - } - - return [ - sprintf('Quest ID: %s', $quest->getId()), - sprintf('Name: %s', $quest->getName()), - sprintf('Giver: %s', $quest->getGiver() === '' ? '-' : $quest->getGiver()), - sprintf('Objectives: %d', count($quest->getObjectives())), - sprintf('Prereqs: %d', count($quest->getPrerequisites())), - '', - 'Description', - $quest->getDescription() === '' ? '(none)' : $quest->getDescription(), - '', - 'Objectives', - ...$quest->getObjectiveSummaryLines(), - ]; + return $this->getDatabasePanes()['cue']['lines'] + ?? ($this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No timing data yet.']); } /** - * Returns the preview lines for the selected skill. + * The summaries beside the selected record ({@see RecordPanes}), shared + * with the GUI. * - * @return string[] + * @return array<'cue'|'frames'|'preview', array{title: string, lines: list}> */ - private function getDatabaseSkillPreviewLines(): array + private function getDatabasePanes(): array { - $skill = $this->getSelectedSkill(); - - if (! $skill instanceof ProjectSkill) { - return ["No skill selected."]; - } - - $scope = $skill->getScope(); - $invocation = $skill->getInvocation(); - $lines = [ - sprintf("Skill ID: %04d", $skill->id), - sprintf("Name: %s", $skill->getName()), - sprintf("Type: %s", ucfirst($skill->getType())), - sprintf("Occasion: %s", $skill->getOccasion()), - sprintf("Cost: %d MP", $skill->getCost()), - sprintf("Cooldown: %d", $skill->getCooldown()), - "", - sprintf("Scope: %s / %s / %s", (string) ($scope["side"] ?? "Enemy"), (string) ($scope["number"] ?? "One"), (string) ($scope["status"] ?? "Alive")), - sprintf("Invoke: %s", (string) ($invocation["message"] ?? "")), - "", - "Effects", - ]; - - return array_merge($lines, $skill->getEffectSummaryLines()); + return $this->workspace instanceof ProjectWorkspace + ? RecordPanes::describe($this->workspace, $this->getSelectedDatabaseCategoryDefinition()->key, $this->getSelectedDatabaseEntryIndex()) + : []; } + /** - * Returns the preview lines for the system database. + * Returns the frame list lines. * * @return string[] */ - private function getDatabaseSystemPreviewLines(): array + private function getDatabaseFrameLines(): array { - if (! $this->workspace instanceof ProjectWorkspace) { - return ['No system settings loaded.']; - } - - $system = $this->workspace->systemDatabase; - $engine = $system->getBattleEngine(); - - if ($engine === 'active_time') { - return [ - 'Battle Engine', - 'Active Time Battle', - '', - sprintf('Mode: %s', $system->getAtbMode()), - sprintf('Base Fill Rate: %d', $system->getAtbBaseFillRate()), - sprintf('Speed Factor: %d%%', $system->getAtbSpeedFactorPercent()), - '', - 'This engine fills battler gauges', - 'continuously and resolves actions', - 'as battlers become ready.', - ]; - } - - - return [ - 'Battle Engine', - 'Traditional Turn-Based', - '', - 'Battlers act in a queued round order.', - 'ATB settings are ignored until you', - 'switch the project to active_time.', - ]; + return $this->getDatabasePanes()['frames']['lines'] + ?? ($this->getSelectedDatabaseCategoryDefinition()->isImplemented ? ['-'] : ['No entry frames.']); } /** - * Returns the preview lines for the selected class. + * Returns the preview lines for the active Database entry. * * @return string[] */ - private function getDatabaseClassPreviewLines(): array + private function getDatabasePreviewLines(): array { - $class = $this->getSelectedClass(); - - if (! $class instanceof ProjectClass) { - return ['No class selected.']; - } - - $experienceCurve = $class->getExperienceCurve(); - $experienceGenerator = new ExperienceCurveGenerator( - baseValue: $experienceCurve['baseValue'], - extraValue: $experienceCurve['extraValue'], - accelerationA: $experienceCurve['accelerationA'], - accelerationB: $experienceCurve['accelerationB'], - ); - $hpCurve = $class->getParameterCurve('totalHp'); - $mpCurve = $class->getParameterCurve('totalMp'); - $attackCurve = $class->getParameterCurve('attack'); - $hpGenerator = new ParameterCurveGenerator(1, $hpCurve['baseValue'], $hpCurve['extraGrowth'], $hpCurve['flatIncrement']); - $mpGenerator = new ParameterCurveGenerator(1, $mpCurve['baseValue'], $mpCurve['extraGrowth'], $mpCurve['flatIncrement']); - $attackGenerator = new ParameterCurveGenerator(1, $attackCurve['baseValue'], $attackCurve['extraGrowth'], $attackCurve['flatIncrement']); - $sampleLevels = [1, 10, 25, 50, 99]; - $lines = [ - sprintf('Class ID: %04d', $class->id), - sprintf('Name: %s', $class->getName()), - '', - 'Curve Samples', - ]; - - foreach ($sampleLevels as $level) { - if ($level > $class->getMaxLevel()) { - continue; - } - - $lines[] = sprintf( - 'Lv%02d HP%-4d MP%-3d ATK%-3d EXP%-6d', - $level, - $hpGenerator->getValue($level), - $mpGenerator->getValue($level), - $attackGenerator->getValue($level), - $experienceGenerator->getValue($level), - ); - } - - return $lines; + return $this->getDatabasePanes()['preview']['lines'] ?? []; } /** @@ -17071,12 +13980,7 @@ private function isDatabaseCategoryDirty(string $categoryKey): bool return match ($categoryKey) { self::DATABASE_CATEGORY_ACTORS => $this->workspace->actorDatabase->isDirty(), - self::DATABASE_CATEGORY_CLASSES => $this->workspace->classDatabase->isDirty(), - self::DATABASE_CATEGORY_SKILLS => $this->workspace->skillDatabase->isDirty(), - self::DATABASE_CATEGORY_ANIMATIONS => $this->workspace->animationDatabase->isDirty(), - self::DATABASE_CATEGORY_SYSTEM => $this->workspace->systemDatabase->isDirty(), - self::DATABASE_CATEGORY_QUESTS => $this->workspace->questDatabase->isDirty(), - default => false, + default => $this->workspace->getRecordDatabase($categoryKey)?->isDirty() ?? false, }; } @@ -17091,25 +13995,6 @@ private function resolveDatabasePaneColor(string $pane): Color return $this->databaseFocus === $pane ? Color::LIGHT_BLUE : Color::WHITE; } - /** - * Resolves the termutil color for a stored animation color name. - * - * @param string|null $color The stored color name. - * @return Color - */ - private function resolveAnimationColor(?string $color): Color - { - return match (strtolower((string) $color)) { - 'red' => Color::LIGHT_RED, - 'green' => Color::LIGHT_GREEN, - 'blue' => Color::LIGHT_BLUE, - 'yellow' => Color::YELLOW, - 'cyan' => Color::LIGHT_CYAN, - 'magenta' => Color::LIGHT_PURPLE, - default => Color::WHITE, - }; - } - /** * Returns the active character palette. * @@ -17132,11 +14017,12 @@ private function getCharacterPalette(): array // Erasing a glyph's last use can therefore never make it unpaintable. $palette = []; - foreach ($selectedMap->getCharacterPalette() as $symbol) { + foreach ($selectedMap->getCharacterPalette($this->getActiveCanvasLayer()) as $symbol) { $palette[$symbol] = $symbol; } - foreach ($this->workspace->getCollisionGlyphs() as $symbol) { + $layerName = array_values(array_filter($this->getTerminalCanvasLayers(), fn(array $layer): bool => $layer['id'] === $this->getActiveCanvasLayer()))[0]['name'] ?? null; + foreach ($this->workspace->getCollisionGlyphs($layerName) as $symbol) { $palette[$symbol] = $symbol; } @@ -17170,6 +14056,16 @@ private function getInspectorLines(): array } } + if ($this->mapWriteField !== null) { + if ($this->referencePicker->isOpen()) { + return $this->buildReferencePickerRows(); + } + + if ($this->worldWriteEditor->isOpen()) { + return $this->buildWorldWriteEditorRows(); + } + } + if ($this->isNpcInspectorHosting() && $this->npcCreationInProgress !== null) { return $this->buildNpcNamePromptRows(); } @@ -17205,10 +14101,13 @@ private function inspectorPaneLayout(?array $fields = null): SettingsPaneLayout foreach ($fields as $index => $field) { $editing = $this->isInspectorEditing && $index === $this->selectedInspectorFieldIndex; + $value = (string) ($field['value'] ?? ''); + $choice = array_find((array) ($field['choices'] ?? []), + static fn(array $choice): bool => (string) $choice['value'] === $value); $rows[] = [ 'prefix' => $this->focusedPane === self::FOCUS_INSPECTOR && $index === $this->selectedInspectorFieldIndex ? '> ' : ' ', 'label' => (string) ($field['label'] ?? 'Field'), - 'value' => $editing ? $this->inspectorEditBuffer : (string) ($field['value'] ?? ''), + 'value' => $editing ? $this->inspectorEditBuffer : (string) ($choice['label'] ?? $value), 'editable' => $editing || $this->isInspectorFieldInteractive($field), 'singleLine' => $editing, ]; @@ -17230,6 +14129,7 @@ private function inspectorPaneLayout(?array $fields = null): SettingsPaneLayout */ private function isInspectorFieldInteractive(array $field): bool { + if (($field['editable'] ?? null) === false) { return isset($field['frame']); } return $this->getInspectorFieldControl($field) instanceof InputControl || ($field['editable'] ?? null) === true || ! empty($field['options']) @@ -17241,18 +14141,14 @@ private function isInspectorFieldInteractive(array $field): bool } /** - * Returns the input control for an inspector field if it is editable. + * The input a row is typed into (see {@see MapInspector::findControl()}). * - * @param array $field The inspector field descriptor. - * @return InputControl|null + * @param array $field */ private function getInspectorFieldControl(array $field): ?InputControl { - $control = $field['control'] ?? null; - - return $control instanceof InputControl ? $control : null; + return MapInspector::findControl($field); } - /** * Re-renders the canvas section without clearing the full shell. * @@ -17308,7 +14204,8 @@ private function drawFooter(): void */ private function renderFooterStatusColor(): void { - if ($this->statusLevel === StatusLevel::INFO) { + // The idle message stays plain; every message shown takes its level's colour. + if ($this->toasts->current() === null) { return; } @@ -17416,6 +14313,16 @@ private function renderModalOverlays(array $layout): bool return true; } + if ($this->modals->has(Modal::LAYER_EDIT)) { + $this->renderLayerPrompt($layout); + return true; + } + + if ($this->modals->has(Modal::MAP_LINES)) { + $this->renderMapLinePrompt($layout); + return true; + } + if ($this->isCommandPaletteOpen) { $this->renderCommandPaletteOverlay($layout); return true; @@ -17426,6 +14333,11 @@ private function renderModalOverlays(array $layout): bool return true; } + if ($this->isEventOptionDialogOpen) { + $this->renderEventOptionDialogOverlay($layout); + return true; + } + return false; } @@ -17563,26 +14475,12 @@ private function renderStatusDetailOverlay(array $layout): void private function renderUnsavedChangesGuardOverlay(array $layout): void { $actionLabel = $this->pendingGuardAction === self::GUARD_ACTION_QUIT ? 'quit' : 'reload the workspace'; - $dirtyMaps = []; - - foreach ($this->workspace?->maps ?? [] as $map) { - if ($map->isDirty()) { - $dirtyMaps[] = ' ' . $map->mapId; - } - } - - $dirtyDatabases = []; - - foreach ($this->getSaveableDatabases() as $label => $database) { - if ($database->isDirty()) { - $dirtyDatabases[] = ' ' . $label . ' database'; - } - } + $unsaved = array_map(static fn(string $document): string => ' ' . $document, $this->workspace?->listUnsavedChanges() ?? []); $rows = [ sprintf('You have unsaved changes. %s anyway?', ucfirst($actionLabel)), '', - ...array_slice([...$dirtyMaps, ...$dirtyDatabases], 0, 8), + ...array_slice($unsaved, 0, 8), '', 'Y: Discard changes and continue', 'S: Save everything first, then continue', @@ -17713,11 +14611,6 @@ private function drawOverlays(): void return; } - if ($this->isEventOptionDialogOpen) { - $this->renderEventOptionDialogOverlay($layout); - return; - } - if ($this->isDestinationSpawnConfirmationOpen) { $this->renderDestinationSpawnConfirmationOverlay($layout); return; @@ -17873,15 +14766,25 @@ private function createAssetWindow(): EditorWindow private function createCanvasWindow(): EditorWindow { $layout = $this->resolveLayout(); - $contentWidth = $this->getWindowContentWidth($layout['centerWidth']); + $bounds = $this->getCanvasPreviewBounds($layout); + $contentWidth = $bounds['width']; + $contentHeight = $bounds['height'] + ProjectWorkspace::CANVAS_HEADER_ROWS; return new EditorWindow( - title: $this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas', + title: ($this->focusedPane === self::FOCUS_CANVAS ? 'Canvas [Focus]' : 'Canvas') + . $this->getCanvasLayerTitle(), help: match (true) { $this->isDestinationSpawnConfirmationOpen => 'Enter:Apply Esc:Back', $this->isDestinationSpawnSelectionOpen => 'Arrows:Move Enter:Select Spawn Esc:Cancel', // Longest that fits wins; the overlay (?) has the full table. $this->editingMode === self::MODE_NPC && $this->npcCreationInProgress !== null => 'Enter:Create Esc:Cancel', + $this->editingMode === self::MODE_AREA && $this->areaIdBuffer !== null => 'Type the id Enter:Make Esc:Cancel', + $this->editingMode === self::MODE_AREA => $this->fitHelp( + $layout['centerWidth'], + 'Space:Cell r:Rectangle Enter:Make/Pick Up n:Next c:Clear Del:Delete Esc:Exit', + 'Space r Enter n c Del Esc:Exit', + 'Esc:Exit', + ), $this->editingMode === self::MODE_NPC => $this->fitHelp( $layout['centerWidth'], 'Enter:Select/Create M:Move D:Dup L:List Del:Delete Esc:Exit', @@ -17891,15 +14794,28 @@ private function createCanvasWindow(): EditorWindow ), $this->inputMode === self::INPUT_PAINT => $this->fitHelp( $layout['centerWidth'], - 'PAINT: every key paints its glyph Esc:Normal', - 'PAINT Esc:Normal', + ...($this->canvasTool === CanvasTool::BRUSH + ? ['PAINT: every key paints its glyph Esc:Normal', 'PAINT Esc:Normal'] + : [sprintf('PAINT %s: keys choose the glyph Enter:Anchor, then Draw Esc:Normal', $this->canvasTool->label()), + sprintf('PAINT %s Enter:Anchor/Draw Esc:Normal', $this->canvasTool->label()), 'PAINT Esc:Normal']), ), - default => $this->fitHelp( + $this->getActivePiecePlacement() !== null => $this->getPiecePlacementHelp($layout['centerWidth']) ?? '', + // A map painted in terrain kinds offers them beside the pieces. + default => (fn(string $pieces): string => $this->fitHelp( $layout['centerWidth'], + "i:Paint b/l/r/R/s:Tool L:Layer {$pieces} o:Colour v:Hide d:Dim ?:Help", + "i:Paint b/l/r/R/s:Tool L:Layer {$pieces} o:Colour ?:Help", + "i:Paint b/l/r/R/s:Tool L:Layer {$pieces} ?:Help", + 'i:Paint b/l/r/R/s:Tool L:Layer ?:Help', + "i:Paint L:Layer {$pieces} v:Hide d:Dim o:Colour ?:Help", + "i:Paint L:Layer {$pieces} v:Hide d:Dim", + "i:Paint L:Layer {$pieces} ?:Help", + 'i:Paint L:Layer v:Hide d:Dim', + 'i:Paint L:Layer ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour ?:Help', 'i:Paint m:Map e:Event n:NPC c:Chars o:Colour', 'i:Paint m:Map e:Event', - ), + ))(($this->getSelectedMap() instanceof ProjectMap && $this->getSelectedMap()->isPaintedInKinds()) ? 'P:Piece K:Kind' : 'P:Piece'), }, position: ['x' => 2 + $layout['leftWidth'] + $layout['gutter'], 'y' => 5], width: $layout['centerWidth'], @@ -17909,7 +14825,7 @@ private function createCanvasWindow(): EditorWindow $this->workspace?->getCanvasLines( $this->selectedAssetIndex, $contentWidth, - max(1, $layout['contentHeight'] - 2), + $contentHeight, $this->canvasOffsetX, $this->canvasOffsetY, $this->showEventOverlay, @@ -17917,9 +14833,14 @@ private function createCanvasWindow(): EditorWindow $this->selectedNpcIndex, $this->previewedNpcSprite(), ['x' => $this->cursorX, 'y' => $this->cursorY], + $this->getCanvasLayerState()['visibility'], + $this->getActiveCanvasLayer(), + false, + $this->getCanvasLayerState()['dim'], + $this->getCanvasPreviewCells(), ) ?? [], $contentWidth, - $layout['contentHeight'] - 2 + $contentHeight ), ); } @@ -17943,7 +14864,7 @@ private function createInspectorWindow(): EditorWindow $this->isInspectorEditing => 'Enter:Apply Esc:Cancel', // Only where there is a list to act on: a hint for keys that // would answer "nothing here is a list" is worse than none. - $this->selectedInspectorList() !== null => $this->fitHelp( + MapInspector::findEventList($this->getInspectorFields()[$this->selectedInspectorFieldIndex] ?? []) !== null => $this->fitHelp( $layout['rightWidth'], 'Enter:Edit Shift+O:Add Shift+X/Del:Remove', 'Enter:Edit Shift+O:Add Del:Remove', diff --git a/src/EquipmentOptimizationPolicy.php b/src/EquipmentOptimizationPolicy.php index 0323b62c..03f82a7e 100644 --- a/src/EquipmentOptimizationPolicy.php +++ b/src/EquipmentOptimizationPolicy.php @@ -4,6 +4,7 @@ namespace Ichiloto\Editor; +use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Throwable; @@ -30,7 +31,8 @@ final class EquipmentOptimizationPolicy /** * Where a project declares its policy. */ - public const string RELATIVE_PATH = 'assets/Data/equipment-optimization.php'; + public const string ASSET_PATH = 'Data/equipment-optimization.php'; + public const string RELATIVE_PATH = 'assets/' . self::ASSET_PATH; /** * The keys the engine's policy constructor takes, in its own order. @@ -102,21 +104,16 @@ public static function isAvailable(): bool } /** - * Returns the policy a project declares, or null when it declares none. + * Returns the policy a project declares as its Optimize pages hold it, + * unsaved edits included, or null when it declares none. * - * @param string $projectRoot The project root. + * @param ProjectWorkspace $workspace The project. * @return array|null The authored payload. */ - public static function declaredIn(string $projectRoot): ?array + public static function declaredIn(ProjectWorkspace $workspace): ?array { - $path = rtrim($projectRoot, '/') . '/' . self::RELATIVE_PATH; - - if (! is_file($path)) { - return null; - } - try { - $payload = (static fn(): mixed => require $path)(); + $payload = $workspace->catalogs->readData(self::ASSET_PATH)['payload']; } catch (Throwable) { return null; } @@ -131,16 +128,16 @@ public static function declaredIn(string $projectRoot): ?array * the engine's own registry does; a preview that quietly showed declared * numbers instead would be describing a policy the game does not have. * - * @param string $projectRoot The project root. + * @param ProjectWorkspace $workspace The project. * @return object|null The engine policy, or null when it cannot be built. */ - public static function enginePolicyFor(string $projectRoot): ?object + public static function enginePolicyFor(ProjectWorkspace $workspace): ?object { if (! self::isAvailable()) { return null; } - $declared = self::declaredIn($projectRoot); + $declared = self::declaredIn($workspace); if ($declared === null) { return new \Ichiloto\Engine\Entities\EquipmentOptimization\LegacyEqualWeightEquipmentOptimizationPolicy(); @@ -168,24 +165,24 @@ public static function enginePolicyFor(string $projectRoot): ?object /** * Returns whether a project has declared a policy of its own. * - * @param string $projectRoot The project root. + * @param ProjectWorkspace $workspace The project. * @return bool True when it has. */ - public static function isDeclaredIn(string $projectRoot): bool + public static function isDeclaredIn(ProjectWorkspace $workspace): bool { - return self::declaredIn($projectRoot) !== null; + return self::declaredIn($workspace) !== null; } /** * Describes which policy a preview is showing, naming the fallback for * what it is. * - * @param string $projectRoot The project root. + * @param ProjectWorkspace $workspace The project. * @return string The description. */ - public static function describeSource(string $projectRoot): string + public static function describeSource(ProjectWorkspace $workspace): string { - if (self::isDeclaredIn($projectRoot)) { + if (self::isDeclaredIn($workspace)) { return sprintf('declared by this project · %s', self::RELATIVE_PATH); } @@ -205,7 +202,7 @@ public static function describeSource(string $projectRoot): string */ public static function rank(ProjectWorkspace $workspace, ProjectActor $actor, string $semanticSlot): array { - $policy = self::enginePolicyFor($workspace->projectRoot); + $policy = self::enginePolicyFor($workspace); if ($policy === null || ! class_exists(\Ichiloto\Engine\Entities\EquipmentSlot::class)) { return []; @@ -310,10 +307,10 @@ private static function candidates(ProjectWorkspace $workspace): array } foreach ($database->getRecords() as $record) { - $payload = $record->toArray(); + $equipment = InventoryCatalog::readDefinition($record); - if ($payload instanceof \Ichiloto\Engine\Entities\Inventory\Equipment) { - $candidates[] = $payload; + if ($equipment instanceof \Ichiloto\Engine\Entities\Inventory\Equipment) { + $candidates[] = $equipment; } } } diff --git a/src/Events/ChestLoot.php b/src/Events/ChestLoot.php new file mode 100644 index 00000000..e323fdbb --- /dev/null +++ b/src/Events/ChestLoot.php @@ -0,0 +1,80 @@ + $definition The event definition. + */ + public static function isChest(array $definition): bool + { + return str_contains(strval($definition['class'] ?? ''), self::TRIGGER); + } + + /** + * Reads a declared loot type the way the Engine does. + * + * A missing or blank type is an item, as the Engine reads it. A value the + * Engine does not know returns null: the Engine would read it as an item + * too, but silently, so the author has to be told. + * + * @param mixed $declared The `lootType` data value: a case, its value, or nothing. + * @return LootType|null The type the chest holds, or null when the Engine does not know it. + */ + public static function readLootType(mixed $declared): ?LootType + { + if ($declared instanceof LootType) { + return $declared; + } + + if ($declared instanceof BackedEnum) { + $declared = $declared->value; + } + + if ($declared === null || $declared === '') { + return LootType::ITEM; + } + + return is_string($declared) || is_int($declared) ? LootType::tryFrom(strval($declared)) : null; + } + + /** + * Tells whether the Engine's chest can give this kind of loot. + */ + public static function isGrantable(LootType $type): bool + { + return ChestOpeningAction::supportsLootType($type); + } +} diff --git a/src/Events/CommandMapContext.php b/src/Events/CommandMapContext.php new file mode 100644 index 00000000..8fd6d425 --- /dev/null +++ b/src/Events/CommandMapContext.php @@ -0,0 +1,135 @@ + $command The command. + * @return list, commands: array}> Each arm and its path from the command. + */ + public static function getArms(array $command): array + { + $arms = []; + foreach ((array) ($command['options'] ?? []) as $index => $option) { + if (is_array($option)) { + $arms[] = ['path' => ['options', $index, 'then'], 'commands' => (array) ($option['then'] ?? [])]; + } + } + foreach (['then', 'else', 'cancel'] as $arm) { + if (array_key_exists($arm, $command)) { + $arms[] = ['path' => [$arm], 'commands' => (array) ($command[$arm] ?? [])]; + } + } + + return $arms; + } + + /** + * The lanes of a parallel command, in order. A lane is a command list or + * a keyed lane holding `commands`. + * + * @param array $command The command. + * @return list, commands: array}> Each lane and its path from the command. + */ + public static function getLanes(array $command): array + { + if (($command['type'] ?? null) !== 'parallel') { + return []; + } + + $lanes = []; + foreach ((array) ($command['lanes'] ?? []) as $index => $lane) { + if (! is_array($lane)) { + continue; + } + $lanes[] = array_is_list($lane) + ? ['path' => ['lanes', $index], 'commands' => $lane] + : ['path' => ['lanes', $index, 'commands'], 'commands' => (array) ($lane['commands'] ?? [])]; + } + + return $lanes; + } + + /** + * Every map a command list can transfer to, through its blocks, arms, + * lanes and the reusable event scripts it runs. + * + * @param array $commands The command list. + * @param callable(string): ?array $findCommonEvent The commands of a reusable event script, or null when it is unknown. + * @param list $stack Reusable event scripts already being followed. + * @return list Each destination map id once; an empty id for a transfer that names no map. + */ + public static function listTransferDestinations(array $commands, callable $findCommonEvent, array $stack = []): array + { + $destinations = []; + foreach ($commands as $command) { + if (! is_array($command)) { + continue; + } + $type = strval($command['type'] ?? ''); + $nested = []; + if ($type === 'transfer') { + $destinations[] = trim(strval($command['map'] ?? '')); + } + if ($type === 'sequence') { + $nested[] = [(array) ($command['commands'] ?? []), $stack]; + } + if ($type === 'common_event') { + $id = trim(strval($command['id'] ?? '')); + $script = in_array($id, $stack, true) ? null : $findCommonEvent($id); + if ($script !== null) { + $nested[] = [$script, [...$stack, $id]]; + } + } + foreach ([...self::getLanes($command), ...self::getArms($command)] as $block) { + $nested[] = [$block['commands'], $stack]; + } + foreach ($nested as [$list, $listStack]) { + $destinations = [...$destinations, ...self::listTransferDestinations($list, $findCommonEvent, $listStack)]; + } + } + + return array_values(array_unique($destinations)); + } + + /** + * Whether the command can finish without running any of its arms: a + * branch missing `then` or `else`, or a choice without `cancel`. + * + * @param array $command The command. + */ + public static function canSkipArms(array $command): bool + { + $type = strval($command['type'] ?? ''); + + return ($type === 'branch' && (! isset($command['then']) || ! isset($command['else']))) + || ($type === 'choice' && ! isset($command['cancel'])); + } +} diff --git a/src/Events/CommonEventMaps.php b/src/Events/CommonEventMaps.php new file mode 100644 index 00000000..9695e42b --- /dev/null +++ b/src/Events/CommonEventMaps.php @@ -0,0 +1,70 @@ +> $maps Map ids by script id. */ + private function __construct(private array $maps) {} + + /** Reads every caller as the workspace holds it now, unsaved edits included. */ + public static function read(ProjectWorkspace $workspace): self + { + $direct = $calls = []; + foreach ($workspace->maps as $map) { + foreach (self::findCalls($map->getEditableData()) as $id) { $direct[$id][$map->mapId] = true; } + } + foreach ($workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [] as $cinematic) { + $start = $cinematic->data()['startMap'] ?? null; + if (!is_string($start) || $start === '') { continue; } + foreach (self::findCalls($cinematic->commands()) as $id) { $direct[$id][$start] = true; } + } + foreach ($workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $record) { + foreach (self::findCalls($record->getSubList('commands')) as $id) { $calls[$record->recordId][$id] = true; } + } + // A script runs on every map a script calling it runs on. + $maps = $direct; + do { + $changed = false; + foreach ($calls as $caller => $callees) { + foreach (array_keys($callees) as $callee) { + $added = array_diff_key($maps[$caller] ?? [], $maps[$callee] ?? []); + if ($added !== []) { + $maps[$callee] = ($maps[$callee] ?? []) + $added; + $changed = true; + } + } + } + } while ($changed); + + return new self(array_map(static fn(array $ids): array => array_keys($ids), $maps)); + } + + /** @return list The maps the script runs on. */ + public function findMaps(string $scriptId): array + { + return $this->maps[$scriptId] ?? []; + } + + /** @return list The scripts a value calls anywhere inside it. */ + private static function findCalls(mixed $value): array + { + if (!is_array($value)) { return []; } + $ids = []; + if (($value['type'] ?? null) === 'common_event' && is_string($value['id'] ?? null)) { $ids[] = $value['id']; } + if (is_string($value['scriptId'] ?? null)) { $ids[] = $value['scriptId']; } + foreach ($value as $child) { array_push($ids, ...self::findCalls($child)); } + return $ids; + } +} diff --git a/src/Events/EventAuthoring.php b/src/Events/EventAuthoring.php new file mode 100644 index 00000000..93f7a9e3 --- /dev/null +++ b/src/Events/EventAuthoring.php @@ -0,0 +1,375 @@ + $cells The cells it triggers on, as [x, y]. + * @param string|null $marker The marker to give it; null takes the first free one ({@see EventMarkers}). + * @return array{marker: string, command: Command} + * @throws EventRefusal When the cells or marker cannot be used. + * @throws MapSourceRefusal When the map is read-only or its data cannot take the definition. + */ + public static function createEvent(ProjectMap $map, array $cells, EventTypeDefinition $type, ?string $marker = null): array + { + self::assertEditable($map); + + if ($cells === []) { + throw new EventRefusal('Choose at least one cell for the event.'); + } + + $used = EventMarkers::findUsedMarkers($map); + + if ($marker === null) { + $marker = EventMarkers::findFreeMarker($used) + ?? throw new EventRefusal(sprintf('%s has no free event marker left.', $map->mapId)); + } elseif (($reason = EventMarkers::findInvalidReason($marker)) !== null) { + throw new EventRefusal($reason); + } elseif (in_array($marker, $used, true)) { + throw new EventRefusal(sprintf('Marker %s is already an event on %s.', $marker, $map->mapId)); + } + + foreach ($cells as [$x, $y]) { + if (! $map->hasLayerCell(MapLayers::EVENT, $x, $y)) { + throw new EventRefusal(sprintf('(%d, %d) is outside %s.', $x, $y, $map->mapId)); + } + + $occupant = $map->getEventMarkerAt($x, $y); + + if ($occupant !== null) { + throw new EventRefusal(sprintf('Marker %s already holds (%d, %d).', $occupant, $x, $y)); + } + } + + $command = self::recordEdit($map, 'Event create', static function () use ($map, $cells, $type, $marker): void { + // The definition first: it is the half the data source can + // refuse, and refusing it leaves nothing painted. + $map->setEventDefinition($marker, $type->buildDefinition(null)); + + foreach ($cells as [$x, $y]) { + $map->setEventSymbol($x, $y, $marker); + } + }); + + return ['marker' => $marker, 'command' => $command ?? throw new EventRefusal('The event could not be placed.')]; + } + + /** + * Makes an event the given type ({@see EventTypeDefinition::buildDefinition()}). + * Null when it already was exactly that. + * + * @throws EventRefusal When the map has no such event. + * @throws MapSourceRefusal When the map's data cannot take the definition. + */ + public static function setEventType(ProjectMap $map, string $marker, EventTypeDefinition $type): ?Command + { + self::assertEditable($map); + self::assertEventExists($map, $marker); + $current = $map->getEventDefinition($marker); + $definition = $type->buildDefinition($current); + + if ($definition === $current) { + return null; + } + + // A route landmark marks a transfer and any other marks something else: the type cannot cross that line + // under a landmark that describes the event. + $landmark = MapLandmarks::findAnchoredEntry($map, ['event' => $marker]); + $kind = MapLandmarkKind::tryFrom((string) ($landmark['kind'] ?? '')); + if ($kind !== null && $kind->isRoute() !== MapLandmarks::isTransfer($definition)) { + throw new EventRefusal(sprintf('Event %s is the %s landmark %s; change or remove the landmark before making it a %s.', + $marker, $kind->value, $landmark['id'] ?? '?', $type->label)); + } + + $map->setEventDefinition($marker, $definition); + + return new GenericCommand( + 'Event type change', + static fn() => $map->setEventDefinition($marker, $definition), + static function () use ($map, $marker, $current): void { + if (is_array($current)) { + $map->setEventDefinition($marker, $current); + return; + } + + $map->removeEventDefinition($marker); + }, + ); + } + + /** + * Deletes an event: its cells cleared and its definition removed, as one + * undo step. A marker painted without a definition, or defined without + * cells, is deleted the same way. + * + * @throws EventRefusal When the map has no such event. + * @throws MapSourceRefusal When the map is read-only or its data cannot drop the definition. + */ + public static function deleteEvent(ProjectMap $map, string $marker): Command + { + self::assertEditable($map); + self::assertEventExists($map, $marker); + // A landmark names its event and never copies its cells, so the event cannot go while one describes it. + $references = MapLandmarks::describeAnchorReferences($map, ['event' => $marker]); + if ($references !== []) { + throw new EventRefusal(sprintf('Event %s anchors %s; remove that landmark first.', $marker, implode(', ', $references))); + } + + return self::recordEdit($map, 'Event delete', static function () use ($map, $marker): void { + $map->removeEventDefinition($marker); + + foreach ($map->getEventArea($marker)?->cells ?? [] as [$x, $y]) { + $map->setEventSymbol($x, $y, ' '); + } + }) ?? throw new EventRefusal(sprintf('Event %s could not be deleted.', $marker)); + } + + /** + * Moves every cell of an event by an offset, keeping its shape. Null + * when the offset is nothing. + * + * @throws EventRefusal When a cell would leave the map or cover another event. + */ + public static function moveEvent(ProjectMap $map, string $marker, int $deltaX, int $deltaY, string $label = 'Event move'): ?Command + { + self::assertEditable($map); + self::refuseBoundEvent($map, $marker); + + if ($deltaX === 0 && $deltaY === 0) { + return null; + } + + return self::recordEdit($map, $label, static function () use ($map, $marker, $deltaX, $deltaY): void { + $refusal = $map->moveEventCells($marker, $deltaX, $deltaY); + + if ($refusal !== null) { + throw new EventRefusal($refusal); + } + }); + } + + /** + * Binds an event to what it describes, by the Engine's event subject: a + * world object, whose covered cells (its anchor when it covers none) + * become the event's area, so moving or replacing the object takes the + * event with it; or a named map area, whose cells it covers. The event's + * painted cells and any authored area go, in one undo step; its marker + * stays its identity. Null when it is already bound to that subject. + * + * @param string $kind One of the Engine's subject kinds ({@see MapEventDefinition::SUBJECT_KINDS}). + * @throws EventRefusal When the map has no such event or subject, or the kind is not one. + * @throws MapSourceRefusal When the map is read-only or its data cannot take the subject. + */ + public static function bindEventSubject(ProjectMap $map, string $marker, string $kind, string $id): ?Command + { + self::assertEditable($map); + self::assertEventExists($map, $marker); + $known = match ($kind) { + WorldObjectAuthoring::SUBJECT_KIND => array_column(WorldObjectAuthoring::readEntries($map), 'id'), + MapAreaAuthoring::SUBJECT_KIND => array_column(MapAreaAuthoring::readEntries($map), 'id'), + default => throw new EventRefusal(sprintf('An event subject is one of %s, not %s.', implode(', ', MapEventDefinition::SUBJECT_KINDS), $kind)), + }; + if (! in_array($id, $known, true)) { + throw new EventRefusal(sprintf('%s has no %s %s.', $map->mapId, $kind === MapAreaAuthoring::SUBJECT_KIND ? 'area' : 'world object', $id)); + } + if ($map->getEventDefinition($marker) === null) { + throw new EventRefusal(sprintf('Event %s has no definition to bind; give it a type first.', $marker)); + } + + return self::recordEdit($map, 'Bind event subject', static function () use ($map, $marker, $kind, $id): void { + foreach ($map->getEventArea($marker)?->cells ?? [] as [$x, $y]) { + $map->setEventSymbol($x, $y, ' '); + } + if (array_key_exists('area', $map->getEventDefinition($marker) ?? [])) { + $map->removeEventField($marker, ['area']); + } + $map->setEventField($marker, ['subject'], ['kind' => $kind, 'id' => $id]); + }); + } + + /** + * Unbinds an event from its subject and paints its marker on cells of + * its own, as one undo step. + * + * @param list $cells The cells it triggers on, as [x, y]. + * @throws EventRefusal When the event has no subject, or a cell leaves the map or holds another event. + * @throws MapSourceRefusal When the map is read-only or its data cannot drop the subject. + */ + public static function unbindEventSubject(ProjectMap $map, string $marker, array $cells): Command + { + self::assertEditable($map); + if (! array_key_exists('subject', $map->getEventDefinition($marker) ?? [])) { + throw new EventRefusal(sprintf('Event %s has no subject to unbind.', $marker)); + } + if ($cells === []) { + throw new EventRefusal(sprintf('Choose the cells event %s triggers on once it has no subject.', $marker)); + } + foreach ($cells as [$x, $y]) { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new EventRefusal(sprintf('%d, %d is outside %s.', $x, $y, $map->mapId)); + } + $held = $map->getLayerSymbol(MapLayers::EVENT, $x, $y); + if (trim($held) !== '' && $held !== $marker) { + throw new EventRefusal(sprintf('%d, %d already holds event %s.', $x, $y, $held)); + } + } + + return self::recordEdit($map, 'Unbind event subject', static function () use ($map, $marker, $cells): void { + $map->removeEventField($marker, ['subject']); + foreach ($cells as [$x, $y]) { + $map->setEventSymbol($x, $y, $marker); + } + }) ?? throw new EventRefusal(sprintf('Event %s could not be unbound.', $marker)); + } + + /** + * Refuses to move or reshape an event bound to a subject: its area is the + * subject's, so it moves when the object does. + * + * @throws EventRefusal When the event has a subject. + */ + private static function refuseBoundEvent(ProjectMap $map, string $marker): void + { + $subject = $map->getEventDefinition($marker)['subject'] ?? null; + if ($subject !== null) { + $id = is_array($subject) && is_string($subject['id'] ?? null) ? $subject['id'] : '(malformed)'; + throw new EventRefusal(is_array($subject) && ($subject['kind'] ?? null) === MapAreaAuthoring::SUBJECT_KIND + ? sprintf('Event %s covers area %s; reshape the area, or unbind the event first.', $marker, $id) + : sprintf('Event %s moves with its subject %s; move the world object, or unbind the event first.', $marker, $id)); + } + } + + /** + * Repaints an event as exactly a rectangle. Null when it already was. + * + * @throws EventRefusal When the event is not placed, or the rectangle is empty, leaves the map or covers another event. + */ + public static function setEventBounds(ProjectMap $map, string $marker, int $x, int $y, int $width, int $height, + string $label = 'Event bounds edit'): ?Command + { + self::assertEditable($map); + self::refuseBoundEvent($map, $marker); + + if ($map->getEventArea($marker) === null) { + throw new EventRefusal(sprintf('Marker %s is not placed.', $marker)); + } + + return self::recordEdit($map, $label, static function () use ($map, $marker, $x, $y, $width, $height): void { + $refusal = $map->setEventBounds($marker, $x, $y, $width, $height); + + if ($refusal !== null) { + throw new EventRefusal($refusal); + } + }); + } + + /** + * Refuses an edit to a map whose grids cannot be saved, before anything + * else is asked of it. + * + * @throws MapSourceRefusal + */ + private static function assertEditable(ProjectMap $map): void + { + if ($map->getGridSourceIssue() !== null) { + throw new MapSourceRefusal(sprintf('%s is read-only: %s', $map->mapId, $map->getGridSourceIssue())); + } + } + + /** @throws EventRefusal When the marker is neither placed nor defined. */ + private static function assertEventExists(ProjectMap $map, string $marker): void + { + if ($map->getEventDefinition($marker) === null && $map->getEventArea($marker) === null) { + throw new EventRefusal(sprintf('%s has no event %s.', $map->mapId, $marker)); + } + } + + /** + * Runs an edit and returns the command that redoes and undoes exactly + * what it changed: the event layer cells and the map's events. Null when + * it changed nothing. An edit that throws must have changed nothing. + * + * @param callable(): void $edit + */ + private static function recordEdit(ProjectMap $map, string $label, callable $edit): ?Command + { + $before = self::captureEventSymbols($map); + $hadEvents = $map->hasMapDataField(['events']); + $eventsBefore = $map->getMapDataField(['events']); + + $edit(); + + $cells = []; + foreach (self::captureEventSymbols($map) as $y => $row) { + foreach ($row as $x => $symbol) { + if ($symbol !== $before[$y][$x]) { + $cells[] = [$x, $y, $before[$y][$x], $symbol]; + } + } + } + $hasEvents = $map->hasMapDataField(['events']); + $eventsAfter = $map->getMapDataField(['events']); + + if ($cells === [] && $hasEvents === $hadEvents && $eventsAfter === $eventsBefore) { + return null; + } + + return new GenericCommand( + $label, + static function () use ($map, $cells, $hasEvents, $eventsAfter): void { + $map->setMapDataField(['events'], $hasEvents ? $eventsAfter : null); + foreach ($cells as [$x, $y, , $symbol]) { + $map->setEventSymbol($x, $y, $symbol); + } + }, + static function () use ($map, $cells, $hadEvents, $eventsBefore): void { + $map->setMapDataField(['events'], $hadEvents ? $eventsBefore : null); + foreach ($cells as [$x, $y, $symbol]) { + $map->setEventSymbol($x, $y, $symbol); + } + }, + ); + } + + /** @return list> The event layer's symbols, row by row. */ + private static function captureEventSymbols(ProjectMap $map): array + { + $rows = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + $row = []; + for ($x = 0; $x < $map->getWidth(); $x++) { + $row[] = $map->getEventSymbol($x, $y); + } + $rows[] = $row; + } + + return $rows; + } +} diff --git a/src/Events/EventMarkers.php b/src/Events/EventMarkers.php new file mode 100644 index 00000000..3c243d45 --- /dev/null +++ b/src/Events/EventMarkers.php @@ -0,0 +1,71 @@ +ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + + /** Why a glyph cannot mark an event, or null when it can. */ + public static function findInvalidReason(string $marker): ?string + { + return match (true) { + trim($marker) === '' => 'An event marker cannot be blank.', + TerminalText::symbolCount($marker) !== 1 || TerminalText::displayWidth($marker) !== 1 + => sprintf('Event marker %s is not one terminal cell wide.', $marker), + ctype_digit($marker) => sprintf('Event marker %s is a digit, which the map data stores as a number the engine cannot read as a marker.', $marker), + default => null, + }; + } + + /** + * Every marker the map claims: painted on its event layer or defined in + * its data. + * + * @return list + */ + public static function findUsedMarkers(ProjectMap $map): array + { + return array_values(array_unique([...$map->getPlacedEventMarkers(), ...$map->getEventMarkers()])); + } + + /** + * The first candidate none of the used markers claims, or null when + * every one is taken. + * + * @param iterable $used + */ + public static function findFreeMarker(iterable $used): ?string + { + $taken = []; + foreach ($used as $marker) { + $taken[$marker] = true; + } + + foreach (str_split(self::CANDIDATES) as $candidate) { + if (! isset($taken[$candidate])) { + return $candidate; + } + } + + return null; + } +} diff --git a/src/Events/EventRecordPane.php b/src/Events/EventRecordPane.php new file mode 100644 index 00000000..730c8c57 --- /dev/null +++ b/src/Events/EventRecordPane.php @@ -0,0 +1,479 @@ + The data keys this pane lifted. */ + private readonly array $lifted; + + /** + * @param string $marker The event's marker. + * @param array $definition The event's definition as the map holds it. + * @param ReferenceCatalog|null $references The project's references, for pickers and dialogue emotions. + */ + public function __construct( + public readonly string $marker, + private readonly array $definition, + ?ReferenceCatalog $references = null, + bool $graphical = false, + ) { + $className = is_string($definition['class'] ?? null) ? $definition['class'] : null; + $this->schema = RecordSchemaCatalog::mapEvent($className, $graphical); + [$payload, $this->lifted] = $this->project($definition); + $this->records = ProjectRecordDatabase::overOwnedList($this->schema, '', [$payload], static function (array $entries): void {}, $graphical); + + if ($references !== null) { + $this->records->useAuthoringReferences($references); + } + } + + /** + * The definition paths these rows edit, which no other row of the event + * describes: the generic trigger fields always, and a type's data lists + * when they could be lifted. + * + * @return list> + */ + public function listOwnedPaths(): array + { + $paths = []; + + foreach ($this->schema->fields as $field) { + if (! in_array($field->key, self::DATA_KEYS, true)) { + $paths[] = explode('.', $field->key); + } + } + + foreach ($this->lifted as $key) { + $paths[] = ['data', $key]; + } + + return $paths; + } + + /** The record pane, for what reads its rows by its own rules (a movement route's owner, an item's place). */ + public function records(): ProjectRecordDatabase + { + return $this->records; + } + + /** + * The rows of one frame: at the root the generic trigger fields, the + * talk-to id, the dialogue variants and the row that opens an inline + * script; inside a script frame, its commands. Null when the frame no + * longer resolves. + * + * @param list $frame The frame; [] for the event's own rows. + * @return list>|null + */ + public function getFields(array $frame = []): ?array + { + if ($frame !== [] && $this->records->getFrameCommands(0, $frame) === null) { + return null; + } + + $payload = (array) ($this->records->getRecordByIndex(0)?->toArray() ?? []); + $rows = []; + + $subList = $this->schema->subList; + if ($frame === [] && $subList !== null) { + // The heading a first variant is added at, as a list beside a record's own is headed. + $rows[] = ['label' => ucfirst($subList->key), 'value' => (string) count((array) ($payload[$subList->key] ?? [])), + 'field' => $subList->prefix . 'List', 'editable' => false, 'item' => true, 'itemNoun' => $subList->singular, + 'listHeading' => true, 'childNoun' => $subList->singular, 'target' => self::TARGET, 'marker' => $this->marker, 'eventFrame' => []]; + } + + foreach ($this->records->getFrameSettingsFields(0, $frame) as $row) { + $rows[] = $this->describeRow($row, $frame); + } + + if ($frame === [] && in_array('script', $this->lifted, true) && ($payload['script'] ?? []) !== [] + && trim((string) ($this->definition['data']['scriptId'] ?? '')) !== '') { + // The Engine runs a non-empty inline script instead of the script the id names. + $rows[] = ['label' => '! Inline script replaces Event Script', 'value' => 'the game runs the inline commands', 'editable' => false]; + } + + return $rows; + } + + /** The trail an author reads for a frame, such as "Script > Command 3 > Then". */ + public function describeFrame(array $frame): string + { + return $this->records->describeFramePath($frame); + } + + /** + * The definition the event has once one row is edited, or null when the + * edit changes nothing. Conditions and writes are checked as the Engine + * reads them. + * + * @param list $frame The frame the row is in. + * @param string $fieldId The row's field id. + * @return array|null + * @throws InspectorRefusal When the row is gone or read-only, or the value is refused. + */ + public function applyField(array $frame, string $fieldId, string $rawValue): ?array + { + $row = $this->requireRow($frame, $fieldId); + + if (! self::isEditable($row)) { + throw new InspectorRefusal(sprintf('%s cannot be edited here.', trim((string) ($row['label'] ?? 'That row')))); + } + + return $this->edit(fn(RecordAuthoring $authoring) => $authoring->applyField($this->records, 0, $frame, $fieldId, $rawValue, + trim((string) ($row['label'] ?? 'Event field')))); + } + + /** + * The definition the event has once an item is added at a row: after the + * item the row belongs to (a command, a dialogue variant, a line, an + * option), or at the end of the frame for a row that names none. + * + * @param list $frame The frame the row is in. + * @param string|null $fieldId The row's field id; null for the end of the frame. + * @return array{definition: array|null, summary: string} + * @throws InspectorRefusal When the frame is gone or the row's item cannot take another. + */ + public function addItem(array $frame, ?string $fieldId): array + { + $this->requireFrame($frame); + $ownListHeading = $frame === [] && $this->schema->subList !== null ? $this->schema->subList->prefix . 'List' : null; + // A frame's placeholder and the heading of the event's own list add at its end. + $fieldId = in_array($fieldId, [ProjectRecordDatabase::EMPTY_FRAME_FIELD, $ownListHeading], true) ? null : $fieldId; + $item = $fieldId === null ? null : $this->records->locateItem(0, $frame, $fieldId); + $noun = match (true) { + $item?->kind === RecordItem::LIST => $item->childNoun ?? $item->noun, + $item !== null => $item->noun, + default => $this->records->getFrameSubList($frame)?->singular ?? 'item', + }; + + if ($fieldId !== null && $item === null && $frame === []) { + throw new InspectorRefusal('Nothing here is a list to add to.'); + } + + return ['definition' => $this->edit(fn(RecordAuthoring $authoring) => $authoring->addItem($this->records, 0, $frame, $fieldId)), + 'summary' => sprintf('Added %s.', self::describeOne($noun))]; + } + + /** + * The definition the event has once the item a row belongs to is removed. + * + * @param list $frame The frame the row is in. + * @return array{definition: array|null, summary: string} + * @throws InspectorRefusal When the frame is gone or the row belongs to no item. + */ + public function removeItem(array $frame, string $fieldId): array + { + $this->requireFrame($frame); + $item = $this->records->locateItem(0, $frame, $fieldId); + + if ($item === null || $item->kind === RecordItem::LIST) { + throw new InspectorRefusal('Nothing here is a list entry to remove.'); + } + + return ['definition' => $this->edit(fn(RecordAuthoring $authoring) => $authoring->removeItem($this->records, 0, $frame, $fieldId)), + 'summary' => sprintf('Removed %s.', self::describeOne($item->noun))]; + } + + /** + * Makes one change through the record pane and returns the definition it + * leaves, refusing conditions and writes the Engine would not read. + * + * @param callable(RecordAuthoring): mixed $change + * @return array|null + * @throws InspectorRefusal + */ + private function edit(callable $change): ?array + { + try { + $change(new RecordAuthoring()); + } catch (RecordRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); + } + + $next = $this->unproject((array) ($this->records->getRecordByIndex(0)?->toArray() ?? [])); + + if ($next === $this->definition) { + return null; + } + + $problem = self::findInvalidState($this->definition, $next); + + if ($problem !== null) { + throw new InspectorRefusal($problem); + } + + return $next; + } + + /** + * The record the pane edits: the definition with the type's data lists + * lifted to its root. A list in a shape the pane would not carry whole + * is left where it is, for the event's other rows to show as authored. + * + * @param array $definition + * @return array{0: array, 1: list} + */ + private function project(array $definition): array + { + $data = $definition['data'] ?? null; + $edited = [ + ...array_map(static fn(RecordField $field): string => $field->key, $this->schema->fields), + ...($this->schema->subList === null ? [] : [$this->schema->subList->key]), + ...array_keys($this->schema->commandLists), + ]; + $lifted = []; + + foreach (self::DATA_KEYS as $key) { + if (! in_array($key, $edited, true) || ! is_array($data) || array_key_exists($key, $definition)) { + // Not this type's, no data to lift it from, or a root key of that name already there. + continue; + } + + if (! array_key_exists($key, $data)) { + $lifted[] = $key; + continue; + } + + $value = $data[$key]; + $whole = match ($key) { + 'npcId' => is_scalar($value), + default => is_array($value) && array_is_list($value) && array_all($value, static fn(mixed $entry): bool => is_array($entry)), + }; + + if (! $whole) { + continue; + } + + $definition[$key] = $key === 'dialogue' ? DialogueVariants::fold($value) : $value; + unset($definition['data'][$key]); + $lifted[] = $key; + } + + return [$definition, $lifted]; + } + + /** + * The definition a record payload stands for: lifted lists put back in + * `data`, exactly as authored where nobody changed them. + * + * @param array $payload + * @return array + */ + private function unproject(array $payload): array + { + [$initial] = $this->project($this->definition); + $next = $payload; + + foreach ($this->lifted as $key) { + $held = array_key_exists($key, $payload); + $value = $payload[$key] ?? null; + unset($next[$key]); + + if ($held === array_key_exists($key, $initial) && $value === ($initial[$key] ?? null)) { + if (array_key_exists($key, (array) ($this->definition['data'] ?? []))) { + $next['data'][$key] = $this->definition['data'][$key]; + } + + continue; + } + + if (! $held || ($key === 'script' && $value === [])) { + // An inline script with no commands leaves the event to its Event Script. + continue; + } + + $next['data'][$key] = $key === 'dialogue' && is_array($value) ? DialogueVariants::unfold($value) : $value; + } + + if (is_array($next['data'] ?? null) && is_array($this->definition['data'] ?? null)) { + // Authored keys keep their places; a new one follows them. + $next['data'] = [...array_intersect_key(array_replace($this->definition['data'], $next['data']), $next['data']), ...$next['data']]; + } + + return $next; + } + + /** + * A record row as the inspector lists it: aimed back at this pane, in its + * frame, with what it belongs to, and refused by name when it is a + * condition or write list the line would not carry whole. + * + * @param array $row + * @param list $frame + * @return array + */ + private function describeRow(array $row, array $frame): array + { + $row['target'] = self::TARGET; + $row['marker'] = $this->marker; + $row['eventFrame'] = $frame; + $fieldId = (string) ($row['field'] ?? ''); + $item = $fieldId === '' ? null : $this->records->locateItem(0, $frame, $fieldId); + + if ($item !== null) { + $row['item'] = true; + $row['itemNoun'] = $item->noun; + if ($item->kind === RecordItem::LIST) { $row['listHeading'] = true; } + if ($item->childNoun !== null) { $row['childNoun'] = $item->childNoun; } + } + + // A list its line cannot carry whole is already read-only, as the record layer describes it. + if (($row['conditions'] ?? false) === true) { + // The map inspector's condition editor, in every interface that hosts one. + $row['mapConditions'] = true; + $row['encoded'] = (string) ($row['value'] ?? ''); + $row['conditionsTitle'] = trim((string) ($row['label'] ?? 'Conditions')); + $row['conditionsHint'] = 'Building conditions. An empty list always holds.'; + } + + if (is_array($row['mapPlacement']['sourcePaths'] ?? null)) { + // A lifted list's commands are at their place in the event's data. + $row['mapPlacement']['sourcePaths'] = array_map(fn(array $path): array => in_array($path[0] ?? null, $this->lifted, true) + ? ['data', ...$path] : $path, $row['mapPlacement']['sourcePaths']); + } + + return $row; + } + + /** One of a kind of item, as an author reads it: "a command", "an option". */ + private static function describeOne(string $noun): string + { + return (preg_match('/^[aeiou]/i', $noun) === 1 ? 'an ' : 'a ') . $noun; + } + + /** Whether a row is one an edit applies through: typed, picked, stepped, or built in an editor. */ + private static function isEditable(array $row): bool + { + if (($row['editable'] ?? true) === false) { + return false; + } + + return isset($row['control']) || isset($row['options']) || isset($row['choices']) || isset($row['reference']) + || ($row['conditions'] ?? false) === true || ($row['worldWrites'] ?? false) === true; + } + + /** + * @param list $frame + * @return array + * @throws InspectorRefusal + */ + private function requireRow(array $frame, string $fieldId): array + { + $rows = $this->getFields($frame) ?? throw new InspectorRefusal(sprintf('%s is no longer there; read the event again.', $this->describeFrame($frame))); + + return array_find($rows, static fn(array $row): bool => ($row['field'] ?? null) === $fieldId) + ?? throw new InspectorRefusal('That row is no longer on the event; read it again.'); + } + + /** + * @param list $frame + * @throws InspectorRefusal + */ + private function requireFrame(array $frame): void + { + if ($frame !== [] && $this->records->getFrameCommands(0, $frame) === null) { + throw new InspectorRefusal(sprintf('%s is no longer there; read the event again.', $this->describeFrame($frame))); + } + } + + /** + * Why the Engine would refuse a condition or write list an edit changed, + * or null when it reads every one. Lists the edit left alone are not its + * to answer for. + * + * @param array $before + * @param array $after + */ + private static function findInvalidState(array $before, array $after): ?string + { + $old = self::collectWorldStateLists($before); + + foreach (self::collectWorldStateLists($after) as $source => [$kind, $list]) { + if (($old[$source][1] ?? null) === $list) { + continue; + } + + try { + $kind === RecordFieldCodec::CONDITIONS + ? WorldConditionEvaluator::validateAll($list, $source) + : WorldStateWriter::validateAll($list, $source); + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + } + + return null; + } + + /** + * Every condition and write list a definition holds, by where it is. + * + * @return array}> + */ + private static function collectWorldStateLists(array $definition): array + { + $lists = []; + $add = static function (string $source, RecordFieldCodec $kind, mixed $list) use (&$lists): void { + if (is_array($list)) { $lists[$source] = [$kind, $list]; } + }; + $add('conditions', RecordFieldCodec::CONDITIONS, $definition['conditions'] ?? null); + $add('sets', RecordFieldCodec::WORLD_WRITES, $definition['sets'] ?? null); + $add('cue.conditions', RecordFieldCodec::CONDITIONS, $definition['cue']['conditions'] ?? null); + $dialogue = $definition['data']['dialogue'] ?? null; + + if (is_array($dialogue)) { + foreach (DialogueVariants::fold($dialogue) as $index => $variant) { + $add(sprintf('dialogue[%d].conditions', $index), RecordFieldCodec::CONDITIONS, $variant['conditions'] ?? null); + $add(sprintf('dialogue[%d].sets', $index), RecordFieldCodec::WORLD_WRITES, $variant['sets'] ?? null); + } + } + + return $lists; + } +} diff --git a/src/Events/EventRefusal.php b/src/Events/EventRefusal.php new file mode 100644 index 00000000..a4b8d86e --- /dev/null +++ b/src/Events/EventRefusal.php @@ -0,0 +1,15 @@ + '', - 'mode' => 'action', + 'mode' => ScriptEventTrigger::MODE_ACTION, 'reusable' => false, ], - defaultDefinitionFields: [ - 'conditions' => [], - 'sets' => [], - 'whenBlocked' => '', - 'cue' => ['symbol' => '', 'color' => 'bright-yellow'], - ], + defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], + // The script runs by id or from the event's own commands; an + // empty id is no script, so it is written only once chosen. + optionalData: ['scriptId' => ''], ), new EventTypeDefinition( label: 'Cinematic', @@ -57,12 +62,7 @@ className: CinematicEventTrigger::class, 'mode' => 'auto', 'reusable' => false, ], - defaultDefinitionFields: [ - 'conditions' => [], - 'sets' => [], - 'whenBlocked' => '', - 'cue' => ['symbol' => '', 'color' => 'bright-yellow'], - ], + defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], ), new EventTypeDefinition( label: 'Dialogue', @@ -88,9 +88,8 @@ className: TransferPlayerTrigger::class, 'x' => 0, 'y' => 0, ], - 'spawnSprite' => [ - '🧍', - ], + // A heading the player faces on arrival, as PlayerSpriteSet reads it. + 'spawnSprite' => [MovementHeading::SOUTH->value], ], defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], ), @@ -115,9 +114,8 @@ className: SleepEventTrigger::class, 'x' => 0, 'y' => 0, ], - 'spawnSprite' => [ - '🧍', - ], + // A heading the player faces on arrival, as PlayerSpriteSet reads it. + 'spawnSprite' => [MovementHeading::SOUTH->value], 'confirmDialogue' => [ 'name' => '', 'text' => 'Rest here?', @@ -125,6 +123,10 @@ className: SleepEventTrigger::class, 'cost' => 0, ], defaultDefinitionFields: ['cue' => ['symbol' => '', 'color' => 'bright-yellow']], + // InnOffer's own optional data: the rest music, else the + // project's sleep theme; the rest stage timeline, else + // config's graphics.inn.presentation. + optionalData: ['bgm' => '', 'presentation' => ''], ), new EventTypeDefinition( label: 'Chest', @@ -157,6 +159,21 @@ public static function at(int $index): EventTypeDefinition return $definitions[max(0, min(count($definitions) - 1, $index))]; } + /** + * Returns the type an author names by its label, or null when no type + * has that label. + */ + public static function findByLabel(string $label): ?EventTypeDefinition + { + return array_find(self::all(), static fn(EventTypeDefinition $definition): bool => $definition->label === $label); + } + + /** Returns the type of a trigger class, or null when the catalog does not list it. */ + public static function findByClass(?string $className): ?EventTypeDefinition + { + return array_find(self::all(), static fn(EventTypeDefinition $definition): bool => $definition->className === $className); + } + /** * Returns the index for the given trigger class. * @@ -173,4 +190,24 @@ public static function indexOfClass(?string $className): int return 0; } + + /** + * Returns the label an event of this trigger class is shown by: Unset for + * none, its catalog label, or the class's short name for one the catalog + * does not list. + */ + public static function describeClass(?string $className): string + { + if ($className === null || $className === '') { + return 'Unset'; + } + + foreach (self::all() as $definition) { + if ($definition->className === $className) { + return $definition->label; + } + } + + return basename(str_replace('\\', '/', $className)); + } } diff --git a/src/Events/EventTypeDefinition.php b/src/Events/EventTypeDefinition.php index b067b92b..6c433584 100644 --- a/src/Events/EventTypeDefinition.php +++ b/src/Events/EventTypeDefinition.php @@ -13,6 +13,11 @@ final class EventTypeDefinition * @param array $defaultData * @param array $defaultDefinitionFields Root fields added * beside `class` and `data` when a new event of this type is created. + * @param array $optionalData Data keys an event of this + * type may hold and the Engine reads as unset when absent, with the + * value an inspector shows for one that is absent. They are offered on + * every such event and written only when an author sets one; clearing + * one removes it again. */ public function __construct( public readonly string $label, @@ -20,6 +25,35 @@ public function __construct( public readonly string $description, public readonly array $defaultData, public readonly array $defaultDefinitionFields = [], + public readonly array $optionalData = [], ) { } + + /** + * The definition an event of this type has. An event already of this + * type keeps everything it holds and gains any default it lacks. Any + * other event starts its data again from the defaults, since another + * type's data means nothing to this one, and keeps its root fields + * (conditions, writes, its cue, facing and engagement), which every + * trigger reads whatever its class. + * + * @param array|null $current The event's definition now, if it has one. + * @return array + */ + public function buildDefinition(?array $current): array + { + $defaults = [ + 'class' => $this->className, + 'data' => $this->defaultData, + ...$this->defaultDefinitionFields, + ]; + + if (! is_array($current)) { + return $defaults; + } + + return ($current['class'] ?? null) === $this->className + ? array_replace_recursive($defaults, $current) + : [...$defaults, ...array_diff_key($current, ['class' => true, 'data' => true])]; + } } diff --git a/src/Events/ProjectScriptCommands.php b/src/Events/ProjectScriptCommands.php new file mode 100644 index 00000000..19ea0e44 --- /dev/null +++ b/src/Events/ProjectScriptCommands.php @@ -0,0 +1,99 @@ +catalog = $catalog ?? ScriptCommandCatalog::createEngineCatalog(); + } + + /** + * Reads a project's declared commands. + * + * A file that cannot be read or declares a malformed command leaves the + * Engine's commands and records why, for validation to report. + * + * @param string $projectRoot The project root. + * @return self + */ + public static function fromProject(string $projectRoot): self + { + $relative = 'assets/' . ScriptCommandRegistry::PROJECT_FILE; + $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $relative; + + if (! file_exists($path) && ! is_link($path)) { + return new self(); + } + + try { + return new self(ScriptCommandCatalog::fromDeclarations( + PhpDataFile::evaluateIsolated($path, $projectRoot), + $relative, + )); + } catch (Throwable $exception) { + return new self(declarationProblem: $exception->getMessage()); + } + } + + /** + * Makes these the commands the Engine's script validation accepts and + * the command editor offers, replacing any earlier project's. + * + * @return void + */ + public function activate(): void + { + ScriptCommandRegistry::configure($this->catalog); + } + + /** + * Returns the Editor reference category for a resource a command names. + * + * @param ScriptCommandReference $reference The resource. + * @return string|null The ReferenceCatalog category; null for a troop member, which the owning troop holds. + */ + public static function getReferenceCategory(ScriptCommandReference $reference): ?string + { + return match ($reference) { + // The item store resolves every inventory definition. + ScriptCommandReference::ITEM => 'inventory', + ScriptCommandReference::MUSIC => 'bgm', + ScriptCommandReference::SOUND => 'sfx', + ScriptCommandReference::MAP => 'maps', + ScriptCommandReference::TROOP => 'troops', + ScriptCommandReference::QUEST => 'quests', + ScriptCommandReference::ACTOR => 'actors', + ScriptCommandReference::STAGE_TIMELINE => 'stage_timelines', + ScriptCommandReference::INCOME_SCHEDULE => 'income_schedules', + // The owning troop's members: chosen from that troop, checked by the Engine's page validation. + ScriptCommandReference::TROOP_MEMBER => null, + }; + } +} diff --git a/src/Events/ScriptCommandUses.php b/src/Events/ScriptCommandUses.php new file mode 100644 index 00000000..9badc1c2 --- /dev/null +++ b/src/Events/ScriptCommandUses.php @@ -0,0 +1,103 @@ + $cinematics The project's cinematics, as the editor holds them. + * @param string|null $type Only commands of this type; every command when null. + * @param \Closure(array, string): void $visit Given the command and where it is. + */ + public static function visitCommands(ProjectWorkspace $workspace, iterable $cinematics, ?string $type, \Closure $visit): void + { + $collect = static function (mixed $value, string $where) use (&$collect, $visit, $type): void { + if (! is_array($value)) { + return; + } + + if (($type === null && is_string($value['type'] ?? null)) || ($type !== null && ($value['type'] ?? null) === $type)) { + $visit($value, $where); + } + + foreach ($value as $nested) { + $collect($nested, $where); + } + }; + + foreach ($workspace->maps as $map) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { + $collect($definition, sprintf('map %s event %s', $map->mapId, strval($marker))); + } + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $index => $npc) { + $id = is_array($npc) && is_string($npc['id'] ?? null) ? $npc['id'] : strval($index); + $collect($npc, sprintf('map %s NPC %s', $map->mapId, $id)); + } + } + + foreach ($workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $record) { + $script = (array) $record->toArray(); + $scriptId = trim(strval($script['__scriptId'] ?? '')); + $collect($script, sprintf('event script %s', $scriptId !== '' ? $scriptId : '(unnamed)')); + } + + foreach ($cinematics as $cinematic) { + $collect([$cinematic->data(), $cinematic->partner()], sprintf('cinematic %s', $cinematic->id)); + } + } + + /** + * Where registered commands name a resource of one kind, by the + * resource's identity: every field the command's declaration marks as + * that kind of reference, list entries included. + * + * @param iterable $cinematics The project's cinematics, as the editor holds them. + * @return array> Identity to the places that name it, each once. + */ + public static function findReferenceUses(ProjectWorkspace $workspace, iterable $cinematics, ScriptCommandReference $reference): array + { + $uses = []; + $read = static function (array $fields, array $data, string $where) use (&$read, &$uses, $reference): void { + foreach ($fields as $field) { + $value = ScriptCommandField::readPath($data, $field->key); + if ($field->kind === ScriptCommandFieldKind::REFERENCE && $field->reference === $reference + && is_string($value) && trim($value) !== '') { + $uses[trim($value)][] = $where; + } elseif ($field->kind === ScriptCommandFieldKind::LIST && is_array($value)) { + foreach (array_filter($value, is_array(...)) as $entry) { + $read($field->fields, $entry, $where); + } + } + } + }; + + self::visitCommands($workspace, $cinematics, null, static function (array $command, string $where) use ($read): void { + $definition = is_string($command['type'] ?? null) ? ScriptCommandRegistry::getCatalog()->findDefinition($command['type']) : null; + if ($definition !== null) { + $read($definition->fields, $command, $where); + } + }); + + return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); + } +} diff --git a/src/Field/AuthoringCanvasHost.php b/src/Field/AuthoringCanvasHost.php new file mode 100644 index 00000000..ed0482bc --- /dev/null +++ b/src/Field/AuthoringCanvasHost.php @@ -0,0 +1,130 @@ +grid = self::getGrid($map); + $this->relay = new PreviewRendererRelay(); + $this->client = new RendererClient($this->relay); + $this->client->start(new RendererSessionConfig('Authoring canvas', $map->getAssetRoot(), $this->grid, RendererProtocolVersion::V2)); + $this->presentation = new RendererPresentation($this->client, $this->grid); + $this->composer = new SceneFrameComposer($map->getAssetRoot()); + $this->signs = new AuthoringSignPlaybacks($map->getAssetRoot()); + } + + /** + * Whether this canvas draws a map as it stands: that reading of it, at + * its size. A map read afresh (a file set written at once reloads the + * project) or resized needs a canvas of its own. + */ + public function drawsMap(ProjectMap $map): bool + { + return $this->map === $map && $this->grid == self::getGrid($map); + } + + /** The grid a map's canvas draws: one renderer cell per map cell, within the renderer's limits. */ + public static function getGrid(ProjectMap $map): RendererGridConfig + { + return new RendererGridConfig(min(self::MAX_COLUMNS, $map->getWidth()), min(self::MAX_ROWS, $map->getHeight()), + FieldViewport::TILE_SIZE, FieldViewport::TILE_SIZE); + } + + /** + * One exchange with the window, presenting the map at a variant selection and preview time. + * + * @param list $events The window's answers, as renderer event lines. + * @param array $variants + * @return list}> + * @throws RendererTransportException When the window reports an error. + */ + public function exchange(array $events, array $variants, float $seconds): array + { + $this->relay->receive($events); + $this->client->pump(); + $invalidate = false; + $expectedGeneration = null; + foreach ($this->client->drainEvents() as $event) { + if ($event->type === RendererEventType::READY) { + $this->ready = true; + } elseif ($event->type === RendererEventType::FRAME_REJECTED) { + $invalidate = true; + $expectedGeneration = max($expectedGeneration ?? 0, $event->expectedGeneration ?? 0); + } elseif ($event->type === RendererEventType::FRAME_ACK) { + $this->presentation->acknowledge($event->generation, $event->presented); + } elseif ($event->type === RendererEventType::ERROR) { + throw new RendererTransportException('Editing canvas error: ' . $event->message, $this->relay->getDiagnostics()); + } + } + foreach ($this->client->drainFrameAcknowledgements() as $acknowledgement) { + $this->presentation->acknowledge($acknowledgement->generation, $acknowledgement->presented); + } + if ($invalidate) { + $this->presentation->invalidate(expectedGeneration: $expectedGeneration); + } + if ($this->ready) { + $this->source = AuthoringMapSource::compose($this->map, $variants, $seconds, $this->signs, $this->client, + $this->grid->columns, $this->grid->rows, $this->source); + // The canvas draws its glyphs itself; the composer's Terminal text is the empty picture of the grid. + $composition = $this->composer->composeFrame($this->source, $this->grid, $this->client->supports(...), + fn(array $excluded, array $worldLayers) => Console::capturePresentation($this->grid->columns, $this->grid->rows, + static function (): void {}, $excluded, $worldLayers, true)); + $this->presentation->presentFrame($composition->prepareFrame($this->presentation), $composition->screenOverlay); + } + + return $this->relay->collect(); + } + + /** What the last composed picture depends on and could not draw, once one has been composed. */ + public function getSource(): ?AuthoringMapSource + { + return $this->source; + } + + /** Releases the signs' prepared labels and closes the renderer client, as closing the map does. */ + public function dispose(): void + { + $this->signs->releaseAll(); + $this->client->shutdown(); + } +} diff --git a/src/Field/AuthoringMapSource.php b/src/Field/AuthoringMapSource.php new file mode 100644 index 00000000..a073b661 --- /dev/null +++ b/src/Field/AuthoringMapSource.php @@ -0,0 +1,164 @@ + $sprites + * @param list $groups + * @param list $assetPaths Every file the picture depends on, for the canvas to watch. + * @param list $diagnostics + */ + private function __construct( + private readonly PresentationWorld $world, + private readonly array $sprites, + private readonly array $groups, + private readonly int $columns, + private readonly int $rows, + private readonly float $scale, + public readonly array $assetPaths, + public readonly array $diagnostics, + public readonly ?string $graphicsIssue, + ) { + } + + /** + * Composes a map at a variant selection and preview time. Signs are laid out through the given provider, the + * canvas's own renderer client; a sign whose label is not prepared yet shows its glyphs until it is. The previous + * picture's world is kept while the map composes to the same one: the renderer retains a world by identity, so + * an unchanged map sends nothing and the window's exchanges settle. + * + * @param array $variants The variant shown for each object, by id; none is the object's own. + */ + public static function compose(ProjectMap $map, array $variants, float $seconds, AuthoringSignPlaybacks $signs, + ?TextPreparationProviderInterface $provider, int $columns, int $rows, ?self $previous = null): self + { + if (! is_finite($seconds) || $seconds < 0 || $seconds > 3600) { + throw new InvalidArgumentException('Preview time requires finite seconds in 0..3600.'); + } + $issue = null; + try { + $graphics = $map->loadGraphics(); + } catch (InvalidArgumentException|\RuntimeException $error) { + $graphics = null; + $issue = $error->getMessage(); + } + $entries = WorldObjectAuthoring::readEntries($map); + $definitions = WorldObjectAuthoring::validateEntries($map, $entries); + if (array_diff(array_keys($variants), array_column($definitions, 'id')) !== []) { + throw new InvalidArgumentException('Preview selection names an unknown map-local object.'); + } + if (array_any($variants, static fn($id): bool => ! is_string($id))) { + throw new InvalidArgumentException('Preview variants must name known variant ids.'); + } + $coverage = $sprites = $groups = $diagnostics = $placed = []; + $assetPaths = array_values($graphics?->tileset->sheets ?? []); + $entriesById = array_column($entries, null, 'id'); + foreach ($definitions as $object) { + $id = 'world-object:' . $map->mapId . ':' . $object->id; + if ($object->resource?->sign !== null) { + // A sign covers its cells only once its board and label are drawn, as the field's does. + $placed[] = $object->id; + $assetPaths[] = $object->resource->sign->asset; + $presentation = $signs->getPlayback($object, $entriesById[$object->id] ?? [])->getPresentation($provider); + if ($presentation !== null) { + $groups[] = new PresentationWorldCanvas($id, $object->x, $object->y, $presentation[1], $presentation[0], + $object->resource->sign->layer); + $coverage = array_replace_recursive($coverage, $object->coverage); + } + continue; + } + $selected = $variants[$object->id] ?? ''; + $definition = $object->sprites; + if ($selected !== '') { + $variant = array_find($object->variants, static fn($variant): bool => $variant['id'] === $selected) + ?? throw new InvalidArgumentException('Choose a known world-object variant to preview.'); + $definition = $variant['sprites']; + } + $role = $definition === null ? null : new FieldSpriteRole($definition, $map->getAssetRoot(), 'World-object authoring ' . $object->id); + if ($definition !== null) { + $assetPaths[] = $definition instanceof FieldPoseAnimation ? $definition->image->asset : $definition->asset; + } + try { + $role?->advance($seconds); + $frame = $role?->getFrame($object->pivot); + if ($definition === null || $frame !== null) { + $coverage = array_replace_recursive($coverage, $object->coverage); + } else { + $diagnostics[] = $object->id . ': the current selected role cannot be drawn; covered glyphs and tiles remain.'; + } + if ($frame !== null) { + $sprites[] = new PresentationSprite($id, $frame->asset, $object->x, $object->y, $frame->width, $frame->height, + $frame->anchor, $frame->layer, $frame->sourceRect, lift: $frame->lift, quarterTurns: $frame->quarterTurns, + pivot: $frame->pivot); + } + } finally { + $role?->release(); + } + } + $signs->retain($placed); + $world = PresentationWorld::getFromLayers($map->getLayerSet(), self::WORLD_ID, $graphics, $map->getAssetRoot(), $coverage); + if ($previous !== null && $previous->world == $world) { + $world = $previous->world; + } + + return new self($world, PresentationSprite::orderedList($sprites), $groups, $columns, $rows, + min($columns / $map->getWidth(), $rows / $map->getHeight()), array_values(array_unique($assetPaths)), $diagnostics, $issue); + } + + public function getPresentationWorld(): ?PresentationWorld + { + return $this->world; + } + + public function getPresentationSprites(): array + { + return $this->sprites; + } + + public function getPresentationWorldCanvases(): array + { + return $this->groups; + } + + /** The whole map in view at its own origin; the canvas pans and zooms over it without moving the camera. */ + public function getPresentationViewport(ConsolePresentationSnapshot|ConsolePresentationChanges $snapshot, array $sprites, array $tiles = []): ?PresentationViewport + { + return new PresentationViewport($this->scale, 0, 0, + new CanvasRectangle(0, 0, $this->columns * FieldViewport::TILE_SIZE, $this->rows * FieldViewport::TILE_SIZE), + spriteIds: array_column($sprites, 'id'), worldId: self::WORLD_ID); + } +} diff --git a/src/Field/AuthoringSignPlaybacks.php b/src/Field/AuthoringSignPlaybacks.php new file mode 100644 index 00000000..74040f77 --- /dev/null +++ b/src/Field/AuthoringSignPlaybacks.php @@ -0,0 +1,65 @@ + */ + private array $playbacks = []; + + public function __construct(private readonly string $assetRoot) + { + } + + /** + * The playback for a sign placement, the existing one while the placement is as it was. + * + * @param array $entry The placement as authored, which identifies what it shows. + */ + public function getPlayback(WorldObjectDefinition $definition, array $entry): WorldSignPlayback + { + $key = json_encode($entry, JSON_THROW_ON_ERROR); + $kept = $this->playbacks[$definition->id] ?? null; + if ($kept !== null && $kept['key'] === $key) { + return $kept['playback']; + } + if ($kept !== null) { + $kept['playback']->release(); + } + $playback = new WorldSignPlayback($definition, $this->assetRoot); + $this->playbacks[$definition->id] = ['key' => $key, 'playback' => $playback]; + + return $playback; + } + + /** + * Releases the playbacks of signs the map no longer places. + * + * @param list $ids The ids of the signs the map places now. + */ + public function retain(array $ids): void + { + foreach (array_diff(array_keys($this->playbacks), $ids) as $id) { + $this->playbacks[$id]['playback']->release(); + unset($this->playbacks[$id]); + } + } + + /** Releases every playback, as closing the canvas does. */ + public function releaseAll(): void + { + $this->retain([]); + } +} diff --git a/src/Field/CharacterSheetFields.php b/src/Field/CharacterSheetFields.php new file mode 100644 index 00000000..f2eefc39 --- /dev/null +++ b/src/Field/CharacterSheetFields.php @@ -0,0 +1,87 @@ + */ + public static function getFields(mixed $binding, string $prefix, string $label): array + { + $fields = [RecordField::reference($prefix . '.sheet', $label, 'png_assets', + allowsNone: true, noneLabel: 'None: no field sheet')]; + if ($binding === null) { + return $fields; + } + $asset = is_array($binding) && is_string($binding['sheet'] ?? null) ? $binding['sheet'] : ''; + $fields[] = new RecordField($prefix . '.index', 'Sheet Character Index', InputControlType::INTEGER, + options: CharacterSheetPreview::getIndexOptions($asset), displayDefault: '0'); + $fields[] = new RecordField($prefix . '.layer', 'Graphical Layer', InputControlType::INTEGER, + displayDefault: (string) CharacterSheet::DEFAULT_LAYER); + + return $fields; + } + + /** Attach the same current-file four-direction preview used for NPCs. */ + public static function describeFields(array $fields, mixed $binding, string $prefix, ?string $assetRoot, bool $worldLayer = true): array + { + if ($binding !== null) { + foreach ($fields as &$field) { + if (($field['field'] ?? null) === $prefix . '.sheet' && ($field['editable'] ?? true) !== false) { + $field['imagePreview'] = CharacterSheetPreview::describe($binding, $assetRoot, $worldLayer); + } + } + unset($field); + } + + return $fields; + } + + /** Refuse invalid selections before changing their owner or its history. */ + public static function applyField(mixed $binding, string $field, string $value, string $assetRoot, bool $worldLayer = true): ?array + { + if ($field === 'sheet' && $value === '') { + return null; + } + if (! in_array($field, ['sheet', 'index', 'layer'], true)) { + throw new InvalidArgumentException('Unknown character sheet field.'); + } + if ($binding !== null && ! is_array($binding)) { + throw new InvalidArgumentException('Clear the malformed character sheet before choosing a new one.'); + } + $draft = $binding ?? []; + if ($field === 'sheet') { + $draft['sheet'] = $value; + } else { + if (! is_string($draft['sheet'] ?? null) || $draft['sheet'] === '') { + throw new InvalidArgumentException('Select a field sheet before editing its character index or graphical layer.'); + } + if (preg_match('/^-?\d+$/D', $value) !== 1 || filter_var($value, FILTER_VALIDATE_INT) === false) { + throw new InvalidArgumentException('A sheet character index or graphical layer must be an integer.'); + } + $draft[$field] = (int) $value; + } + try { + if ($worldLayer) { + NpcCharacterSheet::validate($draft, $assetRoot); + } else { + $sheet = CharacterSheet::fromArray($draft); + $image = PngAssetPreflight::inspect($assetRoot, $sheet->asset); + $sheet->getFrameSize($image['width'], $image['height']); + } + } catch (RuntimeException $refused) { + throw new InvalidArgumentException($refused->getMessage(), previous: $refused); + } + + return $draft; + } +} diff --git a/src/Field/CharacterSheetPreview.php b/src/Field/CharacterSheetPreview.php new file mode 100644 index 00000000..4bd8aa20 --- /dev/null +++ b/src/Field/CharacterSheetPreview.php @@ -0,0 +1,53 @@ +, issue?: string} */ + public static function describe(mixed $data, ?string $assetRoot, bool $worldLayer = true): array + { + try { + if (! is_array($data)) { + throw new InvalidArgumentException('A character sheet requires sheet, index and layer data.'); + } + if ($assetRoot === null) { + throw new RuntimeException('The current owner has no project asset root for character-sheet preview.'); + } + $sheet = $worldLayer ? NpcCharacterSheet::validate($data) : CharacterSheet::fromArray($data); + $image = PngAssetPreflight::inspect($assetRoot, $sheet->asset); + $size = $sheet->getFrameSize($image['width'], $image['height']); + $frames = []; + foreach (['South' => MovementHeading::SOUTH, 'West' => MovementHeading::WEST, + 'East' => MovementHeading::EAST, 'North' => MovementHeading::NORTH] as $label => $heading) { + $frames[] = ['label' => $label, 'sourceRect' => $sheet->getFrame($heading, 1, $size)->sourceRect->toArray()]; + } + + return ['frames' => $frames]; + } catch (InvalidArgumentException|RuntimeException $issue) { + return ['frames' => [], 'issue' => $issue->getMessage()]; + } + } + + /** @return list Character identity choices, independent of stale optional index/layer values. */ + public static function getIndexOptions(string $asset): array + { + try { + $sheet = new CharacterSheet($asset); + + return array_map(strval(...), range(0, $sheet->isSingleCharacter ? 0 + : CharacterSheet::SHEET_CHARACTER_COLUMNS * CharacterSheet::SHEET_CHARACTER_ROWS - 1)); + } catch (InvalidArgumentException) { + return []; + } + } +} diff --git a/src/Field/FieldResourceFields.php b/src/Field/FieldResourceFields.php new file mode 100644 index 00000000..f8c135d7 --- /dev/null +++ b/src/Field/FieldResourceFields.php @@ -0,0 +1,209 @@ + preg_match('/^[a-z]/', $id) === 1 ? $id : 'resource-' . $id, 'name' => $name, + 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => $images[0]]]; + } + + /** What a resource shows: an image, or a sign board its placements label. */ + public const string IMAGE = 'Image'; + public const string SIGN = 'Sign board'; + /** The font role a new sign board names until its author chooses one. */ + public const string DEFAULT_FONT_ROLE = 'sign'; + + /** @return list */ + public static function getFields(array $entry): array + { + $sign = array_key_exists('sign', $entry); + + return [ + new RecordField('id', 'Stable Id'), + new RecordField('name', 'Name'), + new RecordField('pivot.x', 'Ground Contact X (0..1)', InputControlType::FLOAT), + new RecordField('pivot.y', 'Ground Contact Y (0..1)', InputControlType::FLOAT), + // Not stored: choosing it reshapes the resource ({@see checkEdit()}). + new RecordField('presentation', 'Shows', options: [self::IMAGE, self::SIGN], displayDefault: $sign ? self::SIGN : self::IMAGE), + ...($sign ? self::getSignFields() : FieldSpriteFields::getFields($entry)), + ]; + } + + /** + * A sign board: its image, cut into fixed end caps and a repeating centre so it widens to fit its label, the + * project font role its label is set in, and the label's padding and width limits. Cuts are source pixels; + * padding and widths are logical pixels. + * + * @return list + */ + private static function getSignFields(): array + { + return [ + RecordField::reference('sign.asset', 'Board Image', 'png_assets'), + RecordField::reference('sign.fontRole', 'Label Font Role', 'text_roles'), + new RecordField('sign.left', 'Left Cap Width (source px)', InputControlType::INTEGER), + new RecordField('sign.right', 'Right Cap Width (source px)', InputControlType::INTEGER), + new RecordField('sign.repeatWidth', 'Repeating Centre Width (source px)', InputControlType::INTEGER, removeWhenEmpty: true, + displayDefault: '(the whole centre)'), + new RecordField('sign.density', 'Pixel Density', InputControlType::INTEGER), + new RecordField('sign.padding', 'Label Padding (left, top, right, bottom)', codec: RecordFieldCodec::CSV_INTEGERS), + new RecordField('sign.minimumWidth', 'Minimum Width', InputControlType::INTEGER), + new RecordField('sign.maximumWidth', 'Maximum Width', InputControlType::INTEGER), + new RecordField('sign.layer', 'Field Layer', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0'), + ]; + } + + /** A sign board as a resource first shows: the given image whole as its centre, for the author to cut. */ + private static function makeSign(string $asset, string $fontRole): array + { + return ['asset' => $asset, 'fontRole' => $fontRole, 'left' => 0, 'right' => 0, 'density' => 1, + 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 192]; + } + + /** + * The optional authoring stamp: cells relative to a placement's anchor and the physical type each gets when a + * brush places the resource. Placing never infers collision from the art. + */ + private static function getStampList(): RecordSubList + { + $types = array_values(array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH)); + + return new RecordSubList(key: 'occupancyStamp', prefix: 'stamp', singular: 'stamped cell', + fields: [new RecordField('x', 'Offset X', InputControlType::INTEGER), new RecordField('y', 'Offset Y', InputControlType::INTEGER), + new RecordField('type', 'Physical Type', options: array_map(static fn(CollisionType $type): string => $type->name, $types), + enumClass: CollisionType::class)], + blank: ['x' => 0, 'y' => 0, 'type' => CollisionType::SOLID], heading: 'Occupancy Stamp', removeWhenEmpty: true); + } + + /** + * The optional doorway: cells relative to a placement's anchor where the art paints a door, and where each door's + * entrance lies: on the painted opening (the default) or in front of a door painted on a solid face. Validation + * requires each placement's doors to meet an entrance exactly so; the art conforms to the map, so a doorway that + * misses its entrance is corrected here or in the art, never by moving the entrance. + */ + private static function getDoorwayList(): RecordSubList + { + return new RecordSubList(key: 'doorway', prefix: 'doorway', singular: 'doorway cell', + fields: [new RecordField('x', 'Offset X', InputControlType::INTEGER), new RecordField('y', 'Offset Y', InputControlType::INTEGER), + new RecordField('entrance', 'Entrance (on the door, or in front of it)', + options: array_column(DoorwayEntrance::cases(), 'value'), displayDefault: DoorwayEntrance::ON->value)], + blank: ['x' => 0, 'y' => 0], heading: 'Doorway', removeWhenEmpty: true); + } + + /** + * The optional attachments: named features of the art where visuals attach, such as an aerial a signal pulses + * on. Each has the terminal cell that shows it, relative to a placement's anchor, and its point on the art, + * normalized like the ground contact. When the art is replaced or re-cropped, the point is updated here once and + * every visual staged on the feature follows it. + */ + private static function getAttachmentList(): RecordSubList + { + return new RecordSubList(key: 'attachments', prefix: 'attachment', singular: 'attachment', + fields: [new RecordField('id', 'Stable Id'), + new RecordField('cell.x', 'Terminal Cell X', InputControlType::INTEGER), new RecordField('cell.y', 'Terminal Cell Y', InputControlType::INTEGER), + new RecordField('point.x', 'Art Point X (0..1)', InputControlType::FLOAT), new RecordField('point.y', 'Art Point Y (0..1)', InputControlType::FLOAT)], + blank: ['id' => 'attachment', 'cell' => ['x' => 0, 'y' => 0], 'point' => ['x' => .5, 'y' => .5]], heading: 'Attachments', + removeWhenEmpty: true); + } + + /** A stable id is refused as it is typed, by the Engine's own rule, rather than at the save. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'presentation') { + // A resource shows exactly one presentation, as the Engine admits it; switching keeps the image chosen. + $choice = $entry['presentation'] ?? null; + unset($entry['presentation']); + $image = $entry['sign']['asset'] ?? $entry['sprites2d']['asset'] ?? ''; + if ($choice === self::SIGN && ! array_key_exists('sign', $entry)) { + unset($entry['sprites2d']); + // The project's sign role by convention; validation names it when the catalogue has none. + $entry['sign'] = self::makeSign($image, self::DEFAULT_FONT_ROLE); + } elseif ($choice === self::IMAGE && array_key_exists('sign', $entry)) { + unset($entry['sign']); + $entry['sprites2d'] = ['asset' => $image]; + } + } + if ($field === 'id') { + try { + WorldObjectResource::assertId($entry['id'] ?? null); + } catch (Throwable $error) { + throw new RecordRefusal($error->getMessage(), previous: $error); + } + } + + return $entry; + } + + /** The Engine's own admission of the whole catalogue, so a save never writes a file the game refuses. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + FieldPresentationCatalog::fromArray($whole, $projectRoot . '/assets'); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/src/Field/FieldSpriteFields.php b/src/Field/FieldSpriteFields.php new file mode 100644 index 00000000..ad14b7f8 --- /dev/null +++ b/src/Field/FieldSpriteFields.php @@ -0,0 +1,78 @@ + $field->key === 'sprites2d.index' + ? new RecordField('sprites2d.index', 'Sheet Character Index', InputControlType::INTEGER, displayDefault: '0') : $field, $walking); + } + return [$fields[0], ...$walking]; + } + return [...$fields, + new RecordField('sprites2d.layer', 'Graphical Layer', InputControlType::INTEGER, displayDefault: '0'), + new RecordField('sprites2d.cells', 'Visual Size (cells)', codec: RecordFieldCodec::SIZE, removeWhenEmpty: true, displayDefault: '1, 1'), + new RecordField('sprites2d.sourceRect', 'Image Region', codec: RecordFieldCodec::SOURCE_RECT, removeWhenEmpty: true, displayDefault: '(whole image)'), + new RecordField('sprites2d.animation.frames', 'Pose Frame Order (empty: static)', codec: RecordFieldCodec::CSV_INTEGERS, removeWhenEmpty: true), + new RecordField('sprites2d.animation.columns', 'Pose Grid Columns', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('sprites2d.animation.rows', 'Pose Grid Rows', InputControlType::INTEGER, displayDefault: '1'), + new RecordField('sprites2d.animation.fps', 'Pose Frames Per Second', InputControlType::INTEGER, displayDefault: (string) FieldPoseAnimation::DEFAULT_FPS), + RecordField::boolean('sprites2d.animation.loop', 'Loop Pose', removeWhenEmpty: false, displayDefault: 'true'), + new RecordField('sprites2d.animation.restFrame', 'Reduced-Motion Rest Cell', InputControlType::INTEGER, displayDefault: '0'), + ]; + } + + public static function prepareEdit(array $entry, string $field, bool $explicitAbsence = false): array + { + if (!str_starts_with($field, 'sprites2d.')) { return $entry; } + $sprites = $entry['sprites2d'] ?? []; + if ($field === 'sprites2d.sheet' || $field === 'sprites2d.asset') { + $key = substr($field, strlen('sprites2d.')); + if (!isset($sprites[$key])) { + if (($key === 'sheet' && isset($sprites['asset'])) || ($key === 'asset' && isset($sprites['sheet']))) { return $entry; } + if ($explicitAbsence) { $entry['sprites2d'] = null; } else { unset($entry['sprites2d']); } + return $entry; + } + if ($key === 'sheet') { unset($sprites['asset'], $sprites['cells'], $sprites['sourceRect'], $sprites['animation']); } + else { unset($sprites['sheet'], $sprites['index']); } + } + if ($field === 'sprites2d.animation.frames' && !isset($sprites['animation']['frames'])) { unset($sprites['animation']); } + elseif (str_starts_with($field, 'sprites2d.animation.')) { $sprites['animation']['frames'] ??= [0]; } + FieldSpriteRole::readDefinition($sprites); + $entry['sprites2d'] = $sprites; + return $entry; + } + + public static function describeFields(array $fields, array $entry, ?string $assetRoot): array + { + foreach ($fields as &$field) { + if (($field['sourceKey'] ?? $field['field'] ?? null) === 'sprites2d.sheet' && is_array($entry['sprites2d'] ?? null)) { + $field['imagePreview'] = CharacterSheetPreview::describe($entry['sprites2d'], $assetRoot); + } + } + unset($field); + return $fields; + } +} diff --git a/src/Field/MapAreaAuthoring.php b/src/Field/MapAreaAuthoring.php new file mode 100644 index 00000000..b6efc1e5 --- /dev/null +++ b/src/Field/MapAreaAuthoring.php @@ -0,0 +1,354 @@ + ..., 'area' => ...]` entries, the + * area in the Engine's CellArea shape: exact `cells`, or `x`, `y`, `width` + * and `height`. Every edit is checked by the Engine's own reader + * ({@see MapAreaCatalog}) against the map's physical grid before it lands, + * and is one undo step. Renaming an area rewrites the events, landmarks and + * encounter troops that name it in the same step; deleting one they name is + * refused, naming them. + * + * @package Ichiloto\Editor\Field + */ +final readonly class MapAreaAuthoring +{ + /** The map key holding the declarations. */ + public const string KEY = 'areas'; + + /** The event subject kind that names an area, one of {@see \Ichiloto\Engine\Events\MapEventDefinition::SUBJECT_KINDS}. */ + public const string SUBJECT_KIND = 'area'; + + /** The landmark anchor kind that names an area, one of the Engine's landmark anchor kinds. */ + public const string ANCHOR_KIND = 'area'; + + public function __construct(private ProjectMap $map) + { + } + + /** + * The map's area declarations as authored, unsaved edits included. + * + * @return list> + * @throws MapAreaRefusal When the authored block is not a list of declarations. + */ + public static function readEntries(ProjectMap $map): array + { + // A map without the block has no areas yet; a block written as null is + // an authored declaration the Engine refuses, never an empty list. + if (! $map->hasMapDataField([self::KEY])) { + return []; + } + $entries = $map->getMapDataField([self::KEY]); + if (! is_array($entries) || ! array_is_list($entries) || array_any($entries, static fn($entry): bool => ! is_array($entry))) { + throw new MapAreaRefusal(sprintf('%s: areas must be a list of declarations; it cannot be edited as areas.', $map->mapId)); + } + + return $entries; + } + + /** + * The map's areas as the Engine reads them: each id with its cells. + * + * @throws MapAreaRefusal With the Engine's reason when the map would not load them. + */ + public static function readCatalog(ProjectMap $map): MapAreaCatalog + { + return self::readCatalogOf($map, self::readEntries($map)); + } + + /** + * The areas as an author reads them: each id, its shape as written, and + * the cells it covers. + * + * @return list, cells: list}> + */ + public static function describeAreas(ProjectMap $map): array + { + $catalog = self::readCatalog($map); + $described = []; + foreach (self::readEntries($map) as $entry) { + $id = (string) $entry['id']; + $described[] = ['id' => $id, 'area' => (array) $entry['area'], + 'cells' => array_map(static fn(array $cell): array => [$cell[0], $cell[1]], $catalog->getArea($id)->cells)]; + } + + return $described; + } + + /** + * Declares a new area. The shape is the Engine's CellArea shape. + * + * @param array $area + * @throws MapAreaRefusal When the id is taken or not an area id, or the area is empty, malformed or off the map. + */ + public function createArea(string $id, array $area): Command + { + $entries = self::readEntries($this->map); + if (self::findIndex($entries, $id) !== null) { + throw new MapAreaRefusal(sprintf('%s already has an area %s.', $this->map->mapId, $id)); + } + $entries[] = ['id' => $id, 'area' => $area]; + + return $this->record('Create map area', fn() => $this->writeEntries($entries)) + ?? throw new MapAreaRefusal(sprintf('Area %s could not be created.', $id)); + } + + /** + * Gives an area a new shape. Null when it already had it. + * + * @param array $area + * @throws MapAreaRefusal When there is no such area, or the area is empty, malformed or off the map. + */ + public function setArea(string $id, array $area): ?Command + { + $entries = self::readEntries($this->map); + $index = $this->requireIndex($entries, $id); + $entries[$index]['area'] = $area; + + return $this->record('Reshape map area', fn() => $this->writeEntries($entries)); + } + + /** + * Gives an area a new id, and every event, landmark and encounter troop + * naming it the new id, as one undo step. Null when the id is unchanged. + * + * @throws MapAreaRefusal When there is no such area, or the new id is taken or not an area id. + */ + public function renameArea(string $id, string $to): ?Command + { + if ($to === $id) { + return null; + } + $entries = self::readEntries($this->map); + $index = $this->requireIndex($entries, $id); + if (self::findIndex($entries, $to) !== null) { + throw new MapAreaRefusal(sprintf('%s already has an area %s.', $this->map->mapId, $to)); + } + $entries[$index]['id'] = $to; + + return $this->record('Rename map area', function () use ($entries, $id, $to): void { + $this->writeEntries($entries); + foreach ($this->map->getEventDefinitions() as $marker => $definition) { + if (self::namesArea(is_array($definition) ? $definition : [], $id)) { + $this->map->setEventField((string) $marker, ['subject', 'id'], $to); + } + } + $landmarks = $this->map->getMapDataField([MapLandmarks::KEY]); + if (is_array($landmarks)) { + foreach ($landmarks as $index => $landmark) { + if (is_array($landmark) && ($landmark['anchor'] ?? null) === [self::ANCHOR_KIND => $id]) { + $landmarks[$index]['anchor'] = [self::ANCHOR_KIND => $to]; + } + } + $this->map->setMapDataField([MapLandmarks::KEY], $landmarks); + } + $encounters = MapEncounters::fromMap($this->map); + if ($encounters->isSupported() && ($encounters->troopsInArea($id) !== [] || $encounters->arenaRulesInArea($id) !== [])) { + $this->map->setMapDataField([MapEncounters::KEY], $encounters->withAreaRenamed($id, $to)); + } + }); + } + + /** + * Removes an area nothing names. + * + * @throws MapAreaRefusal When there is no such area, or an event, landmark, encounter troop or battle arena rule names it. + */ + public function deleteArea(string $id): Command + { + $entries = self::readEntries($this->map); + array_splice($entries, $this->requireIndex($entries, $id), 1); + $references = self::describeReferences($this->map, $id); + if ($references !== []) { + throw new MapAreaRefusal(sprintf("Area %s is named by %s; change those first.\n%s", $id, implode(', ', $references), + implode("\n", array_map(static fn(string $reference): string => '- ' . $reference, $references)))); + } + + return $this->record('Delete map area', fn() => $this->writeEntries($entries)) + ?? throw new MapAreaRefusal(sprintf('Area %s could not be deleted.', $id)); + } + + /** + * What names an area: events bound to it as their subject, landmarks + * anchored to it, encounter troops met in it, and battle arena rules for it. + * + * @return list + */ + public static function describeReferences(ProjectMap $map, string $id): array + { + $found = []; + foreach ($map->getEventDefinitions() as $marker => $definition) { + if (self::namesArea(is_array($definition) ? $definition : [], $id)) { + $found[] = sprintf('%s event %s', $map->mapId, $marker); + } + } + + $encounters = MapEncounters::fromMap($map); + $troops = $encounters->isSupported() ? $encounters->troopsInArea($id) : []; + $rules = $encounters->isSupported() ? $encounters->arenaRulesInArea($id) : []; + + return [...$found, ...MapLandmarks::describeAnchorReferences($map, [self::ANCHOR_KIND => $id]), + ...array_map(static fn(string $troop): string => sprintf('%s encounter %s', $map->mapId, $troop), $troops), + ...array_map(static fn(int $rule): string => sprintf('%s battle arena rule %d', $map->mapId, $rule + 1), $rules)]; + } + + /** Whether an event definition is bound to the area as its subject. */ + public static function namesArea(array $definition, string $id): bool + { + $subject = $definition['subject'] ?? null; + + return is_array($subject) && ($subject['kind'] ?? null) === self::SUBJECT_KIND && ($subject['id'] ?? null) === $id; + } + + /** + * Writes the declarations once the Engine accepts them; an empty list + * removes the block. + * + * @param list> $entries + * @throws MapAreaRefusal With the Engine's reason when the map would not load them. + */ + private function writeEntries(array $entries): void + { + self::readCatalogOf($this->map, $entries); + $this->map->setMapDataField([self::KEY], $entries === [] ? null : $entries); + } + + /** + * @param list> $entries + * @throws MapAreaRefusal + */ + private static function readCatalogOf(ProjectMap $map, array $entries): MapAreaCatalog + { + try { + return MapAreaCatalog::readMap([self::KEY => $entries], $map->getLayerSet()->getComposedGrid()); + } catch (InvalidArgumentException $error) { + throw new MapAreaRefusal(sprintf('%s: %s', $map->mapId, $error->getMessage()), previous: $error); + } + } + + /** @param list> $entries */ + private static function findIndex(array $entries, string $id): ?int + { + foreach ($entries as $index => $entry) { + if (($entry['id'] ?? null) === $id) { + return $index; + } + } + + return null; + } + + /** + * @param list> $entries + * @throws MapAreaRefusal + */ + private function requireIndex(array $entries, string $id): int + { + return self::findIndex($entries, $id) ?? throw new MapAreaRefusal(sprintf('%s has no area %s.', $this->map->mapId, $id)); + } + + /** + * Runs an edit of the map's areas, events, landmarks and encounters, and returns the + * command that redoes and undoes exactly it. Null when it changed + * nothing. An edit that throws has changed nothing. + * + * @param callable(): void $edit + */ + private function record(string $label, callable $edit): ?Command + { + $map = $this->map; + $keys = [self::KEY, 'events', MapLandmarks::KEY, MapEncounters::KEY]; + $capture = static fn(): array => array_combine($keys, array_map( + static fn(string $key): array => [$map->hasMapDataField([$key]), $map->getMapDataField([$key])], $keys)); + $restore = static function (array $state) use ($map): void { + foreach ($state as $key => [$held, $value]) { + if ($map->getMapDataField([$key]) !== $value || $map->hasMapDataField([$key]) !== $held) { + $map->setMapDataField([$key], $held ? $value : null); + } + } + }; + $before = $capture(); + + try { + $edit(); + } catch (\Throwable $failure) { + $restore($before); + + throw $failure; + } + + $after = $capture(); + if ($after === $before) { + return null; + } + + return new GenericCommand($label, static fn() => $restore($after), static fn() => $restore($before)); + } + + /** + * The shape an author writes for selected cells: a rectangle when they + * fill one exactly, otherwise the exact cells in reading order. Both + * editors write selections through it. + * + * @param list $cells + * @return array + */ + public static function createShapeFor(array $cells): array + { + $unique = []; + foreach ($cells as [$x, $y]) { + $unique[$x . ',' . $y] = [(int) $x, (int) $y]; + } + $cells = array_values($unique); + usort($cells, static fn(array $a, array $b): int => [$a[1], $a[0]] <=> [$b[1], $b[0]]); + if ($cells === []) { + return ['cells' => []]; + } + $xs = array_column($cells, 0); + $ys = array_column($cells, 1); + $width = max($xs) - min($xs) + 1; + $height = max($ys) - min($ys) + 1; + + return $width * $height === count($cells) + ? ['x' => min($xs), 'y' => min($ys), 'width' => $width, 'height' => $height] + : ['cells' => $cells]; + } + + /** The first `area-N` id the map does not hold, for an area made before it is named. */ + public static function findUnusedId(ProjectMap $map): string + { + $ids = array_column(self::readEntries($map), 'id'); + for ($number = 1; ; $number++) { + if (! in_array('area-' . $number, $ids, true)) { + return 'area-' . $number; + } + } + } + + /** + * The cells an area shape covers, for an interface drawing a selection. + * + * @param array $area + * @return list + */ + public static function readCells(array $area): array + { + return array_map(static fn(array $cell): array => [$cell[0], $cell[1]], CellArea::fromArray($area, 'Map area')->cells); + } +} diff --git a/src/Field/MapAreaRefusal.php b/src/Field/MapAreaRefusal.php new file mode 100644 index 00000000..afaf8e1f --- /dev/null +++ b/src/Field/MapAreaRefusal.php @@ -0,0 +1,17 @@ + + * @throws InvalidArgumentException When the block is not an array. + */ + public static function readBlock(ProjectMap $map): array + { + $block = $map->getMapDataField([self::KEY]) ?? []; + return is_array($block) ? $block : throw new InvalidArgumentException('atlas must be an array; it cannot be edited as rows.'); + } + + /** + * Sets the scope. Local is what a map is when it says nothing, so choosing it removes the key; a global map has + * no parent, so becoming global removes the parent. + * + * @param array $block + * @return array + */ + public static function setScope(array $block, string $scope): array + { + if (!in_array($scope, MapAtlasDefinition::SCOPES, true)) { + throw new InvalidArgumentException('Choose a listed atlas scope.'); + } + if ($scope === self::LOCAL) { unset($block['scope']); return $block; } + unset($block['parent']); + return [...$block, 'scope' => $scope]; + } + + /** + * How the Terminal draws the map, as the Engine reads the block: the authored choice, or its default for the + * map's scope. + * + * @param array $block + * @throws InvalidArgumentException When the block is invalid. + */ + public static function readTerminalView(array $block): string + { + return MapAtlasDefinition::readMap($block === [] ? [] : [self::KEY => $block])->terminalView; + } + + /** + * Sets how the Terminal draws the map: places (squares for locations, chevrons for routes) or a floorplan of + * rooms and corridors. Choosing what the Engine would read anyway for the map's scope says nothing. + * + * @param array $block + * @return array + */ + public static function setTerminalView(array $block, string $view): array + { + if (!in_array($view, MapAtlasDefinition::TERMINAL_VIEWS, true)) { + throw new InvalidArgumentException('Choose a listed Terminal view.'); + } + $without = $block; + unset($without['terminalView']); + return $view === self::readTerminalView($without) ? $without : [...$block, 'terminalView' => $view]; + } + + /** + * The map's terrain classifications as authored: per source layer, plain glyphs mapped to Engine terrain roles + * and an optional role for the layer's other non-blank glyphs. + * + * @param array $block + * @return array> + */ + public static function readTerrain(array $block): array + { + $terrain = $block['terrain'] ?? []; + return is_array($terrain) ? $terrain : []; + } + + /** + * Classifies one plain glyph of a source layer, or leaves it unclassified when the role is null. A layer + * classifying nothing says nothing, and a map with no classifications has no terrain key. + * + * @param array $block + * @return array + */ + public static function setTerrainSymbol(array $block, string $layer, string $glyph, ?string $role): array + { + $terrain = self::readTerrain($block); + $symbols = is_array($terrain[$layer]['symbols'] ?? null) ? $terrain[$layer]['symbols'] : []; + if ($role === null) { unset($symbols[$glyph]); } else { $symbols[$glyph] = $role; } + $binding = is_array($terrain[$layer] ?? null) ? $terrain[$layer] : []; + if ($symbols === []) { unset($binding['symbols']); } else { $binding['symbols'] = $symbols; } + return self::withTerrainBinding($block, $terrain, $layer, $binding); + } + + /** + * Sets the role of a source layer's other non-blank glyphs, or removes it when the role is null. + * + * @param array $block + * @return array + */ + public static function setTerrainDefault(array $block, string $layer, ?string $role): array + { + $terrain = self::readTerrain($block); + $binding = is_array($terrain[$layer] ?? null) ? $terrain[$layer] : []; + if ($role === null) { unset($binding['default']); } else { $binding['default'] = $role; } + return self::withTerrainBinding($block, $terrain, $layer, $binding); + } + + /** @return array */ + private static function withTerrainBinding(array $block, array $terrain, string $layer, array $binding): array + { + if ($binding === []) { unset($terrain[$layer]); } else { $terrain[$layer] = $binding; } + if ($terrain === []) { unset($block['terrain']); } else { $block['terrain'] = $terrain; } + return $block; + } + + /** + * Sets or clears the parent entry: a stable landmark on the parent map. + * + * @param array $block + * @return array + */ + public static function setParent(array $block, ?string $mapId, ?string $landmarkId): array + { + if ($mapId === null || $landmarkId === null) { unset($block['parent']); return $block; } + return [...$block, 'parent' => ['map' => $mapId, 'landmark' => $landmarkId]]; + } + + /** A parent choice's value: the map id and the landmark id, which neither may contain. */ + public static function encodeParent(string $mapId, string $landmarkId): string + { + return $mapId . '#' . $landmarkId; + } + + /** @return array{0: string, 1: string}|null */ + public static function decodeParent(string $value): ?array + { + if ($value === '') { return null; } + $parts = explode('#', $value, 2); + return count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '' ? $parts + : throw new InvalidArgumentException('Choose a listed parent entry.'); + } + + /** + * Checks a block through the Engine as the map would stand with it, and returns where the chain reaches the + * global map: its id and the entry on it, null entry for a global map itself, or null when no global map is + * above. + * + * @param array $block + * @param Closure(string): ?ProjectMap $findMap Finds a project map by id, unsaved edits included. + * @return array{map: string, landmark: ?string}|null + * @throws InvalidArgumentException With the Engine's reason when the atlas is invalid. + */ + public static function resolveGlobal(ProjectMap $map, array $block, Closure $findMap, ?MapCategoryCatalog $categories = null): ?array + { + $resolve = static function (string $mapId) use ($map, $block, $findMap): array { + $other = $mapId === $map->mapId ? $map : ($findMap($mapId) ?? throw new InvalidArgumentException("Atlas parent map '{$mapId}' does not exist.")); + $source = MapLandmarks::readResolvedSource($other); + if ($other === $map) { + $source['data'][self::KEY] = $block; + if ($block === []) { unset($source['data'][self::KEY]); } + } + return $source; + }; + MapAtlasDefinition::readMap($block === [] ? [] : [self::KEY => $block]); + return MapAtlasDefinition::resolveGlobal($map->mapId, $resolve, $categories); + } + + /** + * Where maps name a map as their atlas parent, or a landmark on it as their entry: what keeps that landmark from + * being removed, and what a move or delete of the map leaves behind. + * + * @param iterable $maps + * @return list + */ + public static function describeParentReferences(iterable $maps, string $mapId, ?string $landmarkId = null): array + { + return array_map(static function (ProjectMap $map): string { + $landmark = $map->getMapDataField([self::KEY, 'parent', 'landmark']); + return sprintf('the atlas parent of %s (entry %s)', $map->mapId, is_string($landmark) ? $landmark : '?'); + }, self::findChildMaps($maps, $mapId, $landmarkId)); + } + + /** + * The maps that name a map as their atlas parent, or a landmark on it as their entry. + * + * @param iterable $maps + * @return list + */ + public static function findChildMaps(iterable $maps, string $mapId, ?string $landmarkId = null): array + { + $found = []; + foreach ($maps as $map) { + $parent = $map->getMapDataField([self::KEY, 'parent']); + if (!is_array($parent) || ($parent['map'] ?? null) !== $mapId) { continue; } + if ($landmarkId !== null && ($parent['landmark'] ?? null) !== $landmarkId) { continue; } + $found[] = $map; + } + return $found; + } +} diff --git a/src/Field/MapBgmVariants.php b/src/Field/MapBgmVariants.php index 73c8a265..be9f50b7 100644 --- a/src/Field/MapBgmVariants.php +++ b/src/Field/MapBgmVariants.php @@ -155,62 +155,15 @@ public function conditionsAt(int $index): array return is_array($conditions) && array_is_list($conditions) ? $conditions : []; } - /** - * The keys the condition line carries per type, beyond type/name/negate. - */ - private const array CONDITION_KEYS = [ - 'switch' => ['value'], - 'event' => [], - 'variable' => ['op', 'value'], - 'item' => ['quantity'], - 'key_item' => ['quantity'], - 'quest' => ['status'], - ]; - /** * Why one variant's conditions cannot go through the condition editor, - * or null when the editor can carry them faithfully. - * - * Committing the editor writes what the condition line carries; a - * condition type the line cannot express, or an authored key outside - * the line's vocabulary, would be silently dropped on commit, so such - * a variant's conditions are refused by name instead. The track stays - * editable either way. + * or null when the editor can carry them faithfully + * ({@see ConditionCodec::findCarryIssue()}). The track stays editable + * either way. */ public function conditionsIssueAt(int $index): ?string { - $conditions = $this->rows[$index]['conditions'] ?? []; - - if (! is_array($conditions) || ! array_is_list($conditions)) { - return sprintf( - 'the conditions are %s, not a list', - is_array($conditions) ? 'a keyed array' : get_debug_type($conditions), - ); - } - - foreach ($conditions as $conditionIndex => $condition) { - if (! is_array($condition)) { - return sprintf('condition %d is %s, not an array', $conditionIndex + 1, get_debug_type($condition)); - } - - if (ConditionCodec::decode(ConditionCodec::encode($condition)) === null) { - return sprintf('condition %d holds a shape the condition editor cannot carry', $conditionIndex + 1); - } - - $type = strval($condition['type'] ?? ''); - $carried = ['type', 'name', 'negate', ...(self::CONDITION_KEYS[$type] ?? [])]; - $extra = array_diff(array_keys($condition), $carried); - - if ($extra !== []) { - return sprintf( - 'condition %d carries "%s", which the condition editor would drop', - $conditionIndex + 1, - implode('", "', array_map(strval(...), $extra)), - ); - } - } - - return null; + return ConditionCodec::findCarryIssue($this->rows[$index]['conditions'] ?? []); } /** diff --git a/src/Field/MapCategoryFields.php b/src/Field/MapCategoryFields.php new file mode 100644 index 00000000..6d170cbe --- /dev/null +++ b/src/Field/MapCategoryFields.php @@ -0,0 +1,111 @@ + MapCategoryCatalog::SCHEMA]), + fieldsFor: self::getFields(...), + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** + * A new category, which saves at once as the Engine admits it: its id is its name's slug, its name takes the + * Engine's translation key, and it starts as a kind of the Engine's first generic category for the author to + * change, as a new text role starts on the project's first font. + */ + public static function makeBlank(string $name, string $projectRoot): array + { + $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + $id = preg_match('/^[a-z]/', $slug) === 1 ? $slug : 'place-' . $slug; + if (preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1 || MapLandmarkCategory::tryFrom($id) !== null) { + throw new RecordRefusal(sprintf('"%s" cannot name a place category: its id would be %s, which is not a new stable id.', $name, $id)); + } + + return ['id' => $id, 'name' => ['message' => MapCategoryCatalog::getNameMessageKey($id), 'default' => trim($name)], + 'base' => MapLandmarkCategory::cases()[0]->value]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + return [ + // Landmarks name the category by its id: it changes only while none does. + new RecordField('id', 'Stable Id'), + new RecordField('name.default', 'Name'), + new RecordField('name.message', 'Name Translation Key'), + new RecordField('base', 'Specializes', options: array_column(MapLandmarkCategory::cases(), 'value')), + ]; + } + + /** + * Refuses an id the Engine would not accept (a generic category's, or not a stable id) as it is typed, and keeps + * a name translation key that followed the old id following the new one; a key an author chose stays. + */ + public static function checkEdit(array $entry, string $field): array + { + if ($field !== 'id') { return $entry; } + $id = $entry['id'] ?? null; + if (!is_string($id) || preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1 || MapLandmarkCategory::tryFrom($id) !== null) { + throw new RecordRefusal('A place category id is a new stable lowercase id of 1 to 64 characters, not inn, shop, home or organization.'); + } + // A key the Engine derived from an id, read from its own rule, follows the id; any other key stays. + $probe = 'probe'; + $derived = '/\\A' . str_replace($probe, '[a-z][a-z0-9._-]*', preg_quote(MapCategoryCatalog::getNameMessageKey($probe), '/')) . '\\z/'; + $message = $entry['name']['message'] ?? null; + if (is_string($message) && preg_match($derived, $message) === 1) { + $entry['name']['message'] = MapCategoryCatalog::getNameMessageKey($id); + } + + return $entry; + } + + /** Why the Engine would not read the catalogue as a save would write it, or null when it would. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + MapCategoryCatalog::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/src/Field/MapEncounters.php b/src/Field/MapEncounters.php index d95e4fb3..5ed32082 100644 --- a/src/Field/MapEncounters.php +++ b/src/Field/MapEncounters.php @@ -5,6 +5,8 @@ namespace Ichiloto\Editor\Field; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Engine\Field\EncounterArenaRule; +use Ichiloto\Engine\Field\EncounterEntry; use InvalidArgumentException; /** @@ -14,12 +16,34 @@ * * ```php * 'encounters' => [ - * 'troops' => ['Rat + Bat' => 5, 'Bat x 2' => 4], + * 'troops' => [ + * 'Rat + Bat' => 5, + * 'Loch Ness' => ['weight' => 1, 'battleArena' => 'arena.secret-lake'], + * 'Roost Bats' => ['weight' => 2, 'areas' => ['east-roost']], + * ], * 'rate' => 22, * 'tiles' => 'encounter', + * 'battleArena' => 'arena.cryptic-ruins', + * 'battleArenas' => [ + * ['area' => 'east-roost', 'battleArena' => 'arena.roost'], + * ['terrain' => 'woodland', 'battleArena' => 'arena.deep-woods'], + * ], * ], * ``` * + * A troop's entry is its weight, or a map holding its weight and the arena a + * graphical battle against it takes place in, which outranks the map's own + * `battleArena`; with neither, the battle presentation's default arena + * applies. Arenas are graphical presentation only: they change no weight, + * no fight and nothing the terminal shows. An entry's `areas` names the + * map's named areas the troop is met in; without them it is met anywhere the + * map counts steps. Its `terrains` names encounter terrains of the map's + * terrain kinds, such as `woodland`; without them it is met on any. + * `battleArenas` are the map's arena rules, the Engine's own + * ({@see EncounterArenaRule}): the arena for a named area, an encounter + * terrain, or a terrain within an area, edited as ordered rows. The arena a + * battle is drawn on is then refined by its terrain variants. + * * The engine reads it loosely -- a missing `rate` means fifteen steps, a * missing `tiles` means danger tiles only, a troop with a non-positive or * unreadable weight is dropped, and a block that names no readable troop @@ -34,9 +58,10 @@ * same troop: PHP would keep the last and discard the other without a word, * so the editor refuses the edit instead. * - * Anything else inside the block -- a key a later engine will read -- is kept - * exactly as it was found. A shape this model cannot hold is refused by name - * rather than rewritten. + * Anything else inside the block or an entry -- a key a later engine will + * read -- is kept exactly as it was found, and an entry authored as a map + * stays one. A shape this model cannot hold is refused by name rather than + * rewritten. * * @package Ichiloto\Editor\Field */ @@ -54,18 +79,34 @@ final class MapEncounters /** The tile modes the engine understands. */ public const array TILE_MODES = ['encounter', 'any']; + /** The key naming a graphical battle arena, on the block and on an entry. */ + public const string ARENA_KEY = 'battleArena'; + + /** The entry key naming the map areas a troop is met in, the Engine's own. */ + public const string AREAS_KEY = EncounterEntry::AREAS_KEY; + /** The entry key naming the encounter terrains a troop is met on, the Engine's own. */ + public const string TERRAINS_KEY = EncounterEntry::TERRAINS_KEY; + + /** The key holding the map's arena rules, the Engine's own. */ + public const string ARENA_RULES_KEY = EncounterArenaRule::KEY; + /** The keys this model owns; everything else in the block is the author's. */ - private const array OWNED_KEYS = ['troops', 'rate', 'tiles']; + private const array OWNED_KEYS = ['troops', 'rate', 'tiles', self::ARENA_KEY, self::ARENA_RULES_KEY]; /** * @param array|null $block The authored block, or null when the map declares none. - * @param array $rows The troop rows, in authored order. + * @param array, terrains: list, entry: ?array}> $rows + * The troop rows, in authored order: each one's weight and arena, and + * the entry as authored when it was written as a map. * @param string|null $unsupported Why this block cannot be edited, or null. + * @param list}> $rules + * The arena rules, in authored order, each with its entry as authored. */ private function __construct( private readonly ?array $block, private readonly array $rows, private readonly ?string $unsupported, + private readonly array $rules = [], ) { } @@ -96,23 +137,90 @@ public static function of(mixed $block): self return new self($block, [], sprintf('the encounters troops are %s, not troop weights', get_debug_type($troops))); } + // An arena is a key or absent: a null written for one is no arena + // the engine can draw, and the editor will not quietly drop it. + $mapArena = $block[self::ARENA_KEY] ?? null; + + if (array_key_exists(self::ARENA_KEY, $block) && ! is_string($mapArena)) { + return new self($block, [], sprintf('the map\'s battleArena is %s, not an arena key', get_debug_type($mapArena))); + } + $rows = []; - foreach ($troops as $name => $weight) { + foreach ($troops as $name => $entry) { if (! is_string($name)) { // The engine skips a troop it cannot name, and the editor // cannot show a numeric key as a troop reference. return new self($block, [], sprintf('a troop is keyed by %s (%s) rather than by name', get_debug_type($name), var_export($name, true))); } + $structured = is_array($entry); + $weight = $structured ? ($entry['weight'] ?? null) : $entry; + $arena = $structured ? ($entry[self::ARENA_KEY] ?? null) : null; + if (! is_scalar($weight) && $weight !== null) { return new self($block, [], sprintf('the troop "%s" has a weight that is %s', $name, get_debug_type($weight))); } - $rows[] = ['name' => $name, 'weight' => $weight]; + if ($structured && array_key_exists(self::ARENA_KEY, $entry) && ! is_string($arena)) { + return new self($block, [], sprintf('the troop "%s" has a battleArena that is %s, not an arena key', $name, get_debug_type($arena))); + } + + $areas = $structured ? ($entry[self::AREAS_KEY] ?? []) : []; + + if (! is_array($areas) || ! array_is_list($areas) || array_any($areas, static fn(mixed $id): bool => ! is_string($id))) { + return new self($block, [], sprintf('the troop "%s" has areas that are not a list of area ids', $name)); + } + + $terrains = $structured ? ($entry[self::TERRAINS_KEY] ?? []) : []; + + if (! is_array($terrains) || ! array_is_list($terrains) || array_any($terrains, static fn(mixed $id): bool => ! is_string($id))) { + return new self($block, [], sprintf('the troop "%s" has terrains that are not a list of encounter terrain ids', $name)); + } + + $rows[] = ['name' => $name, 'weight' => $weight, 'arena' => $arena, 'areas' => $areas, 'terrains' => $terrains, + 'entry' => $structured ? $entry : null]; + } + + $rules = self::readArenaRules($block[self::ARENA_RULES_KEY] ?? null); + + return is_string($rules) ? new self($block, [], $rules) : new self($block, $rows, null, $rules); + } + + /** + * Reads the arena rules as rows, or says why they cannot be held: the + * Engine judges what a rule means; this keeps exactly what was written. + * + * @return list}>|string + */ + private static function readArenaRules(mixed $rules): array|string + { + if ($rules === null) { + return []; + } + + if (! is_array($rules) || ! array_is_list($rules)) { + return 'the battleArenas are not a list of arena rules'; + } + + $read = []; + + foreach ($rules as $index => $rule) { + if (! is_array($rule) || ($rule !== [] && array_is_list($rule))) { + return sprintf('battle arena rule %d is %s, not a rule', $index + 1, get_debug_type($rule)); + } + + foreach (['area', 'terrain', self::ARENA_KEY] as $key) { + if (array_key_exists($key, $rule) && ! is_string($rule[$key])) { + return sprintf('battle arena rule %d has a %s that is %s', $index + 1, $key, get_debug_type($rule[$key])); + } + } + + $read[] = ['area' => $rule['area'] ?? null, 'terrain' => $rule['terrain'] ?? null, 'arena' => $rule[self::ARENA_KEY] ?? null, + 'entry' => $rule]; } - return new self($block, $rows, null); + return $read; } /** @@ -142,13 +250,123 @@ public function unsupportedReason(): ?string /** * The troop rows, in authored order. * - * @return array + * @return array, terrains: list, entry: ?array}> */ public function rows(): array { return $this->rows; } + /** + * The arena rules, in authored order. + * + * @return list}> + */ + public function arenaRules(): array + { + return $this->rules; + } + + /** + * The arena rules naming an area, by position. + * + * @return list + */ + public function arenaRulesInArea(string $areaId): array + { + return array_keys(array_filter($this->rules, static fn(array $rule): bool => $rule['area'] === $areaId)); + } + + /** + * The block with one more arena rule, after the given position. + * + * @return array|null + */ + public function withArenaRuleAdded(?string $area, ?string $terrain, string $arena, ?int $after = null): ?array + { + $rules = $this->rules; + $position = $after === null ? count($rules) : min(count($rules), max(0, $after + 1)); + array_splice($rules, $position, 0, [['area' => $area, 'terrain' => $terrain, 'arena' => $arena, 'entry' => []]]); + + return $this->rebuilt($this->rows, [self::ARENA_RULES_KEY => self::writeArenaRules($rules)]); + } + + /** + * The block with one arena rule's area, terrain or arena changed; null + * clears an area (the whole map) or a terrain (any terrain). + * + * @param string $field `area`, `terrain` or `arena`. + * @return array|null + */ + public function withArenaRuleAt(int $index, string $field, ?string $value): ?array + { + $rules = $this->rules; + + if (! array_key_exists($index, $rules) || ! in_array($field, ['area', 'terrain', 'arena'], true)) { + return $this->block; + } + + $rules[$index][$field] = $value; + + return $this->rebuilt($this->rows, [self::ARENA_RULES_KEY => self::writeArenaRules($rules)]); + } + + /** + * The block without one arena rule; the last takes the key with it. + * + * @return array|null + */ + public function withArenaRuleRemovedAt(int $index): ?array + { + $rules = $this->rules; + + if (! array_key_exists($index, $rules)) { + return $this->block; + } + + array_splice($rules, $index, 1); + + return $this->rebuilt($this->rows, [self::ARENA_RULES_KEY => self::writeArenaRules($rules)]); + } + + /** + * The rules as the block stores them, each entry keeping what it carried, or null for none. + * + * @param list}> $rules + * @return list>|null + */ + private static function writeArenaRules(array $rules): ?array + { + if ($rules === []) { + return null; + } + + return array_map(static function (array $rule): array { + $entry = $rule['entry']; + + foreach (['area' => $rule['area'], 'terrain' => $rule['terrain'], self::ARENA_KEY => $rule['arena']] as $key => $value) { + if ($value === null) { + unset($entry[$key]); + } else { + $entry[$key] = $value; + } + } + + return $entry; + }, $rules); + } + + /** + * The arena the map's encounters take place in, or null when the battle + * presentation's default applies. + */ + public function mapArena(): ?string + { + $arena = $this->block[self::ARENA_KEY] ?? null; + + return is_string($arena) ? $arena : null; + } + /** * The authored rate, or null when the block leaves it to the engine. */ @@ -273,6 +491,103 @@ public function withTroopAt(int $index, string $name): ?array return $this->rebuilt($rows); } + /** + * The block with a different arena for the map's encounters; null leaves + * it to the battle presentation's default. + * + * @return array|null + */ + public function withMapArena(?string $arena): ?array + { + return $this->rebuilt($this->rows, [self::ARENA_KEY => $arena]); + } + + /** + * The block with one row's own arena changed; null leaves it to the map's. + * + * @return array|null + */ + public function withArenaAt(int $index, ?string $arena): ?array + { + $rows = $this->rows; + + if (! array_key_exists($index, $rows)) { + return $this->block; + } + + $rows[$index]['arena'] = $arena; + + return $this->rebuilt($rows); + } + + /** + * The block with one row limited to the map's named areas; an empty list + * lets the troop be met anywhere the map counts steps. + * + * @param list $areas + * @return array|null + */ + public function withAreasAt(int $index, array $areas): ?array + { + $rows = $this->rows; + + if (! array_key_exists($index, $rows)) { + return $this->block; + } + + $rows[$index]['areas'] = array_values(array_unique($areas)); + + return $this->rebuilt($rows); + } + + /** + * The block with one row limited to encounter terrains of the map's + * terrain kinds; an empty list lets the troop be met on any. + * + * @param list $terrains + * @return array|null + */ + public function withTerrainsAt(int $index, array $terrains): ?array + { + $rows = $this->rows; + + if (! array_key_exists($index, $rows)) { + return $this->block; + } + + $rows[$index]['terrains'] = array_values(array_unique($terrains)); + + return $this->rebuilt($rows); + } + + /** + * The troops limited to a named area. + * + * @return list + */ + public function troopsInArea(string $areaId): array + { + return array_values(array_map(static fn(array $row): string => $row['name'], + array_filter($this->rows, static fn(array $row): bool => in_array($areaId, $row['areas'], true)))); + } + + /** + * The block with an area renamed in every row and arena rule that names it. + * + * @return array|null + */ + public function withAreaRenamed(string $from, string $to): ?array + { + $rows = array_map(static function (array $row) use ($from, $to): array { + $row['areas'] = array_map(static fn(string $id): string => $id === $from ? $to : $id, $row['areas']); + + return $row; + }, $this->rows); + $rules = array_map(static fn(array $rule): array => $rule['area'] === $from ? ['area' => $to] + $rule : $rule, $this->rules); + + return $this->rebuilt($rows, $rules === $this->rules ? [] : [self::ARENA_RULES_KEY => self::writeArenaRules($rules)]); + } + /** * The block with one row's weight changed. * @@ -302,7 +617,7 @@ public function withTroopAdded(string $name, ?int $after = null, int $weight = 1 $this->assertFree($name, null); $rows = $this->rows; $position = $after === null ? count($rows) : min(count($rows), max(0, $after + 1)); - array_splice($rows, $position, 0, [['name' => $name, 'weight' => max(1, $weight)]]); + array_splice($rows, $position, 0, [['name' => $name, 'weight' => max(1, $weight), 'arena' => null, 'areas' => [], 'terrains' => [], 'entry' => null]]); return $this->rebuilt($rows); } @@ -328,10 +643,12 @@ public function withTroopRemovedAt(int $index): ?array /** * Rebuilds the authored block around new rows, keeping every key this - * model does not own, and the order they were authored in. + * model does not own, and the order they were authored in. An entry + * authored as a map stays one, its other keys kept; a weight alone is + * written as a weight, and becomes a map only when it gains an arena. * - * @param array $rows - * @param array $changes Owned values to set. + * @param array, terrains: list, entry: ?array}> $rows + * @param array $changes Owned values to set; null removes one. * @return array|null The block to write, or null to remove it. */ private function rebuilt(array $rows, array $changes = []): ?array @@ -350,18 +667,61 @@ private function rebuilt(array $rows, array $changes = []): ?array $troops = []; foreach ($rows as $row) { - $troops[$row['name']] = $row['weight'] ?? 1; + $troops[$row['name']] = self::entryFor($row); } $block['troops'] = $troops; foreach ($changes as $key => $value) { + if ($value === null) { + unset($block[$key]); + + continue; + } + $block[$key] = $value; } return $block; } + /** + * Writes one row as its entry: as authored, with its weight, arena, areas and terrains. + * + * @param array{name: string, weight: mixed, arena: ?string, areas: list, terrains: list, entry: ?array} $row + */ + private static function entryFor(array $row): mixed + { + $weight = $row['weight'] ?? 1; + + if ($row['entry'] === null && $row['arena'] === null && $row['areas'] === [] && $row['terrains'] === []) { + return $weight; + } + + $entry = $row['entry'] ?? []; + $entry['weight'] = $weight; + + if ($row['arena'] === null) { + unset($entry[self::ARENA_KEY]); + } else { + $entry[self::ARENA_KEY] = $row['arena']; + } + + if ($row['areas'] === []) { + unset($entry[self::AREAS_KEY]); + } else { + $entry[self::AREAS_KEY] = $row['areas']; + } + + if ($row['terrains'] === []) { + unset($entry[self::TERRAINS_KEY]); + } else { + $entry[self::TERRAINS_KEY] = $row['terrains']; + } + + return $entry; + } + /** * Refuses a troop another row already names. * diff --git a/src/Field/MapLandmarks.php b/src/Field/MapLandmarks.php new file mode 100644 index 00000000..06a20902 --- /dev/null +++ b/src/Field/MapLandmarks.php @@ -0,0 +1,470 @@ +> $entries */ + private function __construct(public ProjectMap $map, public array $entries) {} + + /** @throws InvalidArgumentException When the authored block is not a list of declarations. */ + public static function readMap(ProjectMap $map): self + { + $block = $map->getMapDataField([self::KEY]) ?? []; + if (!is_array($block) || !array_is_list($block) || array_any($block, static fn($entry): bool => !is_array($entry))) { + throw new InvalidArgumentException('landmarks must be a list of declarations; it cannot be edited as rows.'); + } + + return new self($map, $block); + } + + /** + * The map as the Engine's map readers take it, unsaved edits included: its data with each event resolved to + * its marker and cells by the Engine's own resolver (painted cells, or a bound subject's cells; an event the + * resolver refuses has nothing to mark, and validation says why), and its composed logical cell grid. + * Landmark anchors and atlas parents are both read from this one view. + * + * @return array{data: array, tiles: array>} + * @throws InvalidArgumentException When world objects cannot be read. + */ + public static function readResolvedSource(ProjectMap $map): array + { + $events = MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), $map->mapId)->events; + $triggers = $map->getEditableData()['triggers'] ?? []; + + return ['data' => [...$map->getEditableData(), 'id' => $map->mapId, 'events' => $events, + 'worldObjects' => WorldObjectAuthoring::readEntries($map), 'triggers' => is_array($triggers) ? $triggers : []], + 'tiles' => $map->getLayerSet()->getComposedGrid()]; + } + + /** + * What the map's landmarks may anchor to, read by the Engine's own adapter from the map as it stands: its + * events, world objects, named areas, and legacy transfers with a stable `id`. + * + * @throws InvalidArgumentException When world objects cannot be read. + */ + public static function readAnchors(ProjectMap $map): MapLandmarkAnchors + { + $source = self::readResolvedSource($map); + + // Named areas are read against the physical grid, as the runtime reads them. + return MapLandmarkAnchors::readMap($source['data'], $source['tiles']); + } + + /** @return list The number of real cells on each row, so ragged maps keep their shape. */ + public static function readRowWidths(ProjectMap $map): array + { + return array_values(array_map(count(...), $map->getLayerSet()->getComposedGrid())); + } + + /** + * Checks declarations against the map as it stands, through the Engine's validator. + * + * @param list> $entries + * @param MapCategoryCatalog|null $categories The project's place categories; null accepts the Engine's generic ones only. + * @throws InvalidArgumentException With the Engine's reason when the map would not load them. + */ + public static function validateEntries(ProjectMap $map, array $entries, ?MapCategoryCatalog $categories = null): MapLandmarkCatalog + { + return MapLandmarkCatalog::readDeclarations($entries, self::readAnchors($map), self::readRowWidths($map), $categories); + } + + /** Whether an event definition moves the player to another map: the only kind of event a route marks. */ + public static function isTransfer(array $definition): bool + { + return is_string($definition['class'] ?? null) && is_a($definition['class'], TransferPlayerTrigger::class, true); + } + + public function findIndex(string $id): int + { + foreach ($this->entries as $index => $entry) { if (($entry['id'] ?? null) === $id) { return $index; } } + throw new InvalidArgumentException("This map has no landmark {$id}."); + } + + /** @param array $anchor */ + public function findAnchorIndex(array $anchor): ?int + { + foreach ($this->entries as $index => $entry) { + if (self::readAnchor($entry) === $anchor) { return $index; } + } + return null; + } + + /** + * An entry's anchor as `[kind => id]`, with an event marker as a string whatever PHP made of its key. + * + * @return array|null + */ + public static function readAnchor(array $entry): ?array + { + $anchor = $entry['anchor'] ?? null; + if (!is_array($anchor) || count($anchor) !== 1) { return null; } + $kind = array_key_first($anchor); + $id = $anchor[$kind]; + return in_array($kind, MapLandmarkCatalog::ANCHOR_KINDS, true) && (is_string($id) || is_int($id)) ? [$kind => (string) $id] : null; + } + + /** A selector's value for an anchor: its kind, a colon, then its id (a marker or an id may itself hold one). */ + public static function encodeAnchor(array $anchor): string + { + $kind = (string) array_key_first($anchor); + return $kind . ':' . $anchor[$kind]; + } + + /** @return array */ + public static function decodeAnchor(string $value): array + { + [$kind, $id] = array_pad(explode(':', $value, 2), 2, ''); + if (!in_array($kind, MapLandmarkCatalog::ANCHOR_KINDS, true) || $id === '') { + throw new InvalidArgumentException('Choose a listed event, world object, area or legacy transfer.'); + } + return [$kind => $id]; + } + + /** + * Every anchor on the map with what an author reads for it, whether it is a transfer, and the landmark already + * describing it, if any. + * + * @return list, value: string, label: string, transfer: bool, landmark: ?string}> + */ + public function describeAnchors(): array + { + $anchors = self::readAnchors($this->map); + $claimed = []; + foreach ($this->entries as $entry) { + $anchor = self::readAnchor($entry); + if ($anchor !== null && is_string($entry['id'] ?? null)) { $claimed[self::encodeAnchor($anchor)] = $entry['id']; } + } + $described = []; + foreach ([['event', $anchors->events], ['object', $anchors->objects], [MapAreaAuthoring::ANCHOR_KIND, $anchors->areas], ['trigger', $anchors->triggers]] as [$kind, $table]) { + foreach ($table as $id => $anchor) { + // A legacy transfer without an id is keyed by its position, which is no identity to keep. + if ($kind === 'trigger' && str_starts_with((string) $id, '@')) { continue; } + $reference = [$kind => (string) $id]; + $value = self::encodeAnchor($reference); + $described[] = ['anchor' => $reference, 'value' => $value, 'label' => $this->describeAnchor($reference), + 'transfer' => $anchor->isTransfer, 'landmark' => $claimed[$value] ?? null]; + } + } + return $described; + } + + /** What an author reads for an anchor: an event by marker and type (and where a transfer leads), or an id. */ + public function describeAnchor(array $anchor): string + { + $kind = (string) array_key_first($anchor); + $id = (string) $anchor[$kind]; + if ($kind === 'object') { return 'World object ' . $id; } + if ($kind === MapAreaAuthoring::ANCHOR_KIND) { return 'Area ' . $id; } + if ($kind === 'trigger') { return 'Legacy transfer ' . $id; } + $definition = $this->map->getEventDefinition($id); + $type = EventTypeCatalog::describeClass(is_string($definition['class'] ?? null) ? $definition['class'] : null); + $destination = $definition['data']['destinationMap'] ?? null; + return sprintf('Event %s · %s%s', $id, $type, is_string($destination) && $destination !== '' ? ' to ' . $destination : ''); + } + + /** + * The kinds a landmark on this anchor may be: a transfer is a route (passage, entrance or exit), anything else + * a building or a landmark. + * + * @return list + */ + public function findKindsFor(array $anchor): array + { + return array_values(array_filter(MapLandmarkKind::cases(), + fn(MapLandmarkKind $kind): bool => $kind->isRoute() === $this->isRouteAnchor($anchor))); + } + + public function isRouteAnchor(array $anchor): bool + { + $kind = (string) array_key_first($anchor); + if ($kind === 'trigger') { return true; } + if ($kind === 'object' || $kind === MapAreaAuthoring::ANCHOR_KIND) { return false; } + $definition = $this->map->getEventDefinition((string) $anchor[$kind]); + return is_array($definition) && self::isTransfer($definition); + } + + /** + * Describes a place: a declaration for an anchor no landmark describes yet. A route starts as the passage its + * automatic marker already is, so describing it changes nothing on the minimap until an author edits it; any + * other anchor starts as the kind chosen, or a landmark. Its id is the anchor's own stable id where that is a + * valid landmark id, otherwise the next free `landmark-N`, until the author chooses another ({@see setId()}). + * + * @return array{entries: list>, id: string} + */ + public function addLandmark(array $anchor, ?MapLandmarkKind $kind = null): array + { + if ($this->findAnchorIndex($anchor) !== null) { + throw new InvalidArgumentException(sprintf('%s already anchors a landmark.', $this->describeAnchor($anchor))); + } + $kinds = $this->findKindsFor($anchor); + $kind ??= $kinds[0] === MapLandmarkKind::PASSAGE ? MapLandmarkKind::PASSAGE : MapLandmarkKind::LANDMARK; + if (!in_array($kind, $kinds, true)) { + throw new InvalidArgumentException(sprintf('%s cannot be a %s.', $this->describeAnchor($anchor), $kind->value)); + } + $id = $this->findUnusedId($anchor); + return ['entries' => [...$this->entries, ['id' => $id, 'kind' => $kind->value, 'anchor' => $anchor]], 'id' => $id]; + } + + /** + * Points a landmark at another anchor. Moving between a route and anything else changes what the landmark can + * be, so its kind becomes what a new landmark there starts as; everything else it says is kept. + * + * @return list> + */ + public function setAnchor(int $index, array $anchor): array + { + $entries = $this->entries; + $current = self::readAnchor($entries[$index]); + if ($current === $anchor) { return $entries; } + $other = $this->findAnchorIndex($anchor); + if ($other !== null) { + throw new InvalidArgumentException(sprintf('%s already anchors landmark %s.', $this->describeAnchor($anchor), $entries[$other]['id'] ?? $other)); + } + $entries[$index]['anchor'] = $anchor; + $kind = MapLandmarkKind::tryFrom((string) ($entries[$index]['kind'] ?? '')); + if ($kind === null || !in_array($kind, $this->findKindsFor($anchor), true)) { + $entries[$index]['kind'] = ($this->isRouteAnchor($anchor) ? MapLandmarkKind::PASSAGE : MapLandmarkKind::LANDMARK)->value; + } + return $entries; + } + + /** + * Gives a landmark the stable id an author chose: one the Engine accepts, used by no other landmark on the map. + * Its translation key stays, since translations are filed under it. Saves remember discoveries by id, so a save + * that learned the landmark under its old id learns it again; other maps whose atlas entry it is follow the + * rename ({@see MapAtlas::findChildMaps()}). + * + * @return list> + * @throws InvalidArgumentException When the id is not one the Engine accepts, or another landmark here has it. + */ + public function setId(int $index, string $id): array + { + $id = trim($id); + if (preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { + throw new InvalidArgumentException(sprintf('"%s" is not a landmark id: a lowercase letter, then up to 63 lowercase letters, digits, dots, underscores or hyphens.', $id)); + } + foreach ($this->entries as $other => $entry) { + if ($other !== $index && ($entry['id'] ?? null) === $id) { + throw new InvalidArgumentException(sprintf('Another landmark on %s is already %s.', $this->map->mapId, $id)); + } + } + $entries = $this->entries; + $entries[$index]['id'] = $id; + return $entries; + } + + /** @return list> */ + public function setKind(int $index, MapLandmarkKind $kind): array + { + $entries = $this->entries; + $entries[$index]['kind'] = $kind->value; + return $entries; + } + + /** + * Says what a place offers (an inn, a shop, the project's own kinds), or removes the category when none is + * chosen. A category is independent of the kind: an entrance into a shop is a route with the shop category. + * + * @return list> + */ + public function setCategory(int $index, ?string $category): array + { + $entries = $this->entries; + if ($category === null || $category === '') { unset($entries[$index]['category']); } + else { $entries[$index]['category'] = $category; } + return $entries; + } + + /** + * Names a landmark, or removes its name when the text is blank. A first name takes the Engine's map-scoped + * translation key; a name already keyed keeps its key. + * + * @return list> + */ + public function setName(int $index, string $text): array + { + $entries = $this->entries; + if (trim($text) === '') { + unset($entries[$index]['name']); + return $entries; + } + $message = $entries[$index]['name']['message'] ?? MapLandmarkCatalog::getNameMessageKey($this->map->mapId, (string) $entries[$index]['id']); + $entries[$index]['name'] = ['message' => $message, 'default' => $text]; + return $entries; + } + + /** @return list> */ + public function setNameMessage(int $index, string $message): array + { + $entries = $this->entries; + if (!isset($entries[$index]['name'])) { throw new InvalidArgumentException('Name the landmark before choosing its translation key.'); } + if (trim($message) === '') { throw new InvalidArgumentException('A named landmark needs a translation key.'); } + $entries[$index]['name']['message'] = trim($message); + return $entries; + } + + /** + * Makes a landmark secret or ordinary. A secret is revealed only explicitly, so it says nothing about approach; + * an ordinary one is learned on approach at the Engine's distance until an author sets another. + * + * @return list> + */ + public function setSecret(int $index, bool $secret): array + { + $entries = $this->entries; + if ($secret) { + $entries[$index]['secret'] = true; + unset($entries[$index]['approachDistance']); + } else { + unset($entries[$index]['secret']); + } + return $entries; + } + + /** + * Sets how near the party must come to learn a landmark: a distance in cells, or null when approach never + * reveals it. + * + * @return list> + */ + public function setApproachDistance(int $index, ?int $distance): array + { + $entries = $this->entries; + if (($entries[$index]['secret'] ?? false) === true) { + throw new InvalidArgumentException('A secret landmark is revealed only explicitly; approach never reveals it.'); + } + $entries[$index]['approachDistance'] = $distance; + return $entries; + } + + /** + * Returns a landmark to the Engine's approach distance by saying nothing about it. + * + * @return list> + */ + public function resetApproachDistance(int $index): array + { + $entries = $this->entries; + unset($entries[$index]['approachDistance']); + return $entries; + } + + /** + * Sets the world conditions that reveal a landmark through play. An empty list removes them: the Engine reads a + * declared empty list as an error, never as "always". + * + * @param list> $conditions + * @return list> + */ + public function setRevealConditions(int $index, array $conditions): array + { + $entries = $this->entries; + if ($conditions === []) { unset($entries[$index]['revealConditions']); } + else { $entries[$index]['revealConditions'] = array_values($conditions); } + return $entries; + } + + /** @return list> */ + public function removeLandmark(int $index): array + { + $entries = $this->entries; + array_splice($entries, $index, 1); + return $entries; + } + + /** + * The landmarks that anchor to a source, which keep it from being deleted or from changing between a route and + * anything else. A landmark block that cannot be read is reported, since its references cannot be proved. + * + * @param array $anchor + * @return list + */ + public static function describeAnchorReferences(ProjectMap $map, array $anchor): array + { + try { + $landmarks = self::readMap($map); + } catch (InvalidArgumentException $error) { + return [sprintf('%s landmarks: %s', $map->mapId, $error->getMessage())]; + } + $index = $landmarks->findAnchorIndex($anchor); + return $index === null ? [] : [sprintf('%s landmark %s (%s)', $map->mapId, $landmarks->entries[$index]['id'] ?? $index, + $landmarks->entries[$index]['kind'] ?? 'no kind')]; + } + + /** + * Where landmarks name a place category: what keeps the category from being deleted. + * + * @param iterable $maps + * @return list + */ + public static function describeCategoryReferences(iterable $maps, string $category): array + { + $found = []; + foreach ($maps as $map) { + try { + $entries = self::readMap($map)->entries; + } catch (InvalidArgumentException $error) { + $found[] = sprintf('%s landmarks: %s', $map->mapId, $error->getMessage()); + continue; + } + foreach ($entries as $entry) { + if (($entry['category'] ?? null) === $category) { + $found[] = sprintf('landmark %s on %s', $entry['id'] ?? '?', $map->mapId); + } + } + } + return $found; + } + + /** + * The landmark an anchor carries, when one does and its block can be read. + * + * @param array $anchor + * @return array|null + */ + public static function findAnchoredEntry(ProjectMap $map, array $anchor): ?array + { + try { + $landmarks = self::readMap($map); + } catch (InvalidArgumentException) { + return null; + } + $index = $landmarks->findAnchorIndex($anchor); + return $index === null ? null : $landmarks->entries[$index]; + } + + private function findUnusedId(array $anchor): string + { + $ids = array_column($this->entries, 'id'); + $own = (string) $anchor[array_key_first($anchor)]; + if (array_key_first($anchor) !== 'event' && preg_match(MapLandmarkCatalog::ID_PATTERN, $own) === 1 && !in_array($own, $ids, true)) { + return $own; + } + for ($number = 1; ; $number++) { + if (!in_array('landmark-' . $number, $ids, true)) { return 'landmark-' . $number; } + } + } +} diff --git a/src/Field/MapRoleFields.php b/src/Field/MapRoleFields.php new file mode 100644 index 00000000..eae047a7 --- /dev/null +++ b/src/Field/MapRoleFields.php @@ -0,0 +1,131 @@ + MapRoleCatalog::SCHEMA]), + fieldsFor: self::getFields(...), + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** A new role, which leads anywhere until the author chooses where its maps may lead. */ + public static function makeBlank(string $name, string $projectRoot): array + { + $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + $id = preg_match('/^[a-z]/', $slug) === 1 ? $slug : 'role-' . $slug; + if (preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { + throw new RecordRefusal(sprintf('"%s" cannot name a map role: its id would be %s, which is not a stable id.', $name, $id)); + } + + return ['id' => $id, 'name' => ['message' => MapRoleCatalog::getNameMessageKey($id), 'default' => trim($name)]]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + return [ + // Maps name the role by its id: it changes only while none does. + new RecordField('id', 'Stable Id'), + new RecordField('name.default', 'Name'), + new RecordField('name.message', 'Name Translation Key'), + new RecordField('leadsTo', 'Leads To', codec: RecordFieldCodec::CSV_LIST, reference: self::CATEGORY, + removeWhenEmpty: true, displayDefault: '(any map)'), + ]; + } + + /** + * Refuses an id the Engine would not accept as it is typed, and keeps a name translation key that followed the + * old id following the new one; a key an author chose stays. + */ + public static function checkEdit(array $entry, string $field): array + { + if ($field !== 'id') { return $entry; } + $id = $entry['id'] ?? null; + if (!is_string($id) || preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { + throw new RecordRefusal('A map role id is a stable lowercase id of 1 to 64 characters.'); + } + $probe = 'probe'; + $derived = '/\\A' . str_replace($probe, '[a-z][a-z0-9._-]*', preg_quote(MapRoleCatalog::getNameMessageKey($probe), '/')) . '\\z/'; + $message = $entry['name']['message'] ?? null; + if (is_string($message) && preg_match($derived, $message) === 1) { + $entry['name']['message'] = MapRoleCatalog::getNameMessageKey($id); + } + + return $entry; + } + + /** Why the Engine would not read the catalogue as a save would write it, or null when it would. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + MapRoleCatalog::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } + + /** + * What names a role, in words: the maps that are of it. Another role leading to it is refused by the + * Engine's catalogue check when it saves. + * + * @param iterable $maps + * @return list + */ + public static function describeRoleReferences(iterable $maps, string $role): array + { + $found = []; + foreach ($maps as $map) { + try { + if (MapRoleCatalog::readMapRole($map->getEditableData()) === $role) { + $found[] = sprintf('map %s', $map->mapId); + } + } catch (InvalidArgumentException) { + // A malformed role names no role; validation reports it on its map. + } + } + + return $found; + } +} diff --git a/src/Field/MapWalkMeasurement.php b/src/Field/MapWalkMeasurement.php new file mode 100644 index 00000000..4491ca87 --- /dev/null +++ b/src/Field/MapWalkMeasurement.php @@ -0,0 +1,200 @@ + $points The start, any waypoints, then the end, as [x, y]. + * @return array{ + * points: list, reachable: bool, problem: ?string, path: list, + * steps: int, across: int, down: int, walkSeconds: float, dashSeconds: ?float, dashSpeed: ?float, dashUnavailable: ?string, + * stepSeconds: float, view: array{columns: int, rows: int, zoom: float}, screens: float, + * encounters: ?array{rate: int, everyTile: bool, countedSteps: float, expected: float, troops: list}, + * legs: list + * } + * @throws InvalidArgumentException When fewer than two points or more than {@see MAX_POINTS} are named. + */ + public function measure(array $points): array + { + if (count($points) < 2 || count($points) > self::MAX_POINTS) { + throw new InvalidArgumentException(sprintf('A walk is measured between 2 and %d points.', self::MAX_POINTS)); + } + $source = MapSourceReader::readDeclarations($this->map->mapId, $this->map->getEditableData(), $this->map->getLayerSet(), + $this->map->getEventLayerCells()); + $collisions = $this->map->getResolvedCollisionMap(); + $walking = MapReachability::readMap($this->map->mapId, $source['data'], $collisions); + $encounters = EncounterTable::readBlock(is_array($source['data']['encounters'] ?? null) ? $source['data']['encounters'] : null, + MapAreaCatalog::readMap($source['data'], $source['tiles']), $this->map->resolveEncounterTerrain()); + $stepSeconds = $this->metric->getWalkSeconds(new Vector2(1, 0)); + + $problem = $this->findPointProblem($walking, $points); + $path = [$points[0]]; + $legs = []; + $troops = []; + $counted = 0.0; + for ($index = 1; $problem === null && $index < count($points); $index++) { + $route = $walking->findRoute($points[$index - 1], $points[$index]); + if ($route === null) { + $problem = sprintf('No walk leads from (%d, %d) to (%d, %d).', ...[...$points[$index - 1], ...$points[$index]]); + break; + } + $legCounted = 0.0; + foreach (array_slice($route, 1) as [$x, $y]) { + $position = new Vector2($x, $y); + $weight = $encounters->getStepWeight(CollisionType::tryFrom($collisions[$y][$x] ?? -1), $position); + if ($weight > 0.0) { + $legCounted += $weight; + $troops += $encounters->getTroopWeightsAt($position); + } + } + $counted += $legCounted; + $steps = count($route) - 1; + $legs[] = ['from' => $points[$index - 1], 'to' => $points[$index], 'steps' => $steps, + 'walkSeconds' => $this->roundSeconds($steps * $stepSeconds), 'dashSeconds' => $this->measureDashSeconds($steps * $stepSeconds), + 'expectedEncounters' => $encounters->hasEncounters ? round($legCounted / $encounters->averageStepsBetween, 2) : null]; + array_push($path, ...array_slice($route, 1)); + } + if ($problem !== null) { + $path = []; + $legs = []; + } + + [$across, $down] = self::countHeadings($path); + $steps = $across + $down; + + return [ + 'points' => $points, + 'reachable' => $problem === null, + 'problem' => $problem, + 'path' => $path, + 'steps' => $steps, + 'across' => $across, + 'down' => $down, + 'walkSeconds' => $this->roundSeconds($steps * $stepSeconds), + 'dashSeconds' => $this->measureDashSeconds($steps * $stepSeconds), + 'dashSpeed' => $this->dashSpeedMultiplier, + 'dashUnavailable' => $this->dashSpeedMultiplier === null + ? $this->dashUnavailable ?? 'This game authors no dash speed, so only walking time is shown.' : null, + 'stepSeconds' => round($stepSeconds, 4), + 'view' => ['columns' => $this->view->columns, 'rows' => $this->view->rows, 'zoom' => $this->view->zoom], + 'screens' => round($across / $this->view->columns + $down / $this->view->rows, 2), + 'encounters' => $encounters->hasEncounters ? [ + 'rate' => $encounters->averageStepsBetween, + 'everyTile' => $encounters->countsEveryTile, + 'countedSteps' => $counted, + 'expected' => round($counted / $encounters->averageStepsBetween, 2), + 'troops' => array_map(strval(...), array_keys($troops)), + ] : null, + 'legs' => $legs, + ]; + } + + /** + * Why a point cannot be on a walk: outside the map, solid, under a fixed + * NPC, or a waypoint that would take the player off the map before the end. + * + * @param list $points + */ + private function findPointProblem(MapReachability $walking, array $points): ?string + { + foreach ($points as $index => [$x, $y]) { + $standing = $walking->describeStandingProblem($x, $y); + if ($standing !== null) { + return sprintf('The player cannot stand at (%d, %d): it is %s.', $x, $y, $standing); + } + if ($index > 0 && $index < count($points) - 1 && $walking->isLeavingMapAt($x, $y)) { + return sprintf('Stepping onto (%d, %d) takes the player off the map, so a walk cannot go on from it.', $x, $y); + } + } + + return null; + } + + private function measureDashSeconds(float $walkSeconds): ?float + { + return $this->dashSpeedMultiplier === null ? null : $this->roundSeconds($walkSeconds / $this->dashSpeedMultiplier); + } + + private function roundSeconds(float $seconds): float + { + return round($seconds, self::SECONDS_PRECISION); + } + + /** + * The steps a walk takes across and down. + * + * @param list $path + * @return array{0: int, 1: int} + */ + private static function countHeadings(array $path): array + { + $across = 0; + $down = 0; + for ($index = 1; $index < count($path); $index++) { + $across += abs($path[$index][0] - $path[$index - 1][0]); + $down += abs($path[$index][1] - $path[$index - 1][1]); + } + + return [$across, $down]; + } +} diff --git a/src/Field/NpcAuthoring.php b/src/Field/NpcAuthoring.php new file mode 100644 index 00000000..ae6c118f --- /dev/null +++ b/src/Field/NpcAuthoring.php @@ -0,0 +1,584 @@ +references = new NpcReferences($workspace); + } + + /** + * Creates a fixed NPC at a tile under the stable id its author chose, or + * one derived from its name. + * + * @param string $name The display name; blank takes {@see DEFAULT_NAME}. + * @param string|null $id The id; null derives one from the name. + * @throws NpcRefusal When the tile is outside the map or another NPC anchors there while both can be visible, or the id is not one or is taken. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function create(ProjectMap $map, int $x, int $y, string $name, ?string $id = null): NpcChange + { + $before = $map->getNpcs(); + $this->assertInsideMap($map, $x, $y); + $name = trim($name) === '' ? self::DEFAULT_NAME : trim($name); + $npc = ProjectNpc::createAt($id === null ? $before->uniqueIdFor($name) : self::requireChosenId($before, null, $id), $name, $x, $y); + $this->assertPlaceable($map, $before, null, $npc); + $after = $before->withAdded($npc); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC create', $before, $after), $before->count(), $npc); + } + + /** + * Moves an NPC's anchor to a tile. A move to where it already stands + * changes nothing. + * + * @throws NpcRefusal When there is no such NPC, the tile is outside the map, or another NPC anchors there while both can be visible. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function move(ProjectMap $map, int $index, int $x, int $y): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + + if ($npc->getX() === $x && $npc->getY() === $y) { + return new NpcChange(null, $index, $npc); + } + + $moved = $npc->movedTo($x, $y); + $this->assertPlaceable($map, $before, $index, $moved); + $after = $before->withReplaced($index, $moved); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC move', $before, $after), $index, $moved); + } + + /** + * Duplicates an NPC under a fresh id derived from its name, one sprite + * width to the right when that tile is inside the map and free, and on + * its own tile otherwise. The copy is appended. + * + * @throws NpcRefusal When there is no such NPC. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function duplicate(ProjectMap $map, int $index): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $copy = $npc->asCopyWithId($before->uniqueIdFor($npc->getName())); + $x = $npc->getX() + $npc->getSpriteWidth(); + + if ($x < $map->getWidth() && $before->indicesAt($x, $npc->getY()) === []) { + $copy = $copy->movedTo($x, $npc->getY()); + } + + $after = $before->withAdded($copy); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC duplicate', $before, $after), $before->count(), $copy); + } + + /** + * Deletes an NPC, refusing while anything names its id: a route or + * script that silently stops resolving is worse than a refusal, and the + * refusal's details are what the author goes to fix. + * + * @throws NpcRefusal When there is no such NPC or something names it. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function delete(ProjectMap $map, int $index): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $references = $npc->getId() !== null ? $this->references->describe($map, $npc->getId()) : []; + + if ($references !== []) { + throw new NpcRefusal( + sprintf('%s is named by %s - resolve those before deleting.', $npc->getName(), implode(', ', $references)), + array_map(static fn(string $reference): string => '- ' . $reference, $references), + ); + } + + $after = $before->withRemoved($index); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, 'NPC delete', $before, $after), null, $npc); + } + + /** + * Gives an NPC authored without a stable id the one its author chose, or + * one derived from its name and unique on its map, so movement routes can + * target it. An existing id changes through {@see setId()}, which checks + * what names it. + * + * @param string|null $id The id; null derives one from the name. + * @throws NpcRefusal When there is no such NPC, it already has an id, or the chosen id is not one or is taken. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function assignId(ProjectMap $map, int $index, ?string $id = null): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + + if ($npc->getId() !== null) { + throw new NpcRefusal(sprintf('%s already has the stable id "%s"; change it through its Id row.', $npc->getName(), $npc->getId())); + } + + $id = $id === null ? $before->uniqueIdFor($npc->getName()) : self::requireChosenId($before, $index, $id); + $identified = $npc->asCopyWithId($id); + $after = $before->withReplaced($index, $identified); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, sprintf('Assign NPC id %s', $id), $before, $after), $index, $identified); + } + + /** + * Gives an NPC the stable id its author chose, refusing while anything + * names its current one: a route or script that silently stops resolving + * is worse than a refusal, and the refusal's details are what the author + * goes to change. An NPC without an id simply takes it. + * + * @throws NpcRefusal When there is no such NPC, the id is not one or is taken, or something names the current id. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function setId(ProjectMap $map, int $index, string $id): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $id = self::requireChosenId($before, $index, $id); + $current = $npc->getId(); + + if ($current === $id) { + return new NpcChange(null, $index, $npc); + } + + $references = $current === null ? [] : $this->references->describe($map, $current); + + if ($references !== []) { + throw new NpcRefusal( + sprintf('%s keeps the id "%s": %s name%s it. Change those first.', $npc->getName(), $current, implode(', ', $references), + count($references) === 1 ? 's' : ''), + array_map(static fn(string $reference): string => '- ' . $reference, $references), + ); + } + + $identified = $current === null ? $npc->asCopyWithId($id) : $npc->withId($id); + $after = $before->withReplaced($index, $identified); + $map->setNpcs($after); + + return new NpcChange($this->createCommand($map, sprintf('NPC id %s', $id), $before, $after), $index, $identified); + } + + /** + * Applies one row's edit through the NPC's record pane. A new name + * carries an id its old name derived with it while nothing names the old + * id; otherwise the id stays and the change says what names it. The + * rename and its id are one undo step. The Id row changes the id as + * {@see setId()} does. The X and Y rows move the NPC, under the same tile + * rules as {@see move()}, and so does a visibility edit to an NPC sharing + * its tile with another. + * + * @param array $framePath The frame the row belongs to; [] for the NPC itself. + * @param array $field The row, as {@see NpcInspector::getFields()} gave it. + * @throws NpcRefusal When there is no such NPC or frame, a coordinate leaves the map, or a move or visibility edit leaves two NPCs on one tile visible at once. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function applyField(NpcInspector $inspector, int $index, array $framePath, array $field, string $rawValue): NpcChange + { + $map = $inspector->map; + $before = $map->getNpcs(); + $this->requireFrame($inspector, $before, $index, $framePath); + $fieldId = (string) ($field['field'] ?? ''); + + if ($framePath === [] && $fieldId === 'id') { + try { + return $this->setId($map, $index, $rawValue); + } finally { + $inspector->refresh(); + } + } + + $followedId = null; + $idReferences = []; + + try { + try { + $inspector->records()->setFrameField($index, $framePath, $fieldId, $rawValue); + } catch (InvalidArgumentException $invalid) { + // A value its field cannot take, such as an unreadable condition, is the author's to correct. + throw new NpcRefusal($invalid->getMessage(), previous: $invalid); + } + $inspector->commit(); + $after = $map->getNpcs(); + $placed = $after->get($index); + $was = $before->get($index); + + if ($placed !== null && $was !== null && ($placed->getX() !== $was->getX() || $placed->getY() !== $was->getY() + || $placed->getConditions() !== $was->getConditions())) { + // A coordinate row is a move, and a visibility edit can make + // phases sharing a tile visible together: the tile rules a + // move keeps apply to both. + $this->assertPlaceable($map, $before, $index, $placed); + } + + if ($framePath === [] && $fieldId === 'name') { + [$after, $followedId, $idReferences] = $this->followNameWithId($map, $before, $after, $index); + } + } catch (Throwable $failure) { + // The rename and its id are one change: when the id cannot + // follow, the name does not land alone. + if ($map->getNpcs()->toMapData() !== $before->toMapData()) { + $map->setNpcs($before); + } + + throw $failure; + } finally { + // The pane always shows the map, whether the write landed or not. + $inspector->refresh(); + } + + return new NpcChange( + $this->createCommand($map, sprintf('NPC %s edit', $field['label'] ?? 'field'), $before, $after), + $index, + $after->get($index), + $followedId, + $idReferences, + ); + } + + /** + * Adds an item at a row of the NPC's record pane: inside a script frame, + * a route step under the route the row belongs to, another option on a + * choice option's row, or a command after the row's command (at the + * end when the row names none); at the root, a line in the row's + * dialogue variant, or a new variant. Which item a row belongs to, and + * how it is added, are the shared record rules' ({@see RecordAuthoring}). + * + * @param array $framePath The frame the row belongs to. + * @param string $fieldId The row's field id; '' for none. + * @throws NpcRefusal When there is no such NPC or frame. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function addSubItem(NpcInspector $inspector, int $index, array $framePath, string $fieldId): NpcChange + { + $records = $inspector->records(); + $item = $records->locateItem($index, $framePath, $fieldId); + // Inside a frame, any row of a command that owns steps adds a step. + $nested = $framePath !== [] ? $records->frameNestedContext($index, $framePath, $fieldId) : null; + + return $this->changeRows($inspector, $index, $framePath, 'NPC add', + static fn(RecordAuthoring $authoring): RecordChange => match (true) { + $nested !== null => $authoring->addNestedItem($records, $index, $framePath, $nested['parentIndex']), + $item?->kind === RecordItem::OPTION => $authoring->addOption($records, $index, $framePath, $item->entryIndex), + $framePath !== [] => $authoring->addEntry($records, $index, $framePath, $item?->entryIndex), + $item?->kind === RecordItem::NESTED => $authoring->addNestedItem($records, $index, $framePath, $item->entryIndex), + default => $authoring->addEntry($records, $index, $framePath), + }); + } + + /** + * Removes the item a row of the NPC's record pane belongs to: a route + * step, a choice's option, a command, a dialogue line or a dialogue + * variant ({@see RecordAuthoring::removeItem()}). A row that belongs to + * none changes nothing. + * + * @param array $framePath The frame the row belongs to. + * @param string $fieldId The row's field id. + * @throws NpcRefusal When there is no such NPC or frame. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function removeSubItem(NpcInspector $inspector, int $index, array $framePath, string $fieldId): NpcChange + { + $records = $inspector->records(); + + return $this->changeRows($inspector, $index, $framePath, 'NPC remove', + static fn(RecordAuthoring $authoring): RecordChange => $authoring->removeItem($records, $index, $framePath, $fieldId)); + } + + /** + * Moves the page a row of the NPC's record pane belongs to one place + * earlier or later. Talking runs the first available page, so the order + * is the NPC's priority. A row of no page changes nothing. + * + * @param string $fieldId Any row of the page: its conditions, cue or a line. + * @param int $step -1 for earlier, 1 for later. + * @throws NpcRefusal When there is no such NPC. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + public function moveSubItem(NpcInspector $inspector, int $index, string $fieldId, int $step): NpcChange + { + $records = $inspector->records(); + $item = $records->locateItem($index, [], $fieldId); + + return $this->changeRows($inspector, $index, [], 'NPC page move', + static fn(RecordAuthoring $authoring): RecordChange => $item === null || $item->framePath !== [] + ? new RecordChange(null) + : $authoring->moveEntry($records, $index, $item->entryIndex, $step, $item->listKey)); + } + + /** + * Makes one item change through the record pane and writes it back to + * the map, as one undo step over the map's NPCs. + * + * @param array $framePath + * @param callable(RecordAuthoring): RecordChange $change + * @throws NpcRefusal When there is no such NPC or frame, or the pane refuses the change. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + private function changeRows(NpcInspector $inspector, int $index, array $framePath, string $label, callable $change): NpcChange + { + $map = $inspector->map; + $before = $map->getNpcs(); + $this->requireFrame($inspector, $before, $index, $framePath); + + try { + // The pane's own undo step is over its records; the map's NPCs are + // what this change's undo restores. + $change(new RecordAuthoring()); + $inspector->commit(); + } catch (RecordRefusal $refusal) { + throw new NpcRefusal($refusal->getMessage(), previous: $refusal); + } finally { + $inspector->refresh(); + } + + return $this->createRowChange($map, $label, $before, $index); + } + + /** + * Gives a renamed NPC the id its new name derives, when its id is the + * one its old name derived and nothing names it. An id an author chose + * is not the name's to change. + * + * @return array{0: NpcCollection, 1: ?string, 2: list} The NPCs afterwards, the id taken, and what names the id kept. + * @throws MapSourceRefusal When the map's source cannot take the change. + */ + private function followNameWithId(ProjectMap $map, NpcCollection $before, NpcCollection $npcs, int $index): array + { + $npc = $npcs->get($index); + $id = $npc?->getId(); + $was = $before->get($index); + + if ($npc === null || $id === null || $was === null || $before->withRemoved($index)->uniqueIdFor($was->getName()) !== $id) { + return [$npcs, null, []]; + } + + $derived = $npcs->withRemoved($index)->uniqueIdFor($npc->getName()); + + if ($derived === $id) { + return [$npcs, null, []]; + } + + $references = $this->references->describe($map, $id); + + if ($references !== []) { + return [$npcs, null, $references]; + } + + $renamed = $npcs->withReplaced($index, $npc->withId($derived)); + $map->setNpcs($renamed); + + return [$renamed, $derived, []]; + } + + /** + * An id an author chose for an NPC: the form ids the editor derives take + * (lowercase letters and digits joined by single hyphens), so routes and + * scripts name it without quoting, and no other NPC on the map has it. + * + * @param int|null $ownIndex The NPC taking it, when it is already on the map. + * @throws NpcRefusal When it is not such an id, or another NPC has it. + */ + private static function requireChosenId(NpcCollection $npcs, ?int $ownIndex, string $id): string + { + $id = trim($id); + + if ($id === '' || Slug::of($id) !== $id) { + throw new NpcRefusal(sprintf('"%s" is not an NPC id: lowercase letters and digits joined by single hyphens, such as "gate-guard".', $id)); + } + + foreach ($npcs->all() as $index => $npc) { + if ($index !== $ownIndex && $npc->getId() === $id) { + throw new NpcRefusal(sprintf('%s already has the id "%s" on this map.', $npc->getName(), $id)); + } + } + + return $id; + } + + /** + * Refuses a tile outside the map, or one another NPC anchors to while + * both can be visible. + * + * The game shows an NPC only while its visibility conditions hold, so + * two NPCs may share a tile as phases of one presence when their + * conditions provably exclude each other ({@see WorldConditionExclusion}). + * Any other pair would be two bodies on one tile. + * + * @param int|null $ownIndex The NPC being placed, when it is already on the map. + * @param ProjectNpc $npc The NPC as it would stand, conditions included. + * @throws NpcRefusal + */ + private function assertPlaceable(ProjectMap $map, NpcCollection $npcs, ?int $ownIndex, ProjectNpc $npc): void + { + $x = $npc->getX(); + $y = $npc->getY(); + $this->assertInsideMap($map, $x, $y); + + foreach ($npcs->indicesAt($x, $y) as $occupant) { + $other = $npcs->get($occupant); + + if ($occupant === $ownIndex || $other === null + || WorldConditionExclusion::proveExclusive($npc->getConditions(), $other->getConditions())) { + continue; + } + + throw new NpcRefusal(sprintf( + '%s already stands at %d,%d while both can be visible. Give one a condition the other negates, such as event:x and !event:x, to share the tile.', + $other->getName(), $x, $y, + )); + } + } + + /** @throws NpcRefusal */ + private function assertInsideMap(ProjectMap $map, int $x, int $y): void + { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new NpcRefusal(sprintf('%d,%d is outside the map.', $x, $y)); + } + } + + /** @throws NpcRefusal */ + private function requireNpc(ProjectMap $map, NpcCollection $npcs, int $index): ProjectNpc + { + return $npcs->get($index) ?? throw new NpcRefusal(sprintf('%s has no NPC %d.', $map->mapId, $index)); + } + + /** + * Refuses an NPC or a script frame that is no longer there. + * + * @param array $framePath + * @throws NpcRefusal + */ + private function requireFrame(NpcInspector $inspector, NpcCollection $npcs, int $index, array $framePath): void + { + $this->requireNpc($inspector->map, $npcs, $index); + + if ($framePath !== [] && $inspector->records()->getFrameCommands($index, $framePath) === null) { + throw new NpcRefusal(sprintf('%s is no longer there.', $inspector->records()->describeFramePath($framePath))); + } + } + + /** + * Gives an NPC the RPG Maker character sheet it walks with in the + * graphical field, or takes it away (null), as one undo step. The sheet's + * other settings are kept, the terminal sprite is untouched, and the sheet + * is checked as the game will read it ({@see ProjectMap::setNpcGraphicalSprites()}). + * + * @throws NpcRefusal When there is no such NPC. + * @throws MapSourceRefusal When the sheet is not a usable character sheet or the map's source cannot take the change. + */ + public function setFieldSheet(ProjectMap $map, int $index, ?string $sheet): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $current = $npc->toArray()['sprites2d'] ?? null; + $sheet = $sheet === null ? '' : trim($sheet); + $map->setNpcGraphicalSprites($index, $sheet === '' ? null : [...(is_array($current) ? $current : []), 'sheet' => $sheet]); + + return $this->createRowChange($map, 'NPC field sheet', $before, $index); + } + + public function setFieldSheetIndex(ProjectMap $map, int $index, int $characterIndex): NpcChange + { + return $this->setFieldSheetValue($map, $index, 'index', $characterIndex); + } + + public function setFieldSheetLayer(ProjectMap $map, int $index, int $layer): NpcChange + { + return $this->setFieldSheetValue($map, $index, 'layer', $layer); + } + + /** Changes only the selected sheet setting through the map's validated, source-safe writer. */ + private function setFieldSheetValue(ProjectMap $map, int $index, string $field, int $value): NpcChange + { + $before = $map->getNpcs(); + $npc = $this->requireNpc($map, $before, $index); + $current = $npc->toArray()['sprites2d'] ?? null; + if (! is_array($current) || ! is_string($current['sheet'] ?? null) || $current['sheet'] === '') { + throw new NpcRefusal('Select an NPC field sheet before editing its character index or graphical layer.'); + } + $map->setNpcGraphicalSprites($index, [...$current, $field => $value]); + + return $this->createRowChange($map, 'NPC field sheet ' . $field, $before, $index); + } + + /** The change a row edit made to one NPC, with its command when it changed anything. */ + private function createRowChange(ProjectMap $map, string $label, NpcCollection $before, int $index): NpcChange + { + $after = $map->getNpcs(); + + return new NpcChange($this->createCommand($map, $label, $before, $after), $index, $after->get($index)); + } + + /** + * The command that puts either collection back, or null when the two + * are the same: a same-value edit leaves no history. + */ + private function createCommand(ProjectMap $map, string $label, NpcCollection $before, NpcCollection $after): ?Command + { + if ($after->toMapData() === $before->toMapData()) { + return null; + } + + return new GenericCommand( + $label, + static fn() => $map->setNpcs($after), + static fn() => $map->setNpcs($before), + ); + } +} diff --git a/src/Field/NpcChange.php b/src/Field/NpcChange.php new file mode 100644 index 00000000..08d46ea3 --- /dev/null +++ b/src/Field/NpcChange.php @@ -0,0 +1,33 @@ + $idReferences What names the id a renamed NPC kept instead. + */ + public function __construct( + public ?Command $command, + public ?int $index, + public ?ProjectNpc $npc, + public ?string $followedId = null, + public array $idReferences = [], + ) { + } +} diff --git a/src/Field/NpcCharacterSheet.php b/src/Field/NpcCharacterSheet.php new file mode 100644 index 00000000..045cfbdd --- /dev/null +++ b/src/Field/NpcCharacterSheet.php @@ -0,0 +1,28 @@ + $data */ + public static function validate(array $data, ?string $assetRoot = null): CharacterSheet + { + $sheet = CharacterSheet::fromArray($data); + if ($sheet->layer < PresentationLayerPolicy::WORLD || $sheet->layer >= PresentationLayerPolicy::FIELD_GUIDANCE) { + throw new RuntimeException('Automatic Game world sprites require layers 0..999; guidance and UI layers are reserved.'); + } + if ($assetRoot !== null) { + $image = PngAssetPreflight::inspect($assetRoot, $sheet->asset); + $sheet->getFrameSize($image['width'], $image['height']); + } + return $sheet; + } +} diff --git a/src/Field/NpcCollection.php b/src/Field/NpcCollection.php index 7755fe97..2d096c3a 100644 --- a/src/Field/NpcCollection.php +++ b/src/Field/NpcCollection.php @@ -91,7 +91,29 @@ public function get(int $index): ?ProjectNpc } /** - * Returns the position of the NPC anchored at a tile, if any. + * Returns the positions of every NPC anchored at a tile, in order. More + * than one stands there only as phases whose visibility conditions + * exclude each other. + * + * @param int $x The column. + * @param int $y The row. + * @return list The positions. + */ + public function indicesAt(int $x, int $y): array + { + $indices = []; + + foreach ($this->npcs as $index => $npc) { + if ($npc->getX() === $x && $npc->getY() === $y) { + $indices[] = $index; + } + } + + return $indices; + } + + /** + * Returns the position of the first NPC anchored at a tile, if any. * * @param int $x The column. * @param int $y The row. diff --git a/src/Field/NpcEventAdoption.php b/src/Field/NpcEventAdoption.php new file mode 100644 index 00000000..81ab9e4c --- /dev/null +++ b/src/Field/NpcEventAdoption.php @@ -0,0 +1,150 @@ + 'its blocked message', 'facing' => 'the facings it answered', + 'engagement' => 'how the player stood to it', 'area' => 'its cells']; + + /** + * @param int $npcIndex The NPC's position in the map's list. + * @param array $entry The NPC entry with the new page first. + * @param array $page The new page. + * @param list $dropped What the event had that a page does not. + * @param array{from: string, to: string}|null $alias The saved identity to carry: when the event ran once, or saves already alias to it. + */ + private function __construct( + public readonly int $npcIndex, + public readonly string $npcId, + public readonly array $entry, + public readonly array $page, + public readonly array $dropped, + public readonly ?array $alias, + ) { + } + + /** + * @param string|null $npcId The NPC to own it; the event's first speaker when null. + * @param bool $aliased Whether saves already alias another identity to this event, so its own must follow the move. + * @throws NpcRefusal When the event is not a story event an NPC can own, or the NPC cannot take it. + */ + public static function plan(ProjectMap $map, string $marker, ?string $npcId = null, bool $aliased = false): self + { + $event = $map->getEventDefinition($marker) ?? throw new NpcRefusal("{$map->mapId} has no event {$marker}."); + $class = ltrim(strval($event['class'] ?? ''), '\\'); + $data = is_array($event['data'] ?? null) ? $event['data'] : []; + if (!array_any(MapNpcInteractionRule::PAGE_ACTION_TRIGGERS, static fn(string $type): bool => is_a($class, $type, true))) { + throw new NpcRefusal("Event {$marker} is not a script, Cinematic or dialogue event, so no NPC page can run it."); + } + if (strtolower(trim(strval($data['mode'] ?? ''))) === ScriptEventTrigger::MODE_AUTO) { + throw new NpcRefusal("Event {$marker} starts when the player arrives; talking to an NPC cannot start it."); + } + if (is_array($event['subject'] ?? null)) { + throw new NpcRefusal("Event {$marker} belongs to the object or place it is bound to."); + } + $speakers = array_values(array_filter((array) ($event['speakers'] ?? []), is_string(...))); + $npcId ??= $speakers[0] ?? throw new NpcRefusal("Event {$marker} names no speaker; choose the NPC who delivers it."); + $npcs = $map->getNpcs(); + $index = $npcs->indexOfId($npcId) ?? throw new NpcRefusal("{$map->mapId} has no NPC {$npcId}."); + $npc = $npcs->get($index) ?? throw new NpcRefusal("{$map->mapId} has no NPC {$npcId}."); + if ($npc->getScript() !== [] && $npc->getDialogue() !== []) { + throw new NpcRefusal("{$npc->getName()} has a script that replaces their dialogue; make them pages first."); + } + + $page = []; + $action = match (true) { + is_a($class, CinematicEventTrigger::class, true) => ['cinematicId' => strval($data['cinematicId'] ?? '')], + is_a($class, DialogueEventTrigger::class, true) => ['lines' => self::readLines($data, $marker)], + is_array($data['script'] ?? null) && $data['script'] !== [] => ['script' => array_values($data['script'])], + default => ['scriptId' => strval($data['scriptId'] ?? '')], + }; + $runsOnce = ($data['reusable'] ?? true) === false; + $existing = array_filter(array_map(static fn(array $page): ?string => is_string($page['id'] ?? null) ? $page['id'] : null, + $npc->getPages())); + $base = strval($action['scriptId'] ?? $action['cinematicId'] ?? '') ?: 'event-' . strtolower($marker); + $id = $base; + for ($n = 2; in_array($id, $existing, true); $n++) { $id = "{$base}-{$n}"; } + if ($runsOnce || $aliased || isset($action['scriptId']) || isset($action['cinematicId'])) { $page['id'] = $id; } + foreach (['conditions', 'sets'] as $field) { + if (is_array($event[$field] ?? null) && $event[$field] !== []) { $page[$field] = array_values($event[$field]); } + } + $page = [...$page, ...$action]; + if ($runsOnce) { $page['reusable'] = false; } + $staged = array_values(array_diff($speakers, [$npcId])); + if ($staged !== []) { $page['speakers'] = $staged; } + if (is_array($event['cue'] ?? null) && trim(strval($event['cue']['symbol'] ?? '')) !== '') { $page['cue'] = $event['cue']; } + + $cue = is_array($npc->toArray()['cue'] ?? null) ? $npc->toArray()['cue'] : null; + if ($cue !== null && $npc->hasDialogueVariants()) { + throw new NpcRefusal("{$npc->getName()} has a cue beside pages; put it on the page it belongs to first."); + } + $pages = match (true) { + // A top-level script replaces dialogue, so it becomes the page after the beat, keeping its cue. + $npc->getScript() !== [] => [array_filter(['script' => $npc->getScript(), 'cue' => $cue], static fn(mixed $v): bool => $v !== null)], + // The NPC's own cue moves with its only page, the one it described (getPages() carries it). + $npc->getPages() === [] && $cue !== null => [['cue' => $cue]], + default => $npc->getPages(), + }; + $entry = $npc->toArray(); + unset($entry['script'], $entry['cue']); + $entry = ProjectNpc::storePages($entry, [$page, ...$pages]); + try { + NpcPages::fromEntry($entry); + } catch (InvalidArgumentException $error) { + throw new NpcRefusal("{$npc->getName()} cannot take event {$marker} as a page: {$error->getMessage()}", previous: $error); + } + + $dropped = []; + foreach (self::CELL_FIELDS as $field => $what) { + if (array_key_exists($field, $event) && $event[$field] !== [] && $event[$field] !== null) { $dropped[] = $what; } + } + + return new self($index, $npcId, $entry, $page, $dropped, $runsOnce || $aliased ? [ + 'from' => "{$map->mapId}:{$marker}", + 'to' => "{$map->mapId}:" . NpcPages::getCompletionMarker($npcId, $id), + ] : null); + } + + /** @return list> */ + private static function readLines(array $data, string $marker): array + { + $dialogue = array_values(array_filter((array) ($data['dialogue'] ?? []), is_array(...))); + if (ConditionalDialogue::isVariantList($dialogue)) { + throw new NpcRefusal("Event {$marker} speaks conditional variants; move them onto the NPC as pages by hand."); + } + + return $dialogue; + } +} diff --git a/src/Field/NpcInspector.php b/src/Field/NpcInspector.php index 7de9409b..c2849998 100644 --- a/src/Field/NpcInspector.php +++ b/src/Field/NpcInspector.php @@ -6,6 +6,7 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\ProjectMap; /** @@ -17,20 +18,209 @@ * flows back into `ProjectMap::setNpcs()`, which is what the map persists * and fingerprints; this class holds no persisted state of its own. * - * Dialogue is presented as variants (plain pages folded into one) and - * unfolded on the way back, so a file authored as pages is saved as pages. + * Dialogue is presented as the NPC's interaction pages (plain lines folded + * into one page, carrying the NPC's cue) and unfolded on the way back, so a + * file authored as lines is saved as lines ({@see ProjectNpc::getPages()}). + * The NPC-level Cue rows are shown only beside a top-level script, the one + * page that has no page rows of its own. + * + * The rows every interface lists for one NPC come from {@see getFields()}: + * the record layer's own rows under headings, with the notes an author + * needs, and field ids untouched so an edit still names the record field. * * @package Ichiloto\Editor\Field */ final class NpcInspector { + /** + * The field id of the note on an NPC without a stable id; acting on it + * assigns one ({@see NpcAuthoring::assignId()}). + */ + public const string ASSIGN_ID_FIELD = '__npc_assign_id'; + + /** + * The headings the root rows are grouped under, and the field ids each + * holds, in the order they are listed. + */ + private const array SECTIONS = [ + 'Identity' => ['id', 'name'], + 'Placement' => ['x', 'y'], + 'Appearance' => ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], + 'Movement' => ['movement', 'directionFix', 'facing', 'wanderArea.x', 'wanderArea.y', 'wanderArea.width', 'wanderArea.height'], + 'Visibility' => ['conditions'], + 'Cue' => ['cue.symbol', 'cue.color', 'cue.conditions'], + 'Interaction' => ['commandListScript'], + 'Completion Writes' => ['sets'], + ]; + + /** The rows that are a cell's coordinates, by field, and the axis each is. */ + private const array AXES = ['x' => 'x', 'y' => 'y']; + private ProjectRecordDatabase $records; - public function __construct(private readonly ProjectMap $map) + public function __construct(public readonly ProjectMap $map, private readonly bool $graphical = false, private readonly ?ReferenceCatalog $references = null) { $this->records = $this->build(); } + /** + * Returns one NPC's rows in a frame of its commands: at the root, its + * fields grouped under headings with its identity notes and each + * dialogue variant headed; inside a script frame, that frame's rows. + * + * @param int $index The NPC's position. + * @param array $framePath The frame; [] for the NPC itself. + * @return array>|null The field descriptors, or null when the frame no longer resolves. + */ + public function getFields(int $index, array $framePath = []): ?array + { + if ($framePath !== [] && $this->records->getFrameCommands($index, $framePath) === null) { + return null; + } + + $fields = $this->records->getFrameSettingsFields($index, $framePath); + + if ($framePath !== []) { + return $fields; + } + + $npc = $this->map->getNpcs()->get($index); + $grouped = []; + $group = static fn(string $title): array => ['label' => $title, 'value' => '', 'editable' => false]; + $notes = []; + + if ($npc !== null && $npc->getId() === null) { + // Legacy entry: nothing can name an id it never had, so one is + // given from the name at once; the Id row takes a chosen one. + $notes[] = [ + 'label' => ' ! No stable id', + 'value' => 'move_route cannot target it; Enter assigns one from the name', + 'editable' => true, + 'field' => self::ASSIGN_ID_FIELD, + ]; + } + + if ($npc !== null && $npc->scriptShadowsDialogue()) { + $notes[] = ['label' => ' ! Script replaces dialogue', 'value' => 'the game runs the script', 'editable' => false]; + } + + if ($npc !== null && $npc->getUnknownFields() !== []) { + $notes[] = ['label' => ' Preserved fields', 'value' => implode(', ', $npc->getUnknownFields()), 'editable' => false]; + } + + $byId = []; + + foreach ($fields as $field) { + $byId[(string) ($field['field'] ?? '')][] = $field; + } + + foreach (self::SECTIONS as $title => $ids) { + $rows = []; + + if ($title === 'Cue' && $npc !== null && $npc->getScript() === [] + && ! (is_array($npc->toArray()['cue'] ?? null) && $npc->hasDialogueVariants())) { + // Pages carry their own cue; dropping the rows keeps one place to author it. + foreach ($ids as $id) { unset($byId[$id]); } + continue; + } + + foreach ($ids as $id) { + foreach ($byId[$id] ?? [] as $field) { + // Wander bounds only matter while wandering; loaded + // values are kept, just not shown for a fixed NPC. + if (! (str_starts_with($id, 'wanderArea.') && $npc !== null && ! $npc->wanders())) { + $rows[] = isset(self::AXES[$id]) ? [...$field, 'axis' => self::AXES[$id]] : $field; + } + } + + unset($byId[$id]); + } + + if ($rows !== []) { + $grouped[] = $group($title); + $grouped = [...$grouped, ...$rows]; + } + + if ($title === 'Identity') { + $grouped = [...$grouped, ...$notes]; + } + + if ($title === 'Interaction') { + // Everything left is dialogue: variants, their lines, and + // their frames, each variant under its own heading. + [$variantRows, $byId] = self::groupVariantRows($byId); + $grouped = [...$grouped, ...$variantRows]; + } + } + + foreach ($byId as $rest) { + $grouped = [...$grouped, ...$rest]; + } + + return $grouped; + } + + /** + * Turns the record pane's variant rows into headed groups: one + * `Dialogue variant N` heading per variant (with its condition line + * when it has one), then that variant's rows under short labels -- + * `When`, `Then Set`, `Script Commands`, `Line 1 Speaker`, `Line 1 + * Text` -- so the label no longer eats the pane before the value + * starts. Field ids are untouched; this is the grouped view's + * presentation of the record layer's own rows. + * + * @param array>> $byId The remaining rows, keyed by field id. + * @return array{0: array>, 1: array>>} The headed rows, and what was left. + */ + private static function groupVariantRows(array $byId): array + { + $singular = ucfirst(RecordSchemaCatalog::mapNpcs()->subList?->singular ?? 'dialogue variant'); + $variants = []; + + foreach ($byId as $id => $rows) { + if (preg_match('/^variant(\d+)/', $id, $matches) !== 1) { + continue; + } + + $variants[intval($matches[1])] = [...($variants[intval($matches[1])] ?? []), ...$rows]; + unset($byId[$id]); + } + + ksort($variants); + $headed = []; + + foreach ($variants as $number => $rows) { + $prefix = sprintf('%s %d ', $singular, $number + 1); + $when = ''; + + foreach ($rows as $row) { + if (($row['field'] ?? null) === sprintf('variant%dConditions', $number)) { + $when = trim((string) ($row['value'] ?? '')); + } + } + + // The condition line rides as the heading's value, so it reads + // "Dialogue variant 2 · when …" and wraps rather than clips. + $headed[] = [ + 'label' => sprintf('%s %d', $singular, $number + 1), + 'value' => $when === '' ? '' : 'when ' . $when, + 'editable' => false, + ]; + + foreach ($rows as $row) { + $label = (string) ($row['label'] ?? ''); + + if (str_starts_with($label, $prefix)) { + $row['label'] = substr($label, strlen($prefix)); + } + + $headed[] = $row; + } + } + + return [$headed, $byId]; + } + /** * Returns the record pane over the map's current NPCs. * @@ -72,7 +262,7 @@ private function build(): ProjectRecordDatabase foreach ($this->map->getNpcs()->toMapData() as $entry) { if (is_array($entry)) { $npc = new ProjectNpc($entry); - $entry['dialogue'] = $npc->getDialogueAsVariants(); + $entry['dialogue'] = $npc->getPages(); if ($entry['dialogue'] === []) { unset($entry['dialogue']); @@ -82,8 +272,8 @@ private function build(): ProjectRecordDatabase $entries[] = $entry; } - return ProjectRecordDatabase::overOwnedList( - RecordSchemaCatalog::mapNpcs(), + $records = ProjectRecordDatabase::overOwnedList( + RecordSchemaCatalog::mapNpcs($this->graphical), $this->map->dataPath, $entries, function (array $written): void { @@ -91,7 +281,7 @@ function (array $written): void { foreach ($written as $entry) { if (is_array($entry) && isset($entry['dialogue']) && is_array($entry['dialogue'])) { - $entry['dialogue'] = ProjectNpc::dialogueFromVariants($entry['dialogue']); + $entry = ProjectNpc::storePages($entry, $entry['dialogue']); } $stored[] = $entry; @@ -99,6 +289,9 @@ function (array $written): void { $this->map->setNpcs(NpcCollection::fromMapData($stored)); }, + graphical: $this->graphical, ); + if ($this->references !== null) { $records->useAuthoringReferences($this->references); } + return $records; } } diff --git a/src/Field/NpcReferences.php b/src/Field/NpcReferences.php index 071b7168..b91b96e9 100644 --- a/src/Field/NpcReferences.php +++ b/src/Field/NpcReferences.php @@ -4,20 +4,27 @@ namespace Ichiloto\Editor\Field; +use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Throwable; /** * Finding what names an NPC's stable id. * - * A `move_route` whose subject is `npc` names it by `npcId`; a script's - * diagnostics name it too. Knowing what points at an id is what lets the - * editor refuse to delete an NPC something still expects, and say what. + * A `move_route` whose subject is `npc` names it by `npcId`, a camera or + * cinematic subject of kind `npc` by `npcId` or `id`, a dialogue event by + * its data's `npcId`, and any map event by its `speakers`. Knowing what points at an id is what lets the editor + * refuse to delete an NPC something still expects, and keep the id of one + * something already names when it is renamed. * * NPC ids are map-local, so a reference is only a reference from that map's - * own context: its map-authored event scripts, its NPCs' inline scripts and - * variant scripts, and the reusable event scripts those trigger. + * own context: its map-authored events and their scripts, its NPCs' inline + * scripts and variant scripts, the cinematics that start on the map, and the + * reusable event scripts any of those reach, by an event's `scriptId` or a + * `common_event` command, followed from script to script as the interpreter + * runs them. * * @package Ichiloto\Editor\Field */ @@ -52,16 +59,22 @@ public function describe(ProjectMap $map, string $npcId): array } $data = (array) ($definition['data'] ?? []); + $label = sprintf('%s event %s', $map->mapId, strval($marker)); + $script = (array) ($data['script'] ?? []); - if ($this->commandsName((array) ($data['script'] ?? []), $npcId)) { - $found[] = sprintf('%s event %s', $map->mapId, strval($marker)); + if (self::readText($data['npcId'] ?? null) === $npcId || $this->commandsName($script, $npcId)) { + $found[] = $label; + } elseif (in_array($npcId, array_map(self::readText(...), (array) ($definition['speakers'] ?? [])), true)) { + $found[] = $label . ' (speaker)'; } - $scriptId = trim(strval($data['scriptId'] ?? '')); + $scriptId = self::readText($data['scriptId'] ?? null); if ($scriptId !== '') { - $usedScripts[$scriptId] = sprintf('%s event %s', $map->mapId, strval($marker)); + $usedScripts[$scriptId] ??= $label; } + + self::collectStartedScripts($script, $label, $usedScripts); } // The map's other NPCs: inline scripts and dialogue-variant scripts. @@ -72,28 +85,76 @@ public function describe(ProjectMap $map, string $npcId): array $found[] = $label . ' script'; } + self::collectStartedScripts($other->getScript(), $label . ' script', $usedScripts); + foreach ($other->getDialogueAsVariants() as $index => $variant) { - if ($this->commandsName((array) ($variant['script'] ?? []), $npcId)) { - $found[] = sprintf('%s dialogue variant %d', $label, $index + 1); + $variantLabel = sprintf('%s page %d', $label, $index + 1); + $script = (array) ($variant['script'] ?? []); + + if ($this->commandsName($script, $npcId)) { + $found[] = $variantLabel; + } elseif (in_array($npcId, array_map(self::readText(...), (array) ($variant['speakers'] ?? [])), true)) { + $found[] = $variantLabel . ' (speaker)'; + } + + self::collectStartedScripts($script, $variantLabel, $usedScripts); + $scriptId = self::readText($variant['scriptId'] ?? null); + + if ($scriptId !== '') { + $usedScripts[$scriptId] ??= $variantLabel; } } } - // Reusable scripts this map triggers. + // Cinematics that start on this map: their cast and commands. + foreach ($this->workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [] as $asset) { + try { + $data = $asset->data(); + $commands = $asset->commands(); + } catch (Throwable) { + continue; + } + + if (self::readText($data['startMap'] ?? null) !== $map->mapId) { + continue; + } + + $cinematic = [...(array) ($data['cast'] ?? []), ...(array) ($data['finalizer'] ?? []), ...$commands]; + $label = sprintf('cinematic %s', $asset->id); + + if ($this->commandsName($cinematic, $npcId)) { + $found[] = $label; + } + + self::collectStartedScripts($cinematic, $label, $usedScripts); + } + + // Reusable scripts this map reaches, and the ones those start in turn. + // Each is read once, so scripts that start each other end the walk. $database = $this->workspace->getRecordDatabase('common_events'); + $scripts = []; if ($database instanceof ProjectRecordDatabase) { foreach ($database->getRecords() as $record) { $script = (array) $record->toArray(); - $scriptId = strval($script['__scriptId'] ?? ''); + $scripts[strval($script['__scriptId'] ?? '')] ??= (array) ($script['commands'] ?? []); + } + } - if (! isset($usedScripts[$scriptId])) { - continue; - } + $read = []; + + while (($pending = array_diff_key($usedScripts, $read)) !== []) { + foreach ($pending as $scriptId => $via) { + $scriptId = strval($scriptId); + $read[$scriptId] = true; + $commands = $scripts[$scriptId] ?? []; + $label = sprintf('event script %s', $scriptId); - if ($this->commandsName((array) ($script['commands'] ?? []), $npcId)) { - $found[] = sprintf('event script %s (via %s)', $scriptId, $usedScripts[$scriptId]); + if ($this->commandsName($commands, $npcId)) { + $found[] = sprintf('%s (via %s)', $label, $via); } + + self::collectStartedScripts($commands, $label, $usedScripts); } } @@ -101,40 +162,71 @@ public function describe(ProjectMap $map, string $npcId): array } /** - * Determines whether a command list routes the NPC, however deeply. + * Notes the reusable event scripts commands start, however deeply nested, + * each under the first owner found starting it. + * + * @param array $commands The commands. + * @param string $owner Where the commands live, in words. + * @param array $started Owners by script id, added to. + */ + private static function collectStartedScripts(array $commands, string $owner, array &$started): void + { + foreach ($commands as $node) { + if (! is_array($node)) { + continue; + } + + // Only a common_event command's id names a script; elsewhere an + // id may be any reference, structured ones included. + $scriptId = ($node['type'] ?? null) === 'common_event' ? self::readText($node['id'] ?? null) : ''; + + if ($scriptId !== '') { + $started[$scriptId] ??= $owner; + } + + self::collectStartedScripts($node, $owner, $started); + } + } + + /** + * Determines whether commands name the NPC, however deeply nested: a + * `move_route` for subject `npc`, or any subject, target or cast entry + * of kind `npc`, by `npcId` or `id`. A structured subject such as + * `['kind' => 'npc', 'id' => ...]` is itself a nested reference, read + * when the walk reaches it, as the Engine's subject resolver reads it. * - * @param array $commands The commands. + * @param array $commands The commands. * @param string $npcId The id. * @return bool True when one does. */ private function commandsName(array $commands, string $npcId): bool { - foreach ($commands as $command) { - if (! is_array($command)) { + foreach ($commands as $node) { + if (! is_array($node)) { continue; } - if ( - strval($command['type'] ?? '') === 'move_route' - && strtolower(trim(strval($command['subject'] ?? 'player'))) === 'npc' - && trim(strval($command['npcId'] ?? '')) === $npcId - ) { + if (strtolower(self::readText($node['kind'] ?? $node['subject'] ?? null)) === 'npc' + && self::readText($node['npcId'] ?? $node['id'] ?? null) === $npcId) { return true; } - foreach ((array) ($command['options'] ?? []) as $option) { - if (is_array($option) && $this->commandsName((array) ($option['then'] ?? []), $npcId)) { - return true; - } - } - - foreach (['then', 'else'] as $arm) { - if ($this->commandsName((array) ($command[$arm] ?? []), $npcId)) { - return true; - } + if ($this->commandsName($node, $npcId)) { + return true; } } return false; } + + /** + * Returns an authored id, kind or map id as trimmed text, or an empty + * string when it is not text. A structured value, such as a subject + * written as `['kind' => 'npc', 'id' => ...]`, is a reference of its own + * that the walk reads where it stands, not text of the node holding it. + */ + private static function readText(mixed $value): string + { + return is_string($value) || is_int($value) ? trim((string) $value) : ''; + } } diff --git a/src/Field/NpcRefusal.php b/src/Field/NpcRefusal.php new file mode 100644 index 00000000..e4cdee19 --- /dev/null +++ b/src/Field/NpcRefusal.php @@ -0,0 +1,24 @@ + $details */ + public function __construct(string $message, public readonly array $details = [], ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + } +} diff --git a/src/Field/NpcSpriteArt.php b/src/Field/NpcSpriteArt.php new file mode 100644 index 00000000..fbfa18a4 --- /dev/null +++ b/src/Field/NpcSpriteArt.php @@ -0,0 +1,54 @@ +getNpcs()->get($index); + if ($npc === null) { throw new MapSourceRefusal('Select an existing NPC before editing artwork.'); } + $document = PhpArraySourceDocument::parse($this->proposedDataSource()); + $node = $document->root(); + foreach (['npcs', $index, 'sprites2d'] as $key) { + $this->assertNpcArtLiteral($node); + $node = $node->entryFor($key)?->value; + if ($node === null) { break; } + } + if ($node !== null) { $this->assertNpcArtLiteral($node, true); } + if ($data !== null) { + try { + $sheet = NpcCharacterSheet::validate($data, dirname($this->getMapsRoot())); + } catch (\InvalidArgumentException | \RuntimeException $invalid) { + // A sheet the game could not read is refused like any other edit the map cannot take. + throw new MapSourceRefusal($invalid->getMessage(), previous: $invalid); + } + if (! in_array($sheet->asset, ReferenceCatalog::getPngAssets(dirname($this->getMapsRoot(), 2)), true)) { + throw new MapSourceRefusal('Choose the character sheet through the project asset picker.'); + } + } + $entries = $this->getNpcs()->toMapData(); + $entries[$index] = $npc->with('sprites2d', $data)->toArray(); + $this->setNpcs(NpcCollection::fromMapData($entries)); + } + + private function assertNpcArtLiteral(SourceNode $node, bool $recursive = false): void + { + $keys = array_map(static fn($entry, $index) => $entry->key ?? $index, $node->entries, array_keys($node->entries)); + if (in_array($node->kind, [SourceNode::VARIABLE, SourceNode::EXPRESSION], true) || $node->hasOpaqueKey + || count(array_unique($keys, SORT_REGULAR)) !== count($keys)) { + throw new MapSourceRefusal('NPC sprites2d requires literal, unambiguous source. Opaque expressions and variables are preserved, not rewritten.'); + } + if ($recursive) { + foreach ($node->entries as $entry) { $this->assertNpcArtLiteral($entry->value, true); } + } + } +} diff --git a/src/Field/PlaceTypeFields.php b/src/Field/PlaceTypeFields.php new file mode 100644 index 00000000..7e4b83d1 --- /dev/null +++ b/src/Field/PlaceTypeFields.php @@ -0,0 +1,135 @@ + PlaceTypeCatalog::SCHEMA]), + fieldsFor: self::getFields(...), + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** A new type, whose id is its name's slug and whose name takes the Engine's translation key. */ + public static function makeBlank(string $name, string $projectRoot): array + { + $slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + $id = preg_match('/^[a-z]/', $slug) === 1 ? $slug : 'place-' . $slug; + if (preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { + throw new RecordRefusal(sprintf('"%s" cannot name a place type: its id would be %s, which is not a stable id.', $name, $id)); + } + + return ['id' => $id, 'name' => ['message' => PlaceTypeCatalog::getNameMessageKey($id), 'default' => trim($name)]]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + return [ + // Maps and tilesets name the type by its id: it changes only while none does. + new RecordField('id', 'Stable Id'), + new RecordField('name.default', 'Name'), + new RecordField('name.message', 'Name Translation Key'), + ]; + } + + /** + * Refuses an id the Engine would not accept as it is typed, and keeps a name translation key that followed the + * old id following the new one; a key an author chose stays. + */ + public static function checkEdit(array $entry, string $field): array + { + if ($field !== 'id') { return $entry; } + $id = $entry['id'] ?? null; + if (!is_string($id) || preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { + throw new RecordRefusal('A place type id is a stable lowercase id of 1 to 64 characters.'); + } + $probe = 'probe'; + $derived = '/\\A' . str_replace($probe, '[a-z][a-z0-9._-]*', preg_quote(PlaceTypeCatalog::getNameMessageKey($probe), '/')) . '\\z/'; + $message = $entry['name']['message'] ?? null; + if (is_string($message) && preg_match($derived, $message) === 1) { + $entry['name']['message'] = PlaceTypeCatalog::getNameMessageKey($id); + } + + return $entry; + } + + /** Why the Engine would not read the catalogue as a save would write it, or null when it would. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + PlaceTypeCatalog::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } + + /** + * What names a type, in words: the maps that are of it and the tilesets that give it an icon. + * + * @param iterable $maps + * @param array> $tilesets Tileset sources by id, as the Tilesets page holds them. + * @return list + */ + public static function describeTypeReferences(iterable $maps, array $tilesets, string $type): array + { + $found = []; + foreach ($maps as $map) { + try { + if (PlaceTypeCatalog::readMapPlaceType($map->getEditableData()) === $type) { + $found[] = sprintf('map %s', $map->mapId); + } + } catch (InvalidArgumentException) { + // A malformed type names no type; validation reports it on its map. + } + } + foreach ($tilesets as $id => $tileset) { + $icons = $tileset[\Ichiloto\Engine\Rendering\Tilesets\Tileset::PLACE_ICONS] ?? null; + if (is_array($icons) && array_key_exists($type, $icons)) { + $found[] = sprintf('tileset %s', $id); + } + } + + return $found; + } +} diff --git a/src/Field/PlayerPresentationFields.php b/src/Field/PlayerPresentationFields.php new file mode 100644 index 00000000..70ced00b --- /dev/null +++ b/src/Field/PlayerPresentationFields.php @@ -0,0 +1,105 @@ + 'Player Field Appearance', + fieldsFor: self::getFields(...), + requireUnchangedSource: true, + ); + } + + /** @return list */ + public static function getFields(array $data): array + { + $fields = [new RecordField('graphicalSubject', 'Graphical Subject', + options: [self::LEGACY, ...array_map(static fn(PlayerGraphicalSubject $mode): string => $mode->value, PlayerGraphicalSubject::cases())], + displayDefault: self::LEGACY)]; + $mode = array_key_exists('graphicalSubject', $data) ? $data['graphicalSubject'] : PlayerGraphicalSubject::FIXED_PLAYER->value; + if (! array_key_exists('graphicalSubject', $data) || $mode === PlayerGraphicalSubject::FIXED_PLAYER->value) { + $fields = [...$fields, ...CharacterSheetFields::getFields($data['sprites2d'] ?? null, 'sprites2d', 'Fixed Player Field Sheet')]; + } else { + // Inactive is not unowned: shape changes must retain legacy art. + $fields = [...$fields, ...array_map(static fn(RecordField $field): RecordField => $field->asReadOnly(), + CharacterSheetFields::getFields($data['sprites2d'] ?? null, 'sprites2d', 'Inactive Fixed Player Field Sheet'))]; + } + + return $fields; + } + + public static function describeFields(array $fields, array $data, ?string $assetRoot): array + { + $mode = array_key_exists('graphicalSubject', $data) ? $data['graphicalSubject'] : PlayerGraphicalSubject::FIXED_PLAYER->value; + if (array_key_exists('graphicalSubject', $data) && ! is_string($mode)) { + foreach ($fields as &$field) { + if (($field['field'] ?? null) === 'graphicalSubject') { + $field['value'] = '(invalid ' . get_debug_type($mode) . ' selector)'; + } + } + unset($field); + } + if ($mode === PlayerGraphicalSubject::FIXED_PLAYER->value) { + $fields = CharacterSheetFields::describeFields($fields, $data['sprites2d'] ?? null, 'sprites2d', $assetRoot, worldLayer: false); + } + $note = match ($mode) { + PlayerGraphicalSubject::PARTY_LEADER->value => 'Uses the selected party leader\'s actor images.field2d role. Missing leader art stays missing; no fixed-player substitute. Inactive fixed art is retained.', + PlayerGraphicalSubject::FIXED_PLAYER->value => 'Uses this player\'s fixed sheet. Actor roles and party order do not change it. Terminal appearance is independent.', + default => 'Invalid graphicalSubject: optional graphical presentation is refused. Choose a valid mode; no identity is substituted.', + }; + $fields[] = ['label' => 'Field Appearance Ownership', 'value' => $note, 'editable' => false]; + + return $fields; + } + + public static function applyField(array $data, string $field, string $value, string $assetRoot): array + { + if ($field === 'graphicalSubject') { + if ($value === self::LEGACY) { + unset($data[$field]); + } elseif (PlayerGraphicalSubject::tryFrom($value) === null) { + throw new InvalidArgumentException('Choose fixed-player or party-leader, or leave the selector absent for legacy fixed-player.'); + } else { + $data[$field] = $value; + } + } elseif (str_starts_with($field, 'sprites2d.')) { + $mode = array_key_exists('graphicalSubject', $data) ? $data['graphicalSubject'] : PlayerGraphicalSubject::FIXED_PLAYER->value; + if ($mode !== PlayerGraphicalSubject::FIXED_PLAYER->value) { + throw new InvalidArgumentException('Fixed player art is inactive; choose fixed-player before editing it.'); + } + $binding = CharacterSheetFields::applyField($data['sprites2d'] ?? null, substr($field, strlen('sprites2d.')), $value, $assetRoot, worldLayer: false); + if ($binding === null) { + unset($data['sprites2d']); + } else { + $data['sprites2d'] = $binding; + } + } else { + throw new InvalidArgumentException('Unknown player presentation field.'); + } + + return $data; + } +} diff --git a/src/Field/ProjectNpc.php b/src/Field/ProjectNpc.php index a3c44e4f..0f40a3e3 100644 --- a/src/Field/ProjectNpc.php +++ b/src/Field/ProjectNpc.php @@ -4,22 +4,31 @@ namespace Ichiloto\Editor\Field; +use Ichiloto\Editor\Dialogue\DialogueVariants; +use Ichiloto\Engine\Messaging\Dialogue\ConditionalDialogue; + /** * One map-local NPC, as the engine's `Field\Npc` will read it. * * The runtime contract, verbatim from `NpcManager::configure()`: `name`, * `x`, `y` required; `sprite` (default `@`); `movement` `fixed`|`wander`; + * `directionFix` (bool, default false: true keeps the heading when the + * player talks to it instead of turning to face the player); `facing` + * (north|east|south|west, default south: the heading it stands with); * optional `wanderArea` {x,y,width,height}, unbounded when omitted; - * `dialogue` (plain pages or conditional variants); `script` (event - * commands, which take precedence over dialogue when non-empty); + * `dialogue` (plain lines, or the NPC's interaction pages as the Engine's + * `NpcPages` reads them); `script` (event commands, which take precedence + * over dialogue when non-empty); `cue` (only beside plain lines or a + * script: pages carry their own); * `conditions` (visibility); `sets` (world writes after each conversation); * `id` (stable map-local identity, targeted by `move_route`); `sprites` * (directional glyphs, either case of key accepted on load). * * The payload is kept verbatim: a key this class does not know survives - * load, edit, save, and reload untouched. Identity is immutable after - * creation — nothing here renames an id, because doors and routes that name - * it would not follow. + * load, edit, save, and reload untouched. A field edit never changes the id: + * the editor gives an NPC a new id only through {@see withId()}, while + * nothing refers to it yet, because doors and routes that name an id would + * not follow. * * @package Ichiloto\Editor\Field */ @@ -29,8 +38,8 @@ final class ProjectNpc * The fields the runtime reads, in the order a fresh entry is written. */ public const array KNOWN_FIELDS = [ - 'id', 'name', 'sprite', 'x', 'y', 'movement', 'wanderArea', 'sprites', - 'conditions', 'dialogue', 'script', 'sets', + 'id', 'name', 'sprite', 'x', 'y', 'movement', 'facing', 'directionFix', 'wanderArea', 'sprites', + 'conditions', 'dialogue', 'script', 'sets', 'sprites2d', 'cue', ]; public const array MOVEMENTS = ['fixed', 'wander']; @@ -113,13 +122,18 @@ public function getVisibleSprite(): string /** * Returns a sprite as the terminal shows it: style tags stripped, - * measured the way the engine measures it, `@` when nothing is left. + * measured the way the engine measures it. An explicit empty sprite + * draws no overlay; style-only source retains the diagnostic fallback. * * @param string $sprite The sprite as authored. * @return string The visible glyph(s). */ public static function visibleGlyph(string $sprite): string { + if ($sprite === '') { + return ''; + } + $stripped = self::strippedGlyph($sprite); return $stripped === '' ? '@' : $stripped; @@ -252,13 +266,69 @@ public function getDialogue(): array */ public function hasDialogueVariants(): bool { - foreach ($this->getDialogue() as $entry) { - if (isset($entry['lines']) || isset($entry['script']) || isset($entry['conditions'])) { - return true; + return ConditionalDialogue::isVariantList($this->getDialogue()); + } + + /** + * Returns the NPC's pages as the editor edits them: plain lines folded + * into one page that also carries the NPC's cue, since the cue belongs + * to the page talking runs. An NPC whose top-level script replaces its + * dialogue keeps its cue with the script. + * + * @return array> The pages. + */ + public function getPages(): array + { + $pages = $this->getDialogueAsVariants(); + $cue = $this->payload['cue'] ?? null; + + if (is_array($cue) && $this->getScript() === [] && ! $this->hasDialogueVariants() && count($pages) === 1) { + $pages[0]['cue'] = $cue; + } + + return $pages; + } + + /** + * Writes pages back into an entry in their stored form: one page of + * plain lines (with at most a cue) is stored as plain lines, its cue on + * the NPC, so content authored that way is saved that way. Once the + * pages become a list, the cue {@see getPages()} copied onto the first + * page stays there alone. A top-level script keeps the NPC's cue. + * + * @param array $entry The entry, its other fields as edited. + * @param array $pages The pages. + * @return array The entry to store. + */ + public static function storePages(array $entry, array $pages): array + { + // A page that runs an action without lines keeps no empty line list. + $pages = array_map(static fn(array $page): array => ($page['lines'] ?? null) === [] + && array_intersect(array_keys($page), ['script', 'scriptId', 'cinematicId']) !== [] ? array_diff_key($page, ['lines' => true]) : $page, + array_values(array_filter($pages, is_array(...)))); + $only = $pages[0] ?? null; + $script = array_filter((array) ($entry['script'] ?? []), is_array(...)) !== []; + + if (! $script && count($pages) === 1 && array_diff(array_keys($only), ['lines', 'cue']) === []) { + if (is_array($only['cue'] ?? null)) { + $entry['cue'] = $only['cue']; + } else { + unset($entry['cue']); } + unset($pages[0]['cue']); + } elseif (! $script && is_array($entry['cue'] ?? null) && ($only['cue'] ?? null) === $entry['cue']) { + unset($entry['cue']); + } + + $dialogue = self::dialogueFromVariants($pages); + + if ($dialogue === []) { + unset($entry['dialogue']); + } else { + $entry['dialogue'] = $dialogue; } - return false; + return $entry; } /** @@ -273,17 +343,7 @@ public function hasDialogueVariants(): bool */ public function getDialogueAsVariants(): array { - $dialogue = $this->getDialogue(); - - if ($dialogue === []) { - return []; - } - - if ($this->hasDialogueVariants()) { - return $dialogue; - } - - return [['lines' => $dialogue]]; + return DialogueVariants::fold($this->getDialogue()); } /** @@ -295,18 +355,7 @@ public function getDialogueAsVariants(): array */ public static function dialogueFromVariants(array $variants): array { - $variants = array_values(array_filter($variants, is_array(...))); - - if (count($variants) === 1) { - $only = $variants[0]; - $extras = array_diff(array_keys($only), ['lines']); - - if ($extras === [] && is_array($only['lines'] ?? null)) { - return array_values($only['lines']); - } - } - - return $variants; + return DialogueVariants::unfold($variants); } /** @@ -392,6 +441,21 @@ public function movedTo(int $x, int $y): self * @param string $id The new id. * @return self The copy. */ + /** + * Returns a copy under another id, in the id's place, for an NPC whose + * name changed while nothing refers to it yet. + * + * @param string $id The new id. + * @return self The NPC under that id. + */ + public function withId(string $id): self + { + $payload = $this->payload; + $payload['id'] = $id; + + return new self($payload); + } + public function asCopyWithId(string $id): self { $payload = $this->payload; diff --git a/src/Field/RegionFields.php b/src/Field/RegionFields.php new file mode 100644 index 00000000..eaccb0ce --- /dev/null +++ b/src/Field/RegionFields.php @@ -0,0 +1,93 @@ + RegionCatalog::SCHEMA]), + fieldsFor: self::getFields(...), + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** A new region record, keyed by the region's name as its maps write it. */ + public static function makeBlank(string $name, string $projectRoot): array + { + $name = trim($name); + if (! RegionCatalog::isRegionName($name)) { + throw new RecordRefusal(sprintf('A region is named as its maps name it, in 1 to %d characters.', RegionCatalog::MAX_NAME_LENGTH)); + } + + return ['id' => $name]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + return [ + // Chosen from the region names the project's maps use, the way a map's own Region row is. + RecordField::reference('id', 'Region', 'map_regions'), + new RecordField(SkitBackdrop::MAP_KEY, 'Skit Backdrop', reference: 'png_assets', removeWhenEmpty: true, + allowsNone: true, displayDefault: self::BACKDROP_NONE), + ]; + } + + /** Refuses a region name the Engine would not accept as it is chosen. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'id' && ! RegionCatalog::isRegionName($entry['id'] ?? null)) { + throw new RecordRefusal(sprintf('A region is named as its maps name it, in 1 to %d characters.', RegionCatalog::MAX_NAME_LENGTH)); + } + + return $entry; + } + + /** Why the Engine would not read the catalogue as a save would write it, or null when it would. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + RegionCatalog::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/src/Field/TextRoleFields.php b/src/Field/TextRoleFields.php new file mode 100644 index 00000000..7fa88122 --- /dev/null +++ b/src/Field/TextRoleFields.php @@ -0,0 +1,207 @@ + ProjectTextCatalog::SCHEMA]), + fieldsFor: self::getFields(...), + subLists: [new RecordSubList(key: 'fonts', prefix: 'font', singular: 'font', + fields: [RecordField::reference('asset', 'Font File', 'font_assets'), + new RecordField('faceIndex', 'Face Index', InputControlType::INTEGER, removeWhenEmpty: true, displayDefault: '0')], + blank: ['asset' => ''], heading: 'Fonts, first choice first')], + saveCheck: self::checkCatalog(...), + prepareEdit: self::checkEdit(...), + requireUnchangedSource: true, + ); + } + + /** A new role, which saves at once as the Engine admits it: set in the project's first font. */ + public static function makeBlank(string $name, string $projectRoot): array + { + $fonts = ReferenceCatalog::getFontAssets($projectRoot); + if ($fonts === []) { + throw new RecordRefusal('A new text role starts on the project\'s first font. Add a font file (.ttf or .otf) under assets first.'); + } + $id = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($name)), '-'); + + return ['id' => preg_match('/^[a-z]/', $id) === 1 ? $id : 'role-' . $id, 'fonts' => [['asset' => $fonts[0]]], 'fontSize' => 12, + 'foreground' => ['kind' => 'rgb', 'r' => 255, 'g' => 255, 'b' => 255]]; + } + + /** @return list */ + public static function getFields(array $entry): array + { + return [ + new RecordField('id', 'Stable Id'), + new RecordField('fontSize', 'Font Size', InputControlType::FLOAT), + ...self::getColourFields($entry, 'foreground', 'Colour'), + ...self::getEffectFields($entry), + ]; + } + + /** + * A role's glyph effects, as the Engine's plain descriptor holds them: an outline and a shadow, each with its + * colour. Effects written as constructed PHP values are shown and kept as authored, never rewritten here. + * + * @return list + */ + private static function getEffectFields(array $entry): array + { + $effects = $entry['glyphEffects'] ?? null; + if ($effects !== null && ! is_array($effects)) { + return [new RecordField('glyphEffects', 'Glyph Effects', isReadOnly: true)]; + } + $choice = new RecordField('effects', 'Glyph Effects', options: [self::NO_EFFECTS, self::OUTLINE_AND_SHADOW], + displayDefault: $effects === null ? self::NO_EFFECTS : self::OUTLINE_AND_SHADOW); + if ($effects === null) { + return [$choice]; + } + + return [ + $choice, + new RecordField('glyphEffects.outline.width', 'Outline Width (0..4 px)', InputControlType::FLOAT), + ...self::getColourFields($entry, 'glyphEffects.outline.color', 'Outline Colour'), + new RecordField('glyphEffects.shadow.offsetX', 'Shadow Offset X (-8..8 px)', InputControlType::FLOAT), + new RecordField('glyphEffects.shadow.offsetY', 'Shadow Offset Y (-8..8 px)', InputControlType::FLOAT), + new RecordField('glyphEffects.shadow.sigma', 'Shadow Blur (0..4)', InputControlType::FLOAT), + new RecordField('glyphEffects.shadow.opacity', 'Shadow Opacity (0..1)', InputControlType::FLOAT), + ...self::getColourFields($entry, 'glyphEffects.shadow.color', 'Shadow Colour'), + ]; + } + + /** + * A colour's kind and the components that kind takes. + * + * @return list + */ + private static function getColourFields(array $entry, string $path, string $label): array + { + $kind = self::readPath($entry, $path)['kind'] ?? 'rgb'; + + return [ + new RecordField($path . '.kind', $label . ' Kind', options: self::COLOUR_KINDS), + ...($kind === 'rgb' + ? [new RecordField($path . '.r', $label . ' Red (0..255)', InputControlType::INTEGER), + new RecordField($path . '.g', $label . ' Green (0..255)', InputControlType::INTEGER), + new RecordField($path . '.b', $label . ' Blue (0..255)', InputControlType::INTEGER)] + : [new RecordField($path . '.index', $label . ' Palette Index', InputControlType::INTEGER)]), + ]; + } + + /** A role's id and colour kind are checked and reshaped as they are typed, by the Engine's own rules. */ + public static function checkEdit(array $entry, string $field): array + { + if ($field === 'id') { + try { + ProjectTextCatalog::assertRoleId($entry['id'] ?? null); + } catch (Throwable $error) { + throw new RecordRefusal($error->getMessage(), previous: $error); + } + } + if ($field === 'effects') { + // The descriptor holds an outline and a shadow together, so effects come and go whole. + $choice = $entry['effects'] ?? null; + unset($entry['effects']); + if ($choice === self::OUTLINE_AND_SHADOW && ! is_array($entry['glyphEffects'] ?? null)) { + $entry['glyphEffects'] = self::makeEffects(); + } elseif ($choice === self::NO_EFFECTS) { + unset($entry['glyphEffects']); + } + } + foreach (self::COLOUR_PATHS as $path) { + if ($field === $path . '.kind') { + $colour = self::readPath($entry, $path); + $kind = $colour['kind'] ?? 'rgb'; + $light = $path === 'foreground'; + $entry = self::writePath($entry, $path, $kind === 'rgb' + ? ['kind' => 'rgb', 'r' => $light ? 255 : 0, 'g' => $light ? 255 : 0, 'b' => $light ? 255 : 0] + : ['kind' => $kind, 'index' => $colour['index'] ?? ($light ? 15 : 0)]); + } + } + + return $entry; + } + + /** Glyph effects as they first appear: no outline, and a hard one-pixel shadow down and to the right, in black. */ + private static function makeEffects(): array + { + return ['outline' => ['width' => 0, 'color' => ['kind' => 'rgb', 'r' => 0, 'g' => 0, 'b' => 0]], + 'shadow' => ['offsetX' => 1, 'offsetY' => 1, 'sigma' => 0, 'opacity' => 1, + 'color' => ['kind' => 'rgb', 'r' => 0, 'g' => 0, 'b' => 0]]]; + } + + /** @return array The value at a dotted path, or none. */ + private static function readPath(array $entry, string $path): array + { + $value = $entry; + foreach (explode('.', $path) as $key) { + $value = is_array($value) ? $value[$key] ?? null : null; + } + + return is_array($value) ? $value : []; + } + + private static function writePath(array $entry, string $path, array $value): array + { + [$key, $rest] = array_pad(explode('.', $path, 2), 2, null); + $entry[$key] = $rest === null ? $value : self::writePath(is_array($entry[$key] ?? null) ? $entry[$key] : [], $rest, $value); + + return $entry; + } + + /** The Engine's own admission of the whole catalogue, so a save never writes a file the game refuses. */ + public static function checkCatalog(array $whole, string $projectRoot): ?string + { + try { + ProjectTextCatalog::fromArray($whole); + } catch (Throwable $error) { + return $error->getMessage(); + } + + return null; + } +} diff --git a/src/Field/WorldObjectAuthoring.php b/src/Field/WorldObjectAuthoring.php new file mode 100644 index 00000000..5ead2dd2 --- /dev/null +++ b/src/Field/WorldObjectAuthoring.php @@ -0,0 +1,678 @@ +getMapDataField([WorldObjectDefinition::DATA_KEY]) ?? []; + if (!is_array($entries) || !array_is_list($entries) || array_any($entries, static fn($entry): bool => !is_array($entry))) { + throw new InvalidArgumentException('worldObjects requires literal object records in a list; it cannot be flattened.'); + } + return $entries; + } + + /** + * The attachments a map's world object offers: those its resource declares, as the map's field catalogue holds + * them, with the object's anchor that their cells are relative to. Null for an inline object, an unknown one or + * a map or catalogue that cannot be read; validation reports why. + * + * @return array{anchor: array{x: int, y: int}, attachments: list<\Ichiloto\Engine\Field\WorldObjectAttachment>}|null + */ + public static function findAttachments(ProjectMap $map, string $objectId): ?array + { + try { + $entry = array_find(self::readEntries($map), static fn(array $entry): bool => ($entry['id'] ?? null) === $objectId); + $resource = is_string($entry['resource'] ?? null) ? $map->getFieldCatalog()->getResource($entry['resource']) : null; + } catch (InvalidArgumentException|MapSourceRefusal) { + return null; + } + $anchor = $entry['anchor'] ?? null; + if ($resource === null || !is_int($anchor['x'] ?? null) || !is_int($anchor['y'] ?? null)) { + return null; + } + return ['anchor' => ['x' => $anchor['x'], 'y' => $anchor['y']], 'attachments' => $resource->attachments]; + } + + /** + * The glyph and tile coverage of a map's world objects at rest, as the field applies it: an object covers its + * cells only while it can be drawn, so its base role's current frame resolves, or its sign board can be read. + * An object without art covers its cells by design. Validation uses it to judge what the field still shows as + * glyphs; a map whose objects cannot be read covers nothing, as its own validation reports why. + * + * @return array>>> + */ + public static function readRestingCoverage(ProjectMap $map): array + { + try { + $definitions = self::validateEntries($map, self::readEntries($map)); + } catch (InvalidArgumentException|MapSourceRefusal) { + return []; + } + $coverage = []; + foreach ($definitions as $object) { + $sign = $object->resource?->sign; + if ($sign !== null) { + try { + $sign->getBoard($map->getAssetRoot()); + } catch (Throwable) { + continue; + } + } elseif ($object->sprites !== null) { + $role = new FieldSpriteRole($object->sprites, $map->getAssetRoot(), 'World-object coverage ' . $object->id); + try { + if ($role->getFrame($object->pivot) === null) { + continue; + } + } finally { + $role->release(); + } + } + $coverage = array_replace_recursive($coverage, $object->coverage); + } + + return $coverage; + } + + public static function validateEntries(ProjectMap $map, array $entries, bool $complete = true): array + { + $owners = array_column($map->describeTileLayers()['layers'], 'owner', 'name'); + if (!$complete) { + // Empty conditions are an unfinished authoring draft, never a runtime default. + foreach ($entries as &$entry) { + // A placement of a named resource has no variants of its own to finish. + if (array_key_exists('resource', $entry)) { continue; } + $variants = $entry['variants'] ?? []; + if (!is_array($variants) || !array_is_list($variants) || count($variants) > WorldObjectDefinition::MAX_VARIANTS) { + throw new InvalidArgumentException('World-object variants exceed the Engine list contract.'); + } + $ids = []; + foreach ($variants as $variant) { + if (!is_array($variant) || array_diff(array_keys($variant), ['id', 'conditions', 'sprites2d', 'terminal']) !== [] + || !array_key_exists('sprites2d', $variant) || !array_key_exists('conditions', $variant)) { + throw new InvalidArgumentException('A variant requires id, conditions and explicit sprites2d.'); + } + $checked = WorldObjectDefinition::readMap(['worldObjects' => [[ + 'id' => $variant['id'] ?? null, 'anchor' => $entry['anchor'], 'pivot' => $entry['pivot'], 'sprites2d' => $variant['sprites2d'], + // A footprint colour recolours the parent's covered glyphs, so the variant is checked with them. + ...(isset($entry['covers']) ? ['covers' => $entry['covers']] : []), + ...(array_key_exists('terminal', $variant) ? ['terminal' => $variant['terminal']] : []), + ]]], $map->getLayerSet(), tileOwners: $owners); + $id = $checked[0]->id; + if (isset($ids[$id])) { throw new InvalidArgumentException('Duplicate world-object variant id.'); } + $ids[$id] = true; + } + $entry['variants'] = array_values(array_filter($entry['variants'] ?? [], static fn($variant): bool => ($variant['conditions'] ?? null) !== [])); + } + unset($entry); + } + return WorldObjectDefinition::readMap([WorldObjectDefinition::DATA_KEY => $entries], $map->getLayerSet(), tileOwners: $owners, + catalog: self::loadCatalog($map, $entries)); + } + + /** + * The project's named field resources, read as the Engine reads them when a map names one: only then, so a map + * of inline objects never depends on the catalogue. + */ + private static function loadCatalog(ProjectMap $map, array $entries): ?FieldPresentationCatalog + { + return array_any($entries, static fn($entry): bool => is_array($entry) && array_key_exists('resource', $entry)) + ? $map->getFieldCatalog() : null; + } + + public function getSourceIssue(): ?string + { + return self::readSourceIssue($this->map); + } + + public static function readSourceIssue(ProjectMap $map): ?string + { + try { + $document = PhpArraySourceDocument::parse($map->captureLayerSnapshot()['source']); + $node = $document->root()->entryFor(WorldObjectDefinition::DATA_KEY)?->value; + $isLiteral = static function (SourceNode $node) use (&$isLiteral): bool { + if ($node->kind === SourceNode::SCALAR) { return true; } + return $node->kind === SourceNode::ARRAY && !$node->hasOpaqueKey + && array_all($node->entries, static fn($entry): bool => $isLiteral($entry->value)); + }; + return $node === null || $isLiteral($node) ? null : 'World-object edits require literal declarations; expressions and spread arrays are preserved, not rewritten.'; + } catch (\RuntimeException $error) { return $error->getMessage(); } + } + + public function getRecords(): ProjectRecordDatabase + { + $entries = self::readEntries($this->map); + $variants = new RecordSubList(key: 'variants', prefix: 'variant', singular: 'variant', fields: [], + blank: ['id' => $this->getUnusedId(array_merge(...array_map(static fn($entry): array => $entry['variants'] ?? [], $entries)), 'variant'), + 'conditions' => [], 'sprites2d' => null], heading: 'Ordered Variants', removeWhenEmpty: true, + getFields: static fn(array $entry): array => [new RecordField('id', 'Variant Id'), + new RecordField('conditions', 'When (all conditions)', codec: RecordFieldCodec::CONDITIONS), + ...FieldSpriteFields::getFields($entry, true), ...self::getTerminalFields($entry, true)], + prepareEdit: fn(array $entry, string $field): array => FieldSpriteFields::prepareEdit($this->prepareTerminalEdit($entry, $field), $field, true)); + $cells = new RecordSubList(key: 'covers.cells', prefix: 'cell', singular: 'covered cell', + fields: [new RecordField('x', 'X', InputControlType::INTEGER), new RecordField('y', 'Y', InputControlType::INTEGER)], + blank: ['x' => 0, 'y' => 0], heading: 'Covered Cells'); + $signs = self::readSignResources($this->map); + $schema = new RecordSchema(key: 'world_objects', entryNoun: 'world object', storage: RecordStorage::MAP_OWNED, + relativePath: '', fields: [], labelKey: 'id', + fieldsFor: function (array $entry) use ($signs): array { + // A placement of a named resource takes its art and pivot from the resource; it chooses which one, and a + // sign placement the text its board shows: a translatable message, and the text where none is given. + // An object with its own art may become a placement by choosing a resource, and a placement choosing + // none goes back to its own art. + $resource = new RecordField('resource', 'Field Resource', reference: 'field_resources', allowsNone: true, + removeWhenEmpty: true, displayDefault: '(its own art)'); + $presentation = array_key_exists('resource', $entry) + ? [$resource, + ...(isset($signs[$entry['resource']]) ? [new RecordField('label.default', 'Sign Text'), + new RecordField('label.message', 'Sign Text Translation Key')] : [])] + : [$resource, + new RecordField('pivot.x', 'Image Pivot X (0..1)', InputControlType::FLOAT), + new RecordField('pivot.y', 'Image Pivot Y (0..1)', InputControlType::FLOAT), + ...FieldSpriteFields::getFields($entry, true)]; + $fields = [new RecordField('id', 'Stable Map-local Id', isReadOnly: true), + new RecordField('anchor.x', 'Anchor X', InputControlType::INTEGER), + new RecordField('anchor.y', 'Anchor Y', InputControlType::INTEGER), + // Every placement, resource or inline, may show only while saved world state holds; none means always. + new RecordField('conditions', 'Visible When (all conditions)', removeWhenEmpty: true, + codec: RecordFieldCodec::CONDITIONS, displayDefault: '(always)'), + ...$presentation, + ...self::getTerminalFields($entry, false), + new RecordField('covers.layer', 'Covered Gameplay Layer', options: ['', ...array_column(array_filter($this->map->getLayers(), + static fn($layer): bool => !$layer['decoration'] && $layer['id'] !== \Ichiloto\Editor\Maps\MapLayers::EVENT), 'name')], + removeWhenEmpty: true, displayDefault: '(no coverage)')]; + if (isset($entry['covers'])) { + $fields[] = new RecordField('covers.tileLayers', 'Covered Tile Layers', codec: RecordFieldCodec::CSV_LIST, + reference: 'world_object_tile_layers', allowsNone: true); + } + return $fields; + }, + subLists: [$variants, $cells], + subListsFor: static fn(array $entry): array => [...(array_key_exists('resource', $entry) ? [] : ['variants']), + ...(isset($entry['covers']) ? ['covers.cells'] : [])], + prepareEdit: function (array $entry, string $field) use ($signs): array { + if ($field === 'resource') { + // A sign placement needs its label and only a sign accepts one; the board's name starts it. + $sign = $signs[$entry['resource'] ?? ''] ?? null; + if ($sign === null) { unset($entry['label']); } + else { $entry['label'] ??= self::makeLabel((string) $entry['id'], $sign); } + // A placement's art and pivot are its resource's; without one, the object holds its own, as a new one does. + if (array_key_exists('resource', $entry)) { + unset($entry['pivot'], $entry['sprites2d'], $entry['variants']); + } else { + $entry['pivot'] ??= ['x' => .5, 'y' => 1.0]; + $entry['sprites2d'] ??= null; + } + } + if ($field === 'covers.layer') { + if (!isset($entry['covers']['layer'])) { unset($entry['covers']); } + else { + $entry['covers']['cells'] ??= [['x' => $entry['anchor']['x'], 'y' => $entry['anchor']['y']]]; + $entry['covers']['tileLayers'] ??= []; + } + } + return FieldSpriteFields::prepareEdit($this->prepareTerminalEdit($entry, $field), $field, true); + }); + foreach ($entries as &$entry) { + if (isset($entry['covers'])) { + $entry['covers']['cells'] = array_map(static fn($cell): array => ['x' => $cell[0], 'y' => $cell[1]], $entry['covers']['cells']); + } + $entry = self::readTerminalChoice($entry, false); + foreach ($entry['variants'] ?? [] as $index => $variant) { $entry['variants'][$index] = self::readTerminalChoice($variant, true); } + } + unset($entry); + $records = ProjectRecordDatabase::overOwnedList($schema, $this->map->dataPath, $entries, static function (): void {}, graphical: true); + $records->useAuthoringReferences($this->references); + return $records; + } + + /** How an object or a variant presents in the Terminal, as an author chooses it. */ + private const string TERMINAL_MAP_GLYPH = "map's own glyph"; + private const string TERMINAL_INHERIT = "the object's"; + private const string TERMINAL_SYMBOL = 'a symbol at the anchor'; + private const string TERMINAL_COLOUR = 'its covered glyphs recoloured'; + /** The terminal's named colours; an authored colour outside them stays selectable as written. */ + private const array TERMINAL_COLOURS = ['white', 'yellow', 'red', 'green', 'blue', 'magenta', 'cyan', 'gray', 'black', + 'bright-white', 'bright-yellow', 'bright-red', 'bright-green', 'bright-blue', 'bright-magenta', 'bright-cyan']; + + /** + * The Terminal presentation as the record shows it: which kind it is, and its symbol or colour. An object without + * one shows the map's glyph; a variant without one shows its object's, and an explicit null the map's glyph again. + * Stored data is written back exactly ({@see writeTerminalChoice()}): a string symbol, or `['color' => ...]`. + */ + private static function readTerminalChoice(array $entry, bool $variant): array + { + $exists = array_key_exists('terminal', $entry); + $terminal = $entry['terminal'] ?? null; + $entry['terminal'] = match (true) { + !$exists => ['mode' => $variant ? self::TERMINAL_INHERIT : self::TERMINAL_MAP_GLYPH], + is_string($terminal) => ['mode' => self::TERMINAL_SYMBOL, 'symbol' => $terminal], + is_array($terminal) => ['mode' => self::TERMINAL_COLOUR, 'color' => (string) ($terminal['color'] ?? '')], + default => ['mode' => self::TERMINAL_MAP_GLYPH], + }; + return $entry; + } + + private static function writeTerminalChoice(array $entry, bool $variant): array + { + $choice = $entry['terminal'] ?? null; + unset($entry['terminal']); + if (!is_array($choice) || !isset($choice['mode'])) { return $entry; } + return match ($choice['mode']) { + self::TERMINAL_SYMBOL => [...$entry, 'terminal' => (string) ($choice['symbol'] ?? '')], + self::TERMINAL_COLOUR => [...$entry, 'terminal' => ['color' => (string) ($choice['color'] ?? '')]], + // Inheriting is the absence of the key; a variant's map glyph is its explicit null. + self::TERMINAL_MAP_GLYPH => $variant ? [...$entry, 'terminal' => null] : $entry, + default => $entry, + }; + } + + /** @return list */ + private static function getTerminalFields(array $entry, bool $variant): array + { + $choice = is_array($entry['terminal'] ?? null) ? $entry['terminal'] : []; + $modes = $variant ? [self::TERMINAL_INHERIT, self::TERMINAL_MAP_GLYPH, self::TERMINAL_SYMBOL, self::TERMINAL_COLOUR] + : [self::TERMINAL_MAP_GLYPH, self::TERMINAL_SYMBOL, self::TERMINAL_COLOUR]; + $fields = [new RecordField('terminal.mode', 'Terminal Shows', options: $modes)]; + if (($choice['mode'] ?? null) === self::TERMINAL_SYMBOL) { + $fields[] = new RecordField('terminal.symbol', 'Terminal Symbol (one styled cell)'); + } + if (($choice['mode'] ?? null) === self::TERMINAL_COLOUR) { + $colour = (string) ($choice['color'] ?? ''); + $fields[] = new RecordField('terminal.color', 'Terminal Colour', + options: array_values(array_unique([...self::TERMINAL_COLOURS, ...($colour === '' ? [] : [$colour])]))); + } + return $fields; + } + + /** + * A new kind starts from what the cell shows now: the anchor's own glyph as the symbol, and white as the colour, + * the Terminal's ordinary text; the author changes either at once. + */ + private function prepareTerminalEdit(array $entry, string $field): array + { + if ($field !== 'terminal.mode') { return $entry; } + $mode = $entry['terminal']['mode'] ?? null; + if ($mode === self::TERMINAL_SYMBOL && !isset($entry['terminal']['symbol'])) { + $anchor = $entry['anchor'] ?? null; + $glyph = is_array($anchor) ? ($this->map->getLayerSet()->getComposedGrid()[$anchor['y']][$anchor['x']] ?? ' ') : ' '; + $glyph = \Ichiloto\Engine\IO\Console\TerminalText::stripAnsi($glyph); + $entry['terminal']['symbol'] = '' . (trim($glyph) === '' ? '*' : $glyph) . ''; + } + if ($mode === self::TERMINAL_COLOUR && !isset($entry['terminal']['color'])) { $entry['terminal']['color'] = 'white'; } + return $entry; + } + + /** + * Creates an object at a cell: an inline one with no art yet, or a placement of a named resource. A sign + * placement is given the text its board shows, under a translation key of its own id. + */ + public function createObject(string $id, int $x, int $y, ?string $resource = null, ?string $text = null): ?Command + { + $entries = self::readEntries($this->map); + if ($resource === null) { + if ($text !== null) { throw new InvalidArgumentException('Only a sign placement shows text.'); } + $entries[] = ['id' => $id, 'anchor' => ['x' => $x, 'y' => $y], 'pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null]; + return $this->writeEntries($entries, 'Create world object'); + } + $sign = self::readSignResources($this->map)[$resource] ?? null; + $entry = ['id' => $id, 'resource' => $resource, 'anchor' => ['x' => $x, 'y' => $y]]; + if ($sign !== null) { + if ($text === null || trim($text) === '') { throw new InvalidArgumentException('A sign needs the text it shows.'); } + $entry['label'] = ['message' => self::getLabelMessage($id), 'default' => $text]; + } elseif ($text !== null) { + throw new InvalidArgumentException('Only a sign placement shows text.'); + } + $entries[] = $entry; + return $this->writeEntries($entries, $sign === null ? 'Place field resource' : 'Place sign'); + } + + /** A sign placement's translation key: its own map-local id under signs, so translators find each sign. */ + public static function getLabelMessage(string $id): string + { + return 'signs.' . $id; + } + + private static function makeLabel(string $id, string $name): array + { + return ['message' => self::getLabelMessage($id), 'default' => $name]; + } + + /** + * The project's sign resources, by id, with their names: the ones a placement labels. A catalogue that cannot be + * read offers none; validation says why. + * + * @return array + */ + private static function readSignResources(ProjectMap $map): array + { + try { + $catalog = $map->getFieldCatalog(); + } catch (\Throwable) { + return []; + } + $signs = []; + foreach ($catalog->resources as $id => $resource) { + if ($resource->sign !== null) { $signs[(string) $id] = $resource->name; } + } + return $signs; + } + + public function deleteObject(string $id): ?Command + { + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + $this->assertObjectUnreferenced($id); + array_splice($entries, $index, 1); + $command = $this->writeEntries($entries, 'Delete world object'); + if ($command === null) { return null; } + return new GenericCommand('Delete world object', function () use ($id, $command): void { + $this->assertObjectUnreferenced($id); + $command->execute(); + }, static fn() => $command->undo()); + } + + private function assertObjectUnreferenced(string $id): void + { + $references = $this->references->describeWorldObjectReferences($this->map, $id); + if ($references !== []) { + throw new MapSourceRefusal("Cannot delete world object {$id}; references or unproven authored sources remain:\n" . implode("\n", $references)); + } + } + + public function getIndex(string $id): int + { + foreach (self::readEntries($this->map) as $index => $entry) { if (($entry['id'] ?? null) === $id) { return $index; } } + throw new InvalidArgumentException('That map-local world object no longer exists.'); + } + + public function applyField(string $id, string $field, string $value): ?Command + { + $records = $this->getRecords(); + $index = $this->getIndex($id); + $fields = $records->getSettingsFields($index); + $row = array_find($fields, static fn($row): bool => ($row['field'] ?? null) === $field); + if ($row === null || ($row['editable'] ?? true) === false) { throw new InvalidArgumentException('Read the current editable world-object row before changing it.'); } + if (isset($row['options']) && !in_array($value, $row['options'], true)) { throw new InvalidArgumentException('Choose a listed value.'); } + if (($row['reference'] ?? null) === 'png_assets' && $value !== '') { + if (!in_array($value, $this->references->valuesFor('png_assets'), true)) { throw new InvalidArgumentException('Choose a current project PNG.'); } + \Ichiloto\Engine\Rendering\Sprites\PngAssetPreflight::inspect($this->map->getAssetRoot(), $value); + } + if (($row['reference'] ?? null) === 'world_object_tile_layers') { + $owner = self::readEntries($this->map)[$index]['covers']['layer']; + $choices = array_column(array_filter($this->map->describeTileLayers()['layers'], static fn($layer): bool => $layer['owner'] === $owner), 'name'); + foreach (array_filter(array_map(trim(...), explode(',', $value))) as $selected) { + if (!in_array($selected, $choices, true)) { throw new InvalidArgumentException('Choose a tile layer owned by the covered gameplay layer.'); } + } + } + (new RecordAuthoring())->applyField($records, $index, [], $field, $value, $row['label']); + return $this->writeRecords($records, 'Edit world object'); + } + + public function changeItem(string $id, string $field, bool $remove): ?Command + { + $records = $this->getRecords(); + $index = $this->getIndex($id); + $item = $records->locateItem($index, [], $field); + if (!$remove && $item?->listKey === 'covers.cells') { + $entries = self::readEntries($this->map); + $object = $entries[$index]; + $claimed = []; + foreach ($entries as $entry) { + if (($entry['covers']['layer'] ?? null) === $object['covers']['layer']) { + array_push($claimed, ...$entry['covers']['cells']); + } + } + for ($y = 0; $y < $this->map->getHeight(); $y++) { + for ($x = 0; $x < $this->map->getWidth(); $x++) { + if (!in_array([$x, $y], $claimed, true)) { + $entries[$index]['covers']['cells'][] = [$x, $y]; + return $this->writeEntries($entries, 'Add covered cell'); + } + } + } + throw new InvalidArgumentException('No unclaimed cell remains on the covered gameplay layer.'); + } + $authoring = new RecordAuthoring(); + $remove ? $authoring->removeItem($records, $index, [], $field) : $authoring->addItem($records, $index, [], $field); + return $this->writeRecords($records, $remove ? 'Remove world-object item' : 'Add world-object item'); + } + + public function moveVariant(string $id, string $variantId, int $offset): ?Command + { + if (!in_array($offset, [-1, 1], true)) { throw new InvalidArgumentException('Move one variant up or down.'); } + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + $variants = $entries[$index]['variants'] ?? []; + $from = array_search($variantId, array_column($variants, 'id'), true); + if ($from === false) { throw new InvalidArgumentException('Choose a known variant to reorder.'); } + $to = $from + $offset; + if ($to < 0 || $to >= count($variants)) { return null; } + [$variants[$from], $variants[$to]] = [$variants[$to], $variants[$from]]; + $entries[$index]['variants'] = $variants; + return $this->writeEntries($entries, 'Reorder world-object variants'); + } + + /** Canvas operations use the same complete declaration and history boundary as rows. */ + public function placeObject(string $id, int $x, int $y, bool $coverage = false): ?Command + { + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + if ($coverage) { + if (!isset($entries[$index]['covers'])) { throw new InvalidArgumentException('Select a covered gameplay layer first.'); } + $cells = $entries[$index]['covers']['cells']; + $at = array_search([$x, $y], $cells, true); + if ($at === false) { $cells[] = [$x, $y]; } else { array_splice($cells, $at, 1); } + $entries[$index]['covers']['cells'] = $cells; + } else { $entries[$index]['anchor'] = ['x' => $x, 'y' => $y]; } + return $this->writeEntries($entries, $coverage ? 'Toggle world-object coverage' : 'Place world-object anchor'); + } + + /** + * Moves an object's anchor and the cells it covers by a step, keeping its + * shape. The glyphs on those cells are the map's to move with it + * ({@see EditorSession::moveWorldObject()}). + * + * @throws InvalidArgumentException When the object is unknown, or the step leaves the map or reaches cells another object covers. + */ + public function moveObject(string $id, int $dx, int $dy): ?Command + { + $entries = self::readEntries($this->map); + $index = $this->getIndex($id); + $moved = static fn(array $cell): array => [$cell[0] + $dx, $cell[1] + $dy]; + $entries[$index]['anchor'] = ['x' => $entries[$index]['anchor']['x'] + $dx, 'y' => $entries[$index]['anchor']['y'] + $dy]; + if (isset($entries[$index]['covers'])) { + $layer = $entries[$index]['covers']['layer']; + $cells = array_map($moved, $entries[$index]['covers']['cells']); + foreach ($entries as $other => $entry) { + if ($other === $index || ($entry['covers']['layer'] ?? null) !== $layer) { + continue; + } + $taken = array_values(array_filter($cells, static fn(array $cell): bool => in_array($cell, $entry['covers']['cells'], true))); + if ($taken !== []) { + throw new InvalidArgumentException(sprintf('%d, %d on the %s layer is covered by world object %s; move it first.', + $taken[0][0], $taken[0][1], $layer, $entry['id'])); + } + } + $entries[$index]['covers']['cells'] = $cells; + } + foreach ([$entries[$index]['anchor'], ...array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1]], + $entries[$index]['covers']['cells'] ?? [])] as $cell) { + if ($cell['x'] < 0 || $cell['y'] < 0 || $cell['x'] >= $this->map->getWidth() || $cell['y'] >= $this->map->getHeight()) { + throw new InvalidArgumentException(sprintf('Moving %s by %d, %d reaches %d, %d, outside the map.', $id, $dx, $dy, $cell['x'], $cell['y'])); + } + } + + return $this->writeEntries($entries, 'Move world object'); + } + + private function writeRecords(ProjectRecordDatabase $records, string $label): ?Command + { + $entries = array_map(static fn($record): array => (array) $record->toArray(), $records->getRecords()); + foreach ($entries as &$entry) { + if (isset($entry['covers'])) { $entry['covers']['cells'] = array_map(static fn($cell): array => [$cell['x'], $cell['y']], $entry['covers']['cells']); } + $entry = self::writeTerminalChoice($entry, false); + foreach ($entry['variants'] ?? [] as $index => $variant) { $entry['variants'][$index] = self::writeTerminalChoice($variant, true); } + } + unset($entry); + return $this->writeEntries($entries, $label); + } + + /** + * Writes the map's world objects, and on a map that declares its own + * collision, the footprints their resources stamp ({@see planFootprints()}), + * as one undo step. + */ + private function writeEntries(array $entries, string $label): ?Command + { + $before = $this->map->getMapDataField([WorldObjectDefinition::DATA_KEY]); + if ($before === $entries) { return null; } + self::validateEntries($this->map, $entries, false); + if (($issue = $this->getSourceIssue()) !== null) { throw new MapSourceRefusal($issue); } + $footprints = $this->planFootprints(is_array($before) ? $before : [], $entries); + $map = $this->map; + $occupancyBefore = $footprints === [] ? null : $map->getMapDataField([MapPhysicalOccupancy::DATA_KEY]); + $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries); + if ($occupancyBefore === null) { + return new GenericCommand($label, + static fn() => $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries), + static fn() => $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $before)); + } + try { + $map->writePhysicalOccupancy($footprints); + } catch (MapSourceRefusal $refusal) { + $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $before); + throw $refusal; + } + $occupancyAfter = $map->getMapDataField([MapPhysicalOccupancy::DATA_KEY]); + return new GenericCommand($label, + static function () use ($map, $entries, $occupancyAfter): void { + $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries); + $map->setMapDataField([MapPhysicalOccupancy::DATA_KEY], $occupancyAfter); + }, + static function () use ($map, $before, $occupancyBefore): void { + $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $before); + $map->setMapDataField([MapPhysicalOccupancy::DATA_KEY], $occupancyBefore); + }); + } + + /** + * The collision writes that keep each world object's resource footprint + * (its occupancy stamp, an authoring recipe the Engine never reads at run + * time) in the map's own declared collision as objects are made, moved, + * changed or deleted. A cell a footprint leaves goes back to plain floor + * when it still holds what the footprint stamped and no other object + * stamps it; a cell a footprint takes must be plain floor, as a tree the + * tree brush plants must, so leaving it later gives back exactly that + * floor. Maps that resolve collision from their glyphs keep none. + * + * @param list> $before + * @param list> $after + * @return list + * @throws MapSourceRefusal When a footprint would take a cell that is not plain floor; nothing is changed. + */ + private function planFootprints(array $before, array $after): array + { + if (! $this->map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + return []; + } + $left = $this->readFootprints($before); + $taken = $this->readFootprints($after); + if ($left === $taken) { + return []; + } + // The declared cells as authored, which footprints write; never what glyphs or terrain kinds resolve to. + $collision = array_map(static fn(mixed $row): array => array_map( + static fn(mixed $cell): ?int => $cell instanceof CollisionType ? $cell->value : null, (array) $row), + (array) $this->map->getMapDataField([MapPhysicalOccupancy::DATA_KEY])); + $writes = []; + foreach ($left as $key => [$x, $y, $type]) { + if (! isset($taken[$key]) && ($collision[$y][$x] ?? null) === $type->value) { + $writes[] = [$x, $y, CollisionType::NONE]; + } + } + foreach ($taken as $key => [$x, $y, $type, $id]) { + if (($left[$key][2] ?? null) === $type) { + continue; + } + $current = $collision[$y][$x] ?? null; + if ($current !== CollisionType::NONE->value && ! isset($left[$key])) { + throw new MapSourceRefusal(sprintf('World object %s stamps %s on %d, %d, which is %s, not plain floor. ' + . 'An object\'s footprint stands on plain floor, so moving or removing it gives that floor back; ' + . 'choose another place or paint the cell\'s collision first. Nothing was changed.', + $id, $type->name, $x, $y, CollisionType::tryFrom((int) $current)?->name ?? 'outside the map')); + } + $writes[] = [$x, $y, $type]; + } + + return $writes; + } + + /** + * Each cell the objects' resources stamp, with its collision and the + * object stamping it first. Objects without a known resource stamp none. + * + * @param list> $entries + * @return array + */ + private function readFootprints(array $entries): array + { + $catalog = $this->map->getFieldCatalog(); + $cells = []; + foreach ($entries as $entry) { + $anchor = $entry['anchor'] ?? null; + if (! is_string($entry['resource'] ?? null) || ! is_int($anchor['x'] ?? null) || ! is_int($anchor['y'] ?? null)) { + continue; + } + try { + $stamp = $catalog->getResource($entry['resource'])->occupancyStamp; + } catch (InvalidArgumentException) { + continue; + } + foreach ($stamp as $cell) { + [$x, $y] = [$anchor['x'] + $cell['x'], $anchor['y'] + $cell['y']]; + $cells["{$x},{$y}"] ??= [$x, $y, $cell['type'], (string) ($entry['id'] ?? $entry['resource'])]; + } + } + + return $cells; + } + + private function getUnusedId(array $entries, string $stem): string + { + $ids = array_column($entries, 'id'); + for ($number = 1; ; $number++) { if (!in_array($stem . '-' . $number, $ids, true)) { return $stem . '-' . $number; } } + } +} diff --git a/src/Field/WorldObjectReferences.php b/src/Field/WorldObjectReferences.php new file mode 100644 index 00000000..c7c1884e --- /dev/null +++ b/src/Field/WorldObjectReferences.php @@ -0,0 +1,155 @@ + $id]); + $scripts = []; + $cinematics = $this->workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? []; + $knownFiles = array_merge([], ...array_map(static fn($asset): array => $asset->paths(), $cinematics)); + foreach (glob($this->workspace->projectRoot . '/' . CutsceneType::CINEMATIC->relativeRoot() . '/*/*.php') ?: [] as $path) { + if (!in_array($path, $knownFiles, true)) { $found[] = $path . ': authored source is unavailable in the current catalog; reload before deleting a subject'; } + } + $document = PhpArraySourceDocument::parse($map->captureLayerSnapshot()['source']); + $loaded = ['events' => $map->getEventDefinitions(), + 'npcs' => array_map(static fn($npc): array => $npc->toArray(), array_values($map->getNpcs()->all()))]; + foreach (['events', 'npcs'] as $key) { + self::describeOpaque($document->root()->entryFor($key)?->value, $map->dataPath . ':' . $key, $found, ['value' => $loaded[$key]]); + } + foreach ($map->getEventDefinitions() as $marker => $event) { + self::describeTree($event, $id, $map->mapId . ' event ' . $marker, $found, $scripts); + } + foreach ($map->getNpcs()->all() as $npc) { + self::describeTree($npc->toArray(), $id, $map->mapId . ' NPC ' . ($npc->getId() ?? $npc->getName()), $found, $scripts); + } + foreach ($cinematics as $asset) { + if ($asset->isDeleted()) { continue; } + $label = 'cinematic ' . $asset->id; + try { + $asset->assertSourcesUnchanged(); + $state = $asset->captureEditState(); + $dataSource = $state['sourceTemplates']['data']['source'] ?? file_get_contents($asset->dataPath()); + $data = PhpArraySourceDocument::parse((string) $dataSource); + $start = $data->root()->entryFor('startMap')?->value; + if ($start === null || $start->kind !== SourceNode::SCALAR) { + $found[] = $label . ': startMap ownership is not safely provable'; + continue; + } + $commands = $asset->commands(); + if (($asset->data()['startMap'] ?? null) !== $map->mapId && !self::hasTransferTo($commands, $map->mapId)) { continue; } + foreach (['cast', 'finalizer'] as $key) { + self::describeOpaque($data->root()->entryFor($key)?->value, $asset->dataPath() . ':' . $key, $found, + array_key_exists($key, $asset->data()) ? ['value' => $asset->data()[$key]] : null); + self::describeTree($asset->data()[$key] ?? [], $id, $label . '.' . $key, $found, $scripts); + } + $source = $state['sourceTemplates'][$asset->type->partnerNoun()]['source'] ?? file_get_contents($asset->partnerPath()); + self::describeOpaque(PhpArraySourceDocument::parse((string) $source)->root(), $asset->partnerPath(), $found, ['value' => $commands]); + self::describeTree($commands, $id, $label . '.commands', $found, $scripts); + } catch (Throwable $error) { + $found[] = $label . ': opaque authored source; references cannot be proved (' . $error->getMessage() . ')'; + } + } + $database = $this->workspace->getRecordDatabase('common_events'); + $visited = []; + while (($pending = array_diff_key($scripts, $visited)) !== []) { + foreach ($pending as $scriptId => $via) { + $visited[$scriptId] = true; + $record = array_find($database?->getRecords() ?? [], static fn($record): bool => ($record->toArray()['__scriptId'] ?? null) === $scriptId); + $label = 'event script ' . $scriptId . ' (via ' . $via . ')'; + if ($record === null) { $found[] = $label . ': references are unavailable'; continue; } + try { + $database->assertUnchangedSource(); + $source = file_get_contents($record->sourcePath ?? $database->backingFilePath()); + self::describeOpaque($root = PhpArraySourceDocument::parse((string) $source)->root(), $label, $found, + self::readLoadedRecord($record, $database->schema, $root)); + self::describeTree((array) $record->toArray(), $id, $label, $found, $scripts); + } catch (Throwable $error) { + $found[] = $label . ': authored references cannot be proved (' . $error->getMessage() . ')'; + } + } + } + return array_values(array_unique($found)); + } + + private static function describeTree(mixed $tree, string $id, string $path, array &$found, array &$scripts): void + { + if (!is_array($tree)) { return; } + if (($tree['kind'] ?? null) === 'world_object' && ($tree['id'] ?? null) === $id) { $found[] = $path; } + $script = ($tree['type'] ?? null) === 'common_event' ? ($tree['id'] ?? null) : ($tree['scriptId'] ?? null); + if (is_string($script) && $script !== '') { $scripts[$script] = $path; } + foreach ($tree as $key => $value) { self::describeTree($value, $id, $path . '.' . $key, $found, $scripts); } + } + + /** + * Reports authored source a reference cannot be proved absent from. + * Literal arrays and scalars are read as written. An expression or a + * variable (an enum case's value, a script included with `require`, a + * helper the file defines) is proved by what it loaded as: when that + * value is plain data, scalars and arrays alone, the loaded definitions + * the references are read from hold all of it. A loaded value holding a + * closure or another object may yield more when the game runs it, and a + * node whose loaded value cannot be found is never proved. + * + * @param array{value: mixed}|null $loaded What the node loaded as, when known. + */ + private static function describeOpaque(?SourceNode $node, string $path, array &$found, ?array $loaded): void + { + if ($node === null || $node->kind === SourceNode::SCALAR) { return; } + if ($node->kind !== SourceNode::ARRAY && $loaded !== null && self::isPlainData($loaded['value'])) { return; } + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { + $found[] = $path . ': opaque authored source; subject references cannot be proved'; + return; + } + $value = $loaded['value'] ?? null; + foreach ($node->entries as $index => $entry) { + $key = $entry->key ?? $index; + self::describeOpaque($entry->value, $path . '.' . $key, $found, + is_array($value) && array_key_exists($key, $value) ? ['value' => $value[$key]] : null); + } + } + + /** + * What a record's own file loaded as, to set beside that file's source: + * an event script's bare command list, which the record wraps under its + * list key, or the record itself. A record read from a file shared with + * others has no source of its own to set it beside. + * + * @return array{value: mixed}|null + */ + private static function readLoadedRecord(ProjectRecord $record, RecordSchema $schema, SourceNode $root): ?array + { + if ($record->sourcePath === null) { return null; } + if ($schema->listPayloadKey === null) { return ['value' => $record->toArray()]; } + // The wrapped list is re-indexed, so only a list written without keys lines up with its source. + return $root->kind === SourceNode::ARRAY && $root->isList && !$root->hasOpaqueKey + ? ['value' => $record->toArray()[$schema->listPayloadKey] ?? null] : null; + } + + private static function isPlainData(mixed $value): bool + { + return is_array($value) ? array_all($value, self::isPlainData(...)) : !is_object($value) && !is_resource($value); + } + + private static function hasTransferTo(array $tree, string $mapId): bool + { + return (($tree['type'] ?? null) === 'transfer' && ($tree['map'] ?? null) === $mapId) + || array_any($tree, static fn($value): bool => is_array($value) && self::hasTransferTo($value, $mapId)); + } +} diff --git a/src/FieldPresentationSettings.php b/src/FieldPresentationSettings.php new file mode 100644 index 00000000..244742fa --- /dev/null +++ b/src/FieldPresentationSettings.php @@ -0,0 +1,118 @@ + $default) { + $settings[self::WAYMARKS . '.' . $key] = ['default' => $default]; + } + $style = new FieldCueStyle(); + foreach (['bob.height' => $style->bobHeight, 'bob.seconds' => $style->bobSeconds, + 'pointerSpacing.x' => $style->pointerSpacingX, 'pointerSpacing.y' => $style->pointerSpacingY, + 'overlap.opacity' => $style->overlapOpacity, 'overlap.seconds' => $style->overlapSeconds, + 'terminal.background' => $style->terminalBackground, + 'terminal.bold' => $style->terminalBold] as $key => $default) { + $settings[self::CUE_STYLE . '.' . $key] = ['default' => $default]; + } + foreach (array_keys(is_array($this->payload[self::CUES] ?? null) ? $this->payload[self::CUES] : []) as $color) { + $settings[self::CUES . ".{$color}.plate"] = ['default' => null]; + foreach (FieldPresentationCatalog::DIRECTIONS as $direction) { + $settings[self::CUES . ".{$color}.pointer.{$direction}.asset"] = ['default' => null]; + $settings[self::CUES . ".{$color}.pointer.{$direction}.quarterTurns"] = ['default' => 0]; + } + } + + return $settings; + } + + /** A plate, a background or a pointer chosen away is no key at all, so the Engine's own treatment applies. */ + protected function isRemovedWhenEmpty(string $path): bool + { + return str_starts_with($path, self::CUES . '.') && (str_ends_with($path, '.asset') || str_ends_with($path, '.plate')); + } + + /** + * Refuses a value the Engine would not accept beside the catalog as it + * stands: the whole would-be catalog is read by the Engine's own reader, + * images included. + * + * @throws \InvalidArgumentException + */ + public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void + { + if (! in_array(explode('.', $path)[0], [self::WAYMARKS, self::CUE_STYLE, self::CUES], true)) { return; } + $payload = $this->readPendingPayload([$path => $value]); + foreach ($payload[self::CUES] ?? [] as $color => $binding) { + // A pointer is authored a part at a time; one still without an image is not yet part of the catalog. + foreach (is_array($binding['pointer'] ?? null) ? $binding['pointer'] : [] as $direction => $image) { + if (! is_array($image) || ($image['asset'] ?? null) === null) { unset($payload[self::CUES][$color]['pointer'][$direction]); } + } + if (($payload[self::CUES][$color]['pointer'] ?? null) === []) { unset($payload[self::CUES][$color]['pointer']); } + } + FieldPresentationCatalog::fromArray($payload, $this->projectRoot . '/assets'); + } + + /** + * Splits each cue colour's old baked edge images into pointer images: the + * same directions and turns, each image replaced through `$assets` (old + * edge image => its pointer-only image). The source keeps its comments + * and every other entry. Returns whether anything changed. + * + * @param array $assets + */ + public function replaceEdgesWithPointers(array $assets): bool + { + return $this->rewritePayload(function (array $payload) use ($assets): array { + foreach (is_array($payload[self::CUES] ?? null) ? $payload[self::CUES] : [] as $color => $binding) { + if (! is_array($binding) || ! is_array($binding['edges'] ?? null)) { continue; } + $pointer = []; + foreach ($binding['edges'] as $direction => $image) { + $asset = (string) ($image['asset'] ?? ''); + $pointer[$direction] = ['asset' => $assets[$asset] ?? throw new \InvalidArgumentException( + "No pointer image was given for {$asset}."), 'quarterTurns' => (int) ($image['quarterTurns'] ?? 0)]; + } + unset($binding['edges']); + $payload[self::CUES][$color] = [...$binding, 'pointer' => $pointer]; + } + FieldPresentationCatalog::fromArray($payload, $this->projectRoot . '/assets'); + return $payload; + }); + } +} diff --git a/src/History/AssetFileCommand.php b/src/History/AssetFileCommand.php new file mode 100644 index 00000000..8675fd7a --- /dev/null +++ b/src/History/AssetFileCommand.php @@ -0,0 +1,34 @@ +plan->apply(); + } + + public function undo(): void + { + $this->plan->revert(); + } +} diff --git a/src/History/CommandGroup.php b/src/History/CommandGroup.php new file mode 100644 index 00000000..27ada079 --- /dev/null +++ b/src/History/CommandGroup.php @@ -0,0 +1,43 @@ + $commands The commands, in the order they were made. + */ + public function __construct( + public readonly string $label, + private readonly array $commands, + ) { + } + + /** + * @inheritDoc + */ + public function execute(): void + { + foreach ($this->commands as $command) { + $command->execute(); + } + } + + /** + * @inheritDoc + */ + public function undo(): void + { + foreach (array_reverse($this->commands) as $command) { + $command->undo(); + } + } +} diff --git a/src/History/CommandHistory.php b/src/History/CommandHistory.php index 9a5f0405..94f7c5b8 100644 --- a/src/History/CommandHistory.php +++ b/src/History/CommandHistory.php @@ -23,6 +23,9 @@ final class CommandHistory */ private array $redoStack = []; + /** How many of the oldest steps were dropped to keep within the capacity. */ + private int $dropped = 0; + /** * @param int $capacity The maximum number of retained undo entries. */ @@ -43,6 +46,7 @@ public function record(Command $command): void if (count($this->undoStack) > max(1, $this->capacity)) { array_shift($this->undoStack); + $this->dropped++; } } @@ -53,13 +57,14 @@ public function record(Command $command): void */ public function undo(): ?Command { - $command = array_pop($this->undoStack); + $command = end($this->undoStack); if (! $command instanceof Command) { return null; } $command->undo(); + array_pop($this->undoStack); $this->redoStack[] = $command; return $command; @@ -72,13 +77,14 @@ public function undo(): ?Command */ public function redo(): ?Command { - $command = array_pop($this->redoStack); + $command = end($this->redoStack); if (! $command instanceof Command) { return null; } $command->execute(); + array_pop($this->redoStack); $this->undoStack[] = $command; return $command; @@ -114,6 +120,15 @@ public function count(): int return count($this->undoStack); } + /** + * Counts the oldest steps dropped so far to keep within the capacity, so a + * caller undoing back to a point can tell whether every step since is still here. + */ + public function countDropped(): int + { + return $this->dropped; + } + /** * Empties both stacks (workspace reloads, map identity changes). * diff --git a/src/History/PaintStrokeCommand.php b/src/History/PaintStrokeCommand.php index 3398870a..09d6bd2e 100644 --- a/src/History/PaintStrokeCommand.php +++ b/src/History/PaintStrokeCommand.php @@ -131,9 +131,11 @@ public function targets(ProjectMap $map): bool */ public function execute(): void { - foreach ($this->cells as $cell) { - $this->applyCell($cell['x'], $cell['y'], $cell['new'], $cell['newPrefix'], $cell['newSuffix']); + if ($this->cells === []) { + return; } + $this->map->setLayerCells($this->layer, array_map(static fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], + 'symbol' => $cell['new'], 'prefix' => $cell['newPrefix'], 'suffix' => $cell['newSuffix']], $this->cells)); } /** @@ -141,28 +143,10 @@ public function execute(): void */ public function undo(): void { - foreach ($this->cells as $cell) { - $this->applyCell($cell['x'], $cell['y'], $cell['old'], $cell['oldPrefix'], $cell['oldSuffix']); - } - } - - /** - * Writes one cell back onto the stroke's layer. - * - * @param int $x The cell x coordinate. - * @param int $y The cell y coordinate. - * @param string $symbol The symbol to apply. - * @param string $prefix The styling prefix bytes (tile layer only). - * @param string $suffix The styling suffix bytes (tile layer only). - * @return void - */ - private function applyCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void - { - if ($this->layer === self::LAYER_EVENT) { - $this->map->setEventSymbol($x, $y, $symbol); + if ($this->cells === []) { return; } - - $this->map->setTileCell($x, $y, $symbol, $prefix, $suffix); + $this->map->setLayerCells($this->layer, array_map(static fn(array $cell): array => ['x' => $cell['x'], 'y' => $cell['y'], + 'symbol' => $cell['old'], 'prefix' => $cell['oldPrefix'], 'suffix' => $cell['oldSuffix']], $this->cells)); } } diff --git a/src/History/SourceSetCommand.php b/src/History/SourceSetCommand.php new file mode 100644 index 00000000..ca64dd7f --- /dev/null +++ b/src/History/SourceSetCommand.php @@ -0,0 +1,69 @@ +assertWorkspace($this->before); + $this->plan->apply(); + try { + $this->after ??= ProjectWorkspace::fromProject($this->before->projectRoot); + } catch (Throwable $failure) { + $this->plan->revert(); + throw $failure; + } + ($this->replaceWorkspace)($this->after); + } + + public function undo(): void + { + $this->assertWorkspace($this->after); + $this->plan->revert(); + ($this->replaceWorkspace)($this->before); + } + + private function assertWorkspace(?ProjectWorkspace $expected): void + { + $current = ($this->getWorkspace)(); + if ($current === null || $current !== $expected) { + throw new RuntimeException("The workspace changed since {$this->subject}. Reload before trying again."); + } + if ($current->hasUnsavedChanges()) { + throw new RuntimeException("Save or undo pending edits before changing {$this->subject}. No files were changed."); + } + } +} diff --git a/src/History/SourceSetRequired.php b/src/History/SourceSetRequired.php new file mode 100644 index 00000000..d5f15812 --- /dev/null +++ b/src/History/SourceSetRequired.php @@ -0,0 +1,38 @@ + $paths The files the set writes, relative to the project root. + */ + public function __construct( + public readonly SourceSetPlan $plan, + public readonly string $label, + public readonly string $subject, + public readonly string $question, + public readonly array $paths, + ) { + parent::__construct($question); + } +} diff --git a/src/History/TracksPersistedState.php b/src/History/TracksPersistedState.php index 2961ceb5..ed698cd8 100644 --- a/src/History/TracksPersistedState.php +++ b/src/History/TracksPersistedState.php @@ -34,6 +34,14 @@ trait TracksPersistedState private string $fingerprintedVersion = ''; private string $currentFingerprint = ''; + /** + * The fingerprint of what this asset holds now, saved or not: two + * readings of one asset with the same fingerprint hold the same content. + */ + public string $contentFingerprint { + get => $this->stateFingerprint(); + } + /** * Builds the exact persisted form this asset's save would write. * @@ -84,6 +92,18 @@ public function stateVersion(): int return $this->stateGeneration; } + /** + * Returns a key that changes whenever this asset's content may have, + * its children's included, so a caller can tell whether a step touched + * it without fingerprinting anything. + * + * @return string The key. + */ + public function getContentVersion(): string + { + return $this->stateGeneration . '|' . $this->dependencyVersion(); + } + /** * Returns the combined version of children this asset's payload embeds. * @@ -108,7 +128,7 @@ protected function dependencyVersion(): string */ protected function stateFingerprint(): string { - $version = $this->stateGeneration . '|' . $this->dependencyVersion(); + $version = $this->getContentVersion(); if ($this->fingerprintedVersion !== $version) { $this->currentFingerprint = sha1($this->buildPersistedPayload()); diff --git a/src/IO/InputRouter.php b/src/IO/InputRouter.php index 2e382458..10df0c84 100644 --- a/src/IO/InputRouter.php +++ b/src/IO/InputRouter.php @@ -207,7 +207,7 @@ public function route(string $input): void $active = $this->modals->active(); // 1. Safety modals consume everything, including global shortcuts. - if ($active !== null && $active->priority() <= Modal::RENAME_CONFIRMATION->priority()) { + if ($active !== null && $active->priority() <= Modal::LAYER_EDIT->priority()) { $this->dispatchModal($active, $input, $normalized); return; } diff --git a/src/Inspector/InspectorListEdit.php b/src/Inspector/InspectorListEdit.php new file mode 100644 index 00000000..6de29be0 --- /dev/null +++ b/src/Inspector/InspectorListEdit.php @@ -0,0 +1,19 @@ + $details */ + public function __construct(string $message, public readonly array $details = [], ?Throwable $previous = null) + { + parent::__construct($message, 0, $previous); + } +} diff --git a/src/Inspector/MapAreaFields.php b/src/Inspector/MapAreaFields.php new file mode 100644 index 00000000..f179c911 --- /dev/null +++ b/src/Inspector/MapAreaFields.php @@ -0,0 +1,196 @@ +> */ + public static function describeMapFields(ProjectMap $map): array + { + try { + $areas = MapAreaAuthoring::describeAreas($map); + } catch (MapAreaRefusal $error) { + return [['label' => 'Areas', 'value' => '', 'editable' => false], + ['label' => ' ! Read-only', 'value' => $error->getMessage(), 'editable' => false]]; + } + $fields = [['label' => sprintf('Areas · %d', count($areas)), 'value' => count($areas) === 0 ? 'Select cells on the map to make one' : '', + 'editable' => false, 'target' => self::TARGET]]; + foreach ($areas as $index => $area) { + // Entries are removed here and made on the map, from selected cells. + $key = ['target' => self::TARGET, 'index' => $index, 'areaList' => ['index' => $index], 'listAddsOnMap' => true]; + $references = MapAreaAuthoring::describeReferences($map, $area['id']); + array_push($fields, + ['label' => ' Area', 'value' => $area['id'], 'control' => new InputControl(InputControlType::TEXT, $area['id']), + 'field' => self::ID_FIELD, ...$key], + ['label' => ' Cells', 'value' => self::describeCells($area['area'], $area['cells']), 'editable' => false, 'field' => 'cells', + 'areaCells' => $area['cells'], ...$key], + ['label' => ' Named By', 'value' => $references === [] ? 'nothing' : implode('; ', $references), 'editable' => false, + 'field' => 'references', ...$key], + ); + } + + return $fields; + } + + /** + * An event's subject row: its own painted cells, or a world object or + * area of the map whose cells it covers. An event without a definition + * has nothing to bind. + * + * @return list> + */ + public static function describeSubjectFields(ProjectMap $map, string $marker): array + { + if ($map->getEventDefinition($marker) === null) { + return []; + } + $subject = $map->getEventDefinition($marker)['subject'] ?? null; + $current = is_array($subject) && is_string($subject['kind'] ?? null) && is_string($subject['id'] ?? null) + ? self::encodeSubject($subject['kind'], $subject['id']) : self::UNBOUND; + $choices = [['label' => 'Its own cells', 'value' => self::UNBOUND, 'description' => 'It triggers where its marker is painted.']]; + try { + foreach (WorldObjectAuthoring::readEntries($map) as $entry) { + if (is_string($entry['id'] ?? null)) { + $choices[] = ['label' => 'World object ' . $entry['id'], 'value' => self::encodeSubject(WorldObjectAuthoring::SUBJECT_KIND, $entry['id']), + 'description' => 'It moves with the object.']; + } + } + foreach (MapAreaAuthoring::readEntries($map) as $entry) { + if (is_string($entry['id'] ?? null)) { + $choices[] = ['label' => 'Area ' . $entry['id'], 'value' => self::encodeSubject(MapAreaAuthoring::SUBJECT_KIND, $entry['id']), + 'description' => 'It covers the area\'s cells.']; + } + } + } catch (InvalidArgumentException|MapAreaRefusal $error) { + return [['label' => 'Bound To', 'value' => '! ' . $error->getMessage(), 'editable' => false]]; + } + + return [['label' => 'Bound To', 'value' => $current, 'editable' => true, 'choices' => $choices, + 'options' => array_column($choices, 'value'), 'target' => self::SUBJECT_TARGET, 'marker' => $marker]]; + } + + /** + * Applies one row's edit: an area's new id, which its events and + * landmarks follow, or an event's subject. Choosing its own cells unbinds + * an event onto the cells its subject gave it. Null when nothing changed. + * + * @param array $field + * @throws InspectorRefusal When the edit cannot be made. + */ + public static function applyValue(ProjectMap $map, array $field, string $value): ?Command + { + try { + if (($field['target'] ?? null) === self::SUBJECT_TARGET) { + return self::applySubject($map, (string) $field['marker'], $value); + } + $area = self::readArea($map, $field); + + return ($field['field'] ?? null) === self::ID_FIELD ? new MapAreaAuthoring($map)->renameArea($area, trim($value)) : null; + } catch (MapAreaRefusal|EventRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Deletes the area a row belongs to. + * + * @param array $field + * @throws InspectorRefusal When something names the area. + */ + public static function removeEntry(ProjectMap $map, array $field): InspectorListEdit + { + try { + $id = self::readArea($map, $field); + + return new InspectorListEdit(new MapAreaAuthoring($map)->deleteArea($id), sprintf('Deleted area %s.', $id)); + } catch (MapAreaRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** A subject choice's value: its kind, a colon, then its id. */ + public static function encodeSubject(string $kind, string $id): string + { + return $kind . ':' . $id; + } + + /** + * What an author reads for an area's cells: a rectangle by its corner and + * size, exact cells by their count and how many places they fall in. + * + * @param array $shape + * @param list $cells + */ + public static function describeCells(array $shape, array $cells): string + { + if (! array_key_exists('cells', $shape)) { + return sprintf('x %d, y %d · %d x %d', $shape['x'] ?? 0, $shape['y'] ?? 0, $shape['width'] ?? 0, $shape['height'] ?? 0); + } + $pieces = count(CellArea::fromCells($cells)->findPieces()); + + return sprintf('%d %s in %d %s', count($cells), count($cells) === 1 ? 'cell' : 'cells', $pieces, $pieces === 1 ? 'place' : 'places'); + } + + /** + * @throws MapAreaRefusal|EventRefusal + */ + private static function applySubject(ProjectMap $map, string $marker, string $value): ?Command + { + if ($value === self::UNBOUND) { + if (! array_key_exists('subject', $map->getEventDefinition($marker) ?? [])) { + return null; + } + $cells = $map->getEventTriggerArea($marker)?->cells + ?? throw new EventRefusal(sprintf('Event %s has no cells of its subject to keep; paint its marker, then unbind it.', $marker)); + + return EventAuthoring::unbindEventSubject($map, $marker, $cells); + } + [$kind, $id] = array_pad(explode(':', $value, 2), 2, ''); + + return EventAuthoring::bindEventSubject($map, $marker, $kind, $id); + } + + /** + * @param array $field + * @throws MapAreaRefusal When the row's area is gone. + */ + private static function readArea(ProjectMap $map, array $field): string + { + $entries = MapAreaAuthoring::readEntries($map); + $index = (int) ($field['areaList']['index'] ?? $field['index'] ?? -1); + + return is_string($entries[$index]['id'] ?? null) ? $entries[$index]['id'] + : throw new MapAreaRefusal('That area is no longer on the map; read the map again.'); + } +} diff --git a/src/Inspector/MapAtlasFields.php b/src/Inspector/MapAtlasFields.php new file mode 100644 index 00000000..8913bb19 --- /dev/null +++ b/src/Inspector/MapAtlasFields.php @@ -0,0 +1,138 @@ +> */ + public static function describeMapFields(ProjectMap $map, ReferenceCatalog $references): array + { + try { + $block = MapAtlas::readBlock($map); + } catch (InvalidArgumentException $error) { + return [['label' => 'Atlas', 'value' => '', 'editable' => false], + ['label' => ' ! Read-only', 'value' => $error->getMessage(), 'editable' => false]]; + } + $scope = is_string($block['scope'] ?? null) ? $block['scope'] : MapAtlas::LOCAL; + $choices = [['label' => 'Local', 'value' => MapAtlas::LOCAL, 'description' => 'A place inside the world: a town, a house, a dungeon.'], + ['label' => 'Global', 'value' => MapAtlas::GLOBAL, 'description' => 'The overworld geography itself; it has no parent.']]; + $fields = [ + ['label' => 'Atlas', 'value' => '', 'editable' => false], + // A short enum: picked or stepped through. + ['label' => ' Scope', 'value' => $scope, 'editable' => true, 'choices' => $choices, + 'options' => array_column($choices, 'value'), 'target' => self::TARGET, 'field' => 'scope'], + ]; + try { + $view = MapAtlas::readTerminalView($block); + } catch (InvalidArgumentException $error) { + $view = null; + $fields[] = ['label' => ' ! Problem', 'value' => $error->getMessage(), 'editable' => false]; + } + if ($view !== null) { + $explained = ['places' => 'Simplified terrain and buildings from the map\'s Terrain classifications, with a square for each location and a chevron for each route.', + 'floorplan' => 'Rooms and corridors drawn as simple outlines, for interiors.']; + $known = array_map(static fn(string $value): array => ['label' => ucfirst($value), 'value' => $value, + 'description' => $explained[$value] ?? ''], MapAtlasDefinition::TERMINAL_VIEWS); + // A short enum: picked or stepped through. What the Terminal draws; graphical maps are unaffected. + $fields[] = ['label' => ' Terminal View', 'value' => $view, 'editable' => true, 'choices' => $known, + 'options' => array_column($known, 'value'), 'target' => self::TARGET, 'field' => 'terminalView']; + } + if ($scope !== MapAtlas::GLOBAL) { + $parent = is_array($block['parent'] ?? null) ? $block['parent'] : null; + $current = $parent === null ? '' : MapAtlas::encodeParent((string) ($parent['map'] ?? ''), (string) ($parent['landmark'] ?? '')); + $fields[] = ['label' => ' Parent Entry', 'value' => $current, 'editable' => true, 'target' => self::TARGET, 'field' => 'parent', + 'choices' => self::describeParentChoices($map, $references, $current)]; + } + $fields[] = ['label' => ' Global Map', 'value' => self::describeGlobal($map, $block, $references), 'editable' => false]; + return $fields; + } + + /** + * The atlas block an edit to one of these rows leaves, or null when it changes nothing. + * + * @param array $field + * @return array|null + */ + public static function applyValue(ProjectMap $map, array $field, mixed $value): ?array + { + $block = MapAtlas::readBlock($map); + $next = match ((string) ($field['field'] ?? '')) { + 'scope' => MapAtlas::setScope($block, (string) $value), + 'terminalView' => MapAtlas::setTerminalView($block, (string) $value), + 'parent' => MapAtlas::setParent($block, ...(MapAtlas::decodeParent((string) $value) ?? [null, null])), + default => $block, + }; + return $next === $block ? null : $next; + } + + /** + * Every declared landmark on every other map, those whose transfer leads to this map first: an entrance here is + * usually the parent entry. A landmark the map names that no longer exists stays listed, said so. + * + * @return list + */ + private static function describeParentChoices(ProjectMap $map, ReferenceCatalog $references, string $current): array + { + $leading = $others = []; + foreach ($references->listMaps() as $other) { + if ($other->mapId === $map->mapId) { continue; } + try { + $entries = MapLandmarks::readMap($other)->entries; + } catch (InvalidArgumentException) { + continue; + } + foreach ($entries as $entry) { + $id = $entry['id'] ?? null; + if (!is_string($id) || preg_match(MapLandmarkCatalog::ID_PATTERN, $id) !== 1) { continue; } + $marker = $entry['anchor']['event'] ?? null; + $leads = $marker !== null + && (($other->getEventDefinition((string) $marker)['data'] ?? [])['destinationMap'] ?? null) === $map->mapId; + $choice = ['label' => sprintf('%s · %s', $other->getDisplayName(), is_string($entry['name']['default'] ?? null) ? $entry['name']['default'] : $id), + 'value' => MapAtlas::encodeParent($other->mapId, $id), + 'description' => sprintf('%s, landmark %s%s', $other->mapId, $id, $leads ? '; its door leads here' : '')]; + $leads ? $leading[] = $choice : $others[] = $choice; + } + } + $choices = [['label' => '(none)', 'value' => '', 'description' => 'No map is above this one.'], ...$leading, ...$others]; + if ($current !== '' && !in_array($current, array_column($choices, 'value'), true)) { + $choices[] = ['label' => $current . ' · no longer declared', 'value' => $current, 'description' => 'Choose an existing entry or none.']; + } + return $choices; + } + + /** Where the chain reaches the global map, as the Engine resolves it, or why it cannot. */ + private static function describeGlobal(ProjectMap $map, array $block, ReferenceCatalog $references): string + { + try { + $global = MapAtlas::resolveGlobal($map, $block, $references->findMap(...), $references->getMapCategoryCatalog()); + } catch (Throwable $error) { + return '! ' . $error->getMessage(); + } + return match (true) { + $global === null => '(none above · set a parent entry)', + $global['landmark'] === null => '(this map)', + default => sprintf('%s at %s', $global['map'], $global['landmark']), + }; + } +} diff --git a/src/Inspector/MapInspector.php b/src/Inspector/MapInspector.php new file mode 100644 index 00000000..a5f1cf5e --- /dev/null +++ b/src/Inspector/MapInspector.php @@ -0,0 +1,2931 @@ + 'Unclassified (clear kind)', 'value' => '', 'description' => 'Omit kind, preserving legacy unclassified guidance and the rest of the cue.'], + ['label' => 'Story', 'value' => EventCueKind::STORY->value, 'description' => 'Story guidance that persists at the screen edge.'], + ['label' => 'Route', 'value' => EventCueKind::ROUTE->value, 'description' => 'Route guidance that belongs to its map cell.'], + ]; + + /** Where the player stands to start an event, as an author picks it. */ + public const array EVENT_ENGAGEMENT_CHOICES = [ + ['label' => 'Stand on it', 'value' => 'inside', 'description' => 'The player starts it from one of its cells, as events always have.'], + ['label' => 'Face it from next to it', 'value' => 'facing', 'description' => 'Placed on an object: the player starts it from the next cell, facing the object.'], + ]; + + /** A chest event's presentations, as an author picks one. */ + public const array CHEST_TYPE_CHOICES = [ + ['label' => 'Common', 'value' => ChestType::COMMON->value, 'description' => 'Standard chest presentation for ordinary treasure.'], + ['label' => 'Rare', 'value' => ChestType::RARE->value, 'description' => 'Highlights a chest that should feel less common.'], + ['label' => 'Epic', 'value' => ChestType::EPIC->value, 'description' => 'Marks a chest carrying high-value treasure.'], + ['label' => 'Legendary', 'value' => ChestType::LEGENDARY->value, 'description' => 'Reserved for the most special chest rewards.'], + ]; + + /** + * Every loot type as an author reads it. Only the ones the Engine's chest + * can give are offered ({@see listLootTypeChoices()}). + */ + private const array LOOT_TYPE_CHOICES = [ + ['label' => 'Item', 'value' => LootType::ITEM->value, 'description' => 'Rewards an item from the project item database.'], + ['label' => 'Gold', 'value' => LootType::GOLD->value, 'description' => 'Awards currency directly when the chest is opened.'], + ['label' => 'Experience', 'value' => LootType::EXPERIENCE->value, 'description' => 'Awards experience directly when claimed.'], + ['label' => 'Skill', 'value' => LootType::SKILL->value, 'description' => 'Rewards a learnable skill identifier.'], + ['label' => 'Spell', 'value' => LootType::SPELL->value, 'description' => 'Rewards a spell identifier.'], + ['label' => 'Weapon', 'value' => LootType::WEAPON->value, 'description' => 'Rewards a weapon identifier.'], + ['label' => 'Armor', 'value' => LootType::ARMOR->value, 'description' => 'Rewards an armor identifier.'], + ['label' => 'Accessory', 'value' => LootType::ACCESSORY->value, 'description' => 'Rewards an accessory identifier.'], + ]; + + /** + * The loot types an author picks a chest's from: the kinds the Engine's + * chest can give, so a new choice never makes a chest that fails to open. + * + * @return list + */ + public static function listLootTypeChoices(): array + { + return array_values(array_filter( + self::LOOT_TYPE_CHOICES, + static fn(array $choice): bool => ChestLoot::isGrantable(LootType::from($choice['value'])), + )); + } + + /** + * @param ReferenceCatalog $references The project's references as the map sees them. + * @param bool $graphical Whether the editor shows graphical presentation, and so offers it. + */ + public function __construct(private ReferenceCatalog $references, private bool $graphical = false) + { + } + + /** + * The map's own rows: identity, kind, size, counts, music and encounters. + * + * @return array> + */ + public function getMapFields(ProjectMap $map): array + { + return [ + [ + 'label' => 'Name', + 'value' => $map->getDisplayName(), + 'control' => new InputControl(InputControlType::TEXT, $map->getDisplayName()), + 'target' => 'map', + 'field' => 'name', + ], + // Chosen from the region names the project's maps use, never typed. + [ + 'label' => 'Region', + 'value' => $map->getRegion(), + 'selectedValue' => $map->getRegion(), + 'reference' => 'map_regions', + 'target' => 'map', + 'field' => 'region', + ], + $this->buildMapRoleField($map), + $this->buildPlaceTypeField($map), + $this->buildSkitBackdropField($map), + $this->buildMapKindField($map), + [ + 'label' => 'Description', + 'value' => $map->getDescription(), + 'control' => new InputControl(InputControlType::TEXT, $map->getDescription()), + 'target' => 'map', + 'field' => 'description', + ], + [ + 'label' => 'Size', + 'value' => '', + 'editable' => false, + ], + [ + 'label' => ' X', + 'value' => (string) $map->getWidth(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $map->getWidth()), + 'target' => 'map-size', + 'field' => 'width', + 'axis' => 'x', + ], + [ + 'label' => ' Y', + 'value' => (string) $map->getHeight(), + 'control' => new InputControl(InputControlType::INTEGER, (string) $map->getHeight()), + 'target' => 'map-size', + 'field' => 'height', + 'axis' => 'y', + ], + [ + 'label' => 'Events', + 'value' => (string) $map->getEventDefinitionCount(), + 'editable' => false, + ], + [ + 'label' => 'Triggers', + 'value' => (string) $map->getTriggerCount(), + 'editable' => false, + ], + ...$this->mapRuntimeFields($map), + ...MapAtlasFields::describeMapFields($map, $this->references), + // Where the region map pins it, beside where the atlas places it. + MapStationField::describeField($map), + ...MapTerrainFields::describeMapFields($map), + ...MapAreaFields::describeMapFields($map), + ...$this->getLandmarkFields()->describeMapFields($map), + ]; + } + + /** + * One event's rows: its marker, type, position and size (or its painted + * cells), and its data. + * + * @return array> + */ + public function getEventFields(ProjectMap $map, string $marker): array + { + $definition = $map->getEventDefinition($marker); + $bounds = $map->getEventBounds($marker); + $area = $map->getEventArea($marker); + $fields = [ + [ + 'label' => 'Event', + 'value' => $marker, + 'editable' => false, + ], + [ + 'label' => 'Type', + 'value' => EventTypeCatalog::describeClass( + is_array($definition) && is_string($definition['class'] ?? null) ? $definition['class'] : null, + ), + 'editable' => true, + // Chosen, never stepped through: changing an event's type + // replaces data another type gave it. + 'choices' => array_map(static fn(EventTypeDefinition $type): array => [ + 'label' => $type->label, + 'value' => $type->label, + 'description' => $type->description, + ], EventTypeCatalog::all()), + 'target' => 'event-type', + 'marker' => $marker, + ], + ...MapAreaFields::describeSubjectFields($map, $marker), + ...MapSpeakerFields::describeEventFields($map, $marker), + ]; + + if ($bounds !== null) { + $fields[] = ['label' => 'Position', 'value' => '', 'editable' => false]; + foreach (['x' => ' X', 'y' => ' Y'] as $axis => $label) { + $fields[] = [ + 'label' => $label, + 'value' => (string) $bounds[$axis], + 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds[$axis]), + 'target' => 'event-bounds', + 'marker' => $marker, + 'field' => $axis, + 'axis' => $axis, + ]; + } + if ($area?->isRectangle ?? true) { + $fields[] = ['label' => 'Size', 'value' => '', 'editable' => false]; + foreach (['width' => ' X', 'height' => ' Y'] as $axis => $label) { + $fields[] = [ + 'label' => $label, + 'value' => (string) $bounds[$axis], + 'control' => new InputControl(InputControlType::INTEGER, (string) $bounds[$axis]), + 'target' => 'event-bounds', + 'marker' => $marker, + 'field' => $axis, + 'axis' => $axis === 'width' ? 'x' : 'y', + ]; + } + } else { + // Painted in its own shape: the event triggers on exactly these cells. + $pieces = count($area->findPieces()); + $fields[] = [ + 'label' => 'Cells', + 'value' => sprintf('%d in %d %s', count($area->cells), $pieces, $pieces === 1 ? 'shape' : 'places'), + 'editable' => false, + ]; + } + } + + if ($definition !== null) { + $fields = [...$fields, ...$this->buildEventDataFields($marker, $definition)]; + $fields = $this->describeChestRewardFields($map, $marker, $definition, $fields); + $fields = \Ichiloto\Editor\Maps\MapPlacement::describeEventFields($definition, $fields); + } + + return [...$fields, ...$this->getLandmarkFields()->describeEventFields($map, $marker)]; + } + + /** + * Applies one row's edit to the map and returns the command that undoes + * and redoes it, already applied; null when nothing changed or the row + * is not one an edit applies through: the map's kind, which may first + * ask to clear tiles ({@see changeMapKind()}). + * + * @param array $field The row's descriptor. + * @throws InspectorRefusal When the edit cannot be made as asked, or the row is a transfer's + * destination, which is set with its spawn point ({@see setTransferDestination()}). + */ + public function apply(ProjectMap $map, array $field, string $rawValue): ?Command + { + if (($field['innPresentation'] ?? false) === true) { + return $this->applyInnPresentationField($map, $field, $rawValue); + } + if (isset($field['movementRouteField'])) { + return $this->applyMovementRouteField($map, $field, $rawValue); + } + if (($field['destination'] ?? false) === true) { + throw new InspectorRefusal('A destination is set with its spawn point; choose both together.'); + } + if (($field['target'] ?? null) === EventRecordPane::TARGET) { + return $this->applyEventRecordField($map, $field, $rawValue); + } + + $control = self::findControl($field); + $type = $control?->type ?? InputControlType::TEXT; + $value = match ($type) { + InputControlType::INTEGER => (int) trim($rawValue), + InputControlType::FLOAT => (float) trim($rawValue), + InputControlType::BOOLEAN => InputControl::parseBoolean($rawValue), + default => $rawValue, + }; + $choices = self::findChoiceValues($field); + + if ($control === null && $choices !== null && ! in_array($rawValue, $choices, true)) { + // A row picked from a list takes one of its choices, whatever + // interface sent the value. + throw new InspectorRefusal(sprintf('%s cannot be %s; choose one of: %s.', + trim((string) ($field['label'] ?? 'That row')), $rawValue, implode(', ', $choices))); + } + + if (($field['mapConditions'] ?? false) === true) { + try { + $value = ConditionCodec::decodeAllStrictly($rawValue); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + } + + return match ((string) ($field['target'] ?? 'map')) { + 'map' => $this->applyMapField($map, (string) $field['field'], $value, (string) ($field['label'] ?? 'Field')), + 'map-data' => ($path = array_values((array) ($field['path'] ?? []))) === [] + ? null + : $this->writeMapData($map, $path, $value === '' ? null : $value, (string) ($field['label'] ?? 'Field')), + 'map-encounters' => $this->applyMapEncounterValue($map, $field, $value), + 'map-bgm-variants' => $this->applyMapBgmVariantValue($map, $field, $value), + 'map-size' => $this->applyMapSize($map, (string) ($field['field'] ?? ''), (int) $value), + 'event' => $this->applyEventField($map, $field, $value), + 'event-type' => $this->applyEventType($map, (string) $field['marker'], $rawValue), + 'event-bounds' => $this->applyEventBounds($map, (string) $field['marker'], (string) $field['field'], (int) $value), + MapLandmarkFields::TARGET, MapLandmarkFields::EVENT_TARGET, MapLandmarkFields::OBJECT_TARGET => $this->applyLandmarkValue($map, $field, $value), + MapAreaFields::TARGET, MapAreaFields::SUBJECT_TARGET => MapAreaFields::applyValue($map, $field, $rawValue), + MapSpeakerFields::TARGET => MapSpeakerFields::applyValue($map, $field, $rawValue), + MapAtlasFields::TARGET => $this->applyAtlasValue($map, $field, $value), + MapTerrainFields::TARGET => $this->applyTerrainValue($map, $field, $value), + MapStationField::TARGET => $this->applyStationValue($map, (string) $value), + self::ROLE_TARGET => $this->applyMapRole($map, trim((string) $value)), + self::PLACE_TYPE_TARGET => $this->applyPlaceType($map, trim((string) $value)), + self::SKIT_BACKDROP_TARGET => $this->applySkitBackdrop($map, trim((string) $value)), + self::DASHING_TARGET => $this->writeMapData($map, [DashSettings::MAP_DISABLE_DASHING], $value === true ? true : null, + 'Disable Dashing'), + default => null, + }; + } + + /** + * Applies one minimap landmark row's edit: the declarations it leaves are checked by the Engine against the map + * as it stands, then written as one undo step. + * + * @param array $field + * @throws InspectorRefusal When the edit cannot be made or the map would not load the result. + */ + private function applyLandmarkValue(ProjectMap $map, array $field, mixed $value): ?Command + { + if (($field['target'] ?? null) === MapLandmarkFields::TARGET && ($field['field'] ?? null) === MapLandmarkFields::ID_FIELD) { + return $this->renameLandmark($map, $field, (string) $value); + } + try { + $entries = $this->getLandmarkFields()->applyValue($map, $field, $value); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return $entries === null ? null : $this->writeLandmarks($map, $entries, trim((string) ($field['label'] ?? 'Landmark'))); + } + + /** + * Renames a landmark and, in the same undo step, the atlas entry of every map that names it, so no local map + * loses its place above. When any of those maps cannot take the edit, nothing is renamed. + * + * @param array $field The landmark's id row. + * @throws InspectorRefusal When the id is refused, the map would not load the result, or a map naming it cannot follow. + */ + private function renameLandmark(ProjectMap $map, array $field, string $id): ?Command + { + try { + $rename = $this->getLandmarkFields()->renameEntry($map, $field, $id); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + if ($rename === null) { return null; } + + $problem = $this->getLandmarkFields()->findCurrentProblem($map, $rename['entries']); + if ($problem !== null) { throw new InspectorRefusal($problem); } + + // The maps naming it first: one that cannot follow refuses the rename before the landmark itself changes. + $commands = []; + try { + foreach ($rename['children'] as $child) { + $commands[] = $this->writeMapData($child, [MapAtlas::KEY, 'parent', 'landmark'], $rename['to'], sprintf('%s atlas entry', $child->mapId)); + } + $commands[] = $this->writeMapData($map, [MapLandmarks::KEY], $rename['entries'], 'Landmark id'); + } catch (MapSourceRefusal $refusal) { + foreach (array_reverse(array_filter($commands)) as $command) { $command->undo(); } + throw new InspectorRefusal(sprintf('Landmark %s was not renamed: %s', $rename['from'], $refusal->getMessage()), previous: $refusal); + } + + $commands = array_values(array_filter($commands)); + + return $commands === [] ? null : new CommandGroup(sprintf('Landmark rename %s to %s', $rename['from'] ?? '(no id)', $rename['to']), $commands); + } + + /** + * Applies one terrain row's edit: the atlas block it leaves is checked by the Engine against the map's source + * layers as they stand, then written as one undo step; an empty block removes the key. + * + * @param array $field + * @throws InspectorRefusal When the edit cannot be made or the Engine would refuse the terrain. + */ + private function applyTerrainValue(ProjectMap $map, array $field, mixed $value): ?Command + { + try { + $block = MapTerrainFields::applyValue($map, $field, $value); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + if ($block === null) { return null; } + $problem = MapTerrainFields::findProblem($map, $block); + if ($problem !== null) { throw new InspectorRefusal($problem); } + + return $this->writeMapData($map, [MapAtlas::KEY], $block === [] ? null : $block, 'Atlas terrain'); + } + + /** + * Pins the map to a cell of its region's map, or takes the pin away, as one undo step. + * + * @throws InspectorRefusal When the value is not an x, y pair of whole numbers. + */ + private function applyStationValue(ProjectMap $map, string $value): ?Command + { + try { + $station = MapStationField::applyValue($map, $value); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return $this->writeMapData($map, [MapStationField::KEY], $station, 'Station'); + } + + /** The landmark rows, over the project's place categories and maps as this inspector reads them. */ + private function getLandmarkFields(): MapLandmarkFields + { + return new MapLandmarkFields($this->references); + } + + /** + * Applies one atlas row's edit: the block it leaves is checked by the Engine through the parent chain, as the + * project's maps stand, then written as one undo step; an empty block removes the key. + * + * @param array $field + * @throws InspectorRefusal When the edit cannot be made or the atlas would not resolve. + */ + private function applyAtlasValue(ProjectMap $map, array $field, mixed $value): ?Command + { + try { + $block = MapAtlasFields::applyValue($map, $field, $value); + if ($block === null) { return null; } + MapAtlas::resolveGlobal($map, $block, $this->references->findMap(...), $this->references->getMapCategoryCatalog()); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return $this->writeMapData($map, [MapAtlas::KEY], $block === [] ? null : $block, 'Atlas ' . strtolower(trim((string) ($field['label'] ?? 'edit')))); + } + + /** + * Writes a map's landmark declarations once the Engine accepts them; none removes the key. + * + * @param list> $entries + * @throws InspectorRefusal When the map would not load them. + */ + private function writeLandmarks(ProjectMap $map, array $entries, string $label): ?Command + { + $problem = $this->getLandmarkFields()->findCurrentProblem($map, $entries); + if ($problem !== null) { + throw new InspectorRefusal($problem); + } + + return $this->writeMapData($map, [MapLandmarks::KEY], $entries === [] ? null : $entries, 'Landmark ' . lcfirst($label)); + } + + /** + * What names a row so it can be found again among a map's current rows: its target and the members that tell + * it from its neighbours. Null for a row nothing is applied through. Every interface finds a row by this. + * + * @param array $field + * @return array|null + */ + public static function describeRowKey(array $field): ?array + { + if (! is_string($field['target'] ?? null)) { + return null; + } + + return array_filter([ + 'target' => $field['target'], + 'field' => isset($field['field']) ? (string) $field['field'] : null, + 'path' => isset($field['path']) ? array_values(array_map('strval', (array) $field['path'])) : null, + 'marker' => isset($field['marker']) ? (string) $field['marker'] : null, + 'index' => isset($field['index']) ? (int) $field['index'] : null, + // A list heading has no path of its own; its list's names it. + 'list' => is_array($field['list']['path'] ?? null) ? array_values(array_map('strval', $field['list']['path'])) : null, + // An event record row is named within the frame of commands it is in. + 'frame' => is_array($field['eventFrame'] ?? null) ? array_values($field['eventFrame']) : null, + ], static fn(mixed $value): bool => $value !== null); + } + + /** + * The values a row is picked from: its `choices`, or the `options` it + * steps through. Null for a row that is typed or referenced. + * + * @param array $field + * @return list|null + */ + public static function findChoiceValues(array $field): ?array + { + return match (true) { + is_array($field['choices'] ?? null) => array_values(array_map(static fn(array $choice): string => (string) $choice['value'], $field['choices'])), + is_array($field['options'] ?? null) => array_values(array_map('strval', $field['options'])), + default => null, + }; + } + + /** + * Sets a transfer event's destination: the map and the spawn point on + * it, and when given the heading the player faces there, as one undo + * step. Without a heading the spawn facing stays as it is. + * + * @param ProjectMap $destination The map it leads to. + * @param string|null $heading North, East, South or West; null leaves the facing unchanged. + * @throws InspectorRefusal When the event is not a transfer, the spawn point is off the destination map, or the heading is not one. + */ + public function setTransferDestination(ProjectMap $map, string $marker, ProjectMap $destination, int $x, int $y, ?string $heading = null): ?Command + { + $current = $map->getEventDefinition($marker); + + if (! is_array($current) || ! is_array($current['data'] ?? null) || ! array_key_exists('destinationMap', $current['data'])) { + throw new InspectorRefusal(sprintf('Event %s has no destination to set.', $marker)); + } + + if ($x < 0 || $y < 0 || $x >= $destination->getWidth() || $y >= $destination->getHeight()) { + throw new InspectorRefusal(sprintf('(%d, %d) is outside %s, which is %dx%d.', + $x, $y, $destination->mapId, $destination->getWidth(), $destination->getHeight())); + } + + $facing = $heading === null ? null : (self::findSpawnHeading($heading) + ?? throw new InspectorRefusal(sprintf('%s is not a spawn facing; choose %s.', $heading, implode(', ', self::listSpawnHeadingNames())))); + + $next = $current; + $next['data']['destinationMap'] = $destination->mapId; + $next['data']['spawnPoint'] = [...(is_array($current['data']['spawnPoint'] ?? null) ? $current['data']['spawnPoint'] : []), 'x' => $x, 'y' => $y]; + if ($facing !== null) { + $next['data']['spawnSprite'] = [$facing->value]; + } + + if ($next === $current) { + return null; + } + + $map->setEventDefinition($marker, $next); + + return new GenericCommand( + 'Destination change', + static fn() => $map->setEventDefinition($marker, $next), + static fn() => $map->setEventDefinition($marker, $current), + ); + } + + /** + * Adds an entry to the list a row belongs to: a troop to the map's + * encounters, a music variant, or an entry like its neighbour to an + * event's list. Null when the row is in a list but nothing changed. + * + * @param array $field + * @throws InspectorRefusal When the row is in no list, or its list cannot take an entry. + */ + public function addListEntry(ProjectMap $map, array $field): ?InspectorListEdit + { + if (isset($field['innPresentationList'])) { return $this->editInnPresentationList($map, $field, false); } + if (($field['target'] ?? null) === 'map-encounters' && is_array($field[self::ARENA_RULE_LIST] ?? null)) { + return $this->addArenaRule($map, (int) $field[self::ARENA_RULE_LIST]['index']); + } + if (($field['target'] ?? null) === 'map-encounters' && is_array($field['encounterList'] ?? null)) { + $encounters = $this->requireEditableEncounters($map); + $troops = $this->references->valuesFor('troops'); + $available = array_values(array_diff($troops, $encounters->troopNames())); + + if ($available === []) { + throw new InspectorRefusal($troops === [] + ? 'This project defines no troops to encounter.' + : 'Every troop this project defines is already in this table.'); + } + + $block = $encounters->withTroopAdded($available[0], (int) $field['encounterList']['index']); + + return new InspectorListEdit($this->writeMapData($map, [MapEncounters::KEY], $block, 'Encounter troop add'), + sprintf('Added %s to the encounter table.', $available[0])); + } + + if (($field['target'] ?? null) === MapLandmarkFields::TARGET && is_array($field['landmarkList'] ?? null)) { + return $this->editLandmarkList($map, fn(): array => $this->getLandmarkFields()->addEntry($map), 'add'); + } + + if (($field['target'] ?? null) === MapAreaFields::TARGET) { + // An area is its cells, which are chosen on the map, not here. + throw new InspectorRefusal('Select cells on the map, then make an area of them.'); + } + + if (($field['target'] ?? null) === MapSpeakerFields::TARGET) { + return MapSpeakerFields::addSpeaker($map, $field); + } + + if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { + $variants = $this->requireEditableBgmVariants($map); + + return new InspectorListEdit($this->writeMapData($map, [MapBgmVariants::KEY], $variants->withVariantAdded(), 'Music variant add'), + sprintf('Added variant %d. Pick its track; it stays inactive until one is chosen.', $variants->count() + 1)); + } + + if (($field['target'] ?? null) === EventRecordPane::TARGET) { + return $this->editEventRecordItems($map, $field, false); + } + + $list = self::findEventList($field) ?? throw new InspectorRefusal('Nothing here is a list to add to.'); + $marker = (string) $field['marker']; + $entries = $map->getEventField($marker, $list['path']); + $entries = is_array($entries) ? array_values($entries) : []; + // The new entry is shaped like the one it follows -- the same keys, + // their values cleared -- because an author adding a second shop + // line means another line like the first, not an empty hole. + $routePoints = ($field['movementRoutePoints'] ?? false) === true; + $template = $routePoints ? $list['blank'] : ($entries[$list['index']] ?? ($list['blank'] ?? ($entries === [] ? '' : end($entries)))); + $position = min(count($entries), $list['index'] + 1); + array_splice($entries, $position, 0, [$routePoints ? $template : self::createBlankLike($template)]); + + return new InspectorListEdit($this->writeEventList($map, $marker, $list['path'], $entries, 'Add list entry'), + sprintf('Added %s %d.', self::describeListPath($list['path']), $position + 1)); + } + + /** + * Adds an arena rule after another: for the first of the map's encounter + * terrains no rule names yet, else its first area, on the map's arena + * (or the first declared), for the author to change. + * + * @throws InspectorRefusal When the map has no terrain or area to hold a rule for, or no arena to pick. + */ + private function addArenaRule(ProjectMap $map, int $after): InspectorListEdit + { + $encounters = $this->requireEditableEncounters($map); + + if ($encounters->rows() === []) { + throw new InspectorRefusal('Add an encounter troop first; arena rules choose where its battles are drawn.'); + } + + $arena = $encounters->mapArena() ?? ($this->references->valuesFor('battle_arenas')[0] + ?? throw new InspectorRefusal('The battle presentation declares no arena for a rule to pick.')); + $named = array_filter(array_column($encounters->arenaRules(), 'terrain')); + $terrain = array_values(array_diff($this->readEncounterTerrains($map) ?? [], $named))[0] ?? null; + $area = $terrain === null ? ($this->readMapAreaIds($map)[0] ?? null) : null; + + if ($terrain === null && $area === null) { + throw new InspectorRefusal('An arena rule holds for an area or an encounter terrain; this map has none left to rule.'); + } + + return new InspectorListEdit($this->writeMapData($map, [MapEncounters::KEY], $encounters->withArenaRuleAdded($area, $terrain, $arena, $after), + 'Arena rule add'), sprintf('Added an arena rule for %s.', $terrain ?? "area {$area}")); + } + + /** + * Removes the entry a row belongs to. Removing the last troop disables + * encounters and the last music variant takes the variants key with it. + * Null when the row names no entry to remove. + * + * @param array $field + * @throws InspectorRefusal When the row is in no list, or its list cannot lose an entry. + */ + public function removeListEntry(ProjectMap $map, array $field): ?InspectorListEdit + { + if (isset($field['innPresentationList'])) { return $this->editInnPresentationList($map, $field, true); } + if (($field['target'] ?? null) === 'map-encounters' && is_array($field[self::ARENA_RULE_LIST] ?? null)) { + $encounters = $this->requireEditableEncounters($map); + $index = (int) $field[self::ARENA_RULE_LIST]['index']; + + if (! array_key_exists($index, $encounters->arenaRules())) { + throw new InspectorRefusal('No arena rule to remove.'); + } + + return new InspectorListEdit($this->writeMapData($map, [MapEncounters::KEY], $encounters->withArenaRuleRemovedAt($index), + 'Arena rule remove'), sprintf('Removed arena rule %d.', $index + 1)); + } + if (($field['target'] ?? null) === 'map-encounters' && is_array($field['encounterList'] ?? null)) { + $encounters = $this->requireEditableEncounters($map); + $index = (int) $field['encounterList']['index']; + $rows = $encounters->rows(); + + if (! array_key_exists($index, $rows)) { + throw new InspectorRefusal('No encounter troop to remove.'); + } + + $block = $encounters->withTroopRemovedAt($index); + + return new InspectorListEdit($this->writeMapData($map, [MapEncounters::KEY], $block, 'Encounter troop remove'), $block === null + ? sprintf('Removed %s; this map no longer has random encounters.', $rows[$index]['name']) + : sprintf('Removed %s from the encounter table.', $rows[$index]['name'])); + } + + if (($field['target'] ?? null) === MapLandmarkFields::TARGET && is_array($field['landmarkList'] ?? null)) { + return $this->editLandmarkList($map, fn(): array => $this->getLandmarkFields()->removeEntry($map, $field), 'remove'); + } + + if (($field['target'] ?? null) === MapAreaFields::TARGET && is_array($field['areaList'] ?? null)) { + return MapAreaFields::removeEntry($map, $field); + } + + if (($field['target'] ?? null) === MapSpeakerFields::TARGET && isset($field['index'])) { + return MapSpeakerFields::removeSpeaker($map, $field); + } + + if (($field['target'] ?? null) === 'map-bgm-variants' && is_array($field['bgmVariantList'] ?? null)) { + $variants = $this->requireEditableBgmVariants($map); + $index = (int) $field['bgmVariantList']['index']; + + if ($variants->count() === 0) { + throw new InspectorRefusal('There is no music variant here to remove.'); + } + + return new InspectorListEdit($this->writeMapData($map, [MapBgmVariants::KEY], $variants->withVariantRemovedAt($index), 'Music variant remove'), + sprintf('Removed variant %d.', $index + 1)); + } + + if (($field['target'] ?? null) === EventRecordPane::TARGET) { + return $this->editEventRecordItems($map, $field, true); + } + + $list = self::findEventList($field) ?? throw new InspectorRefusal('Nothing here is a list entry to remove.'); + $marker = (string) $field['marker']; + $entries = $map->getEventField($marker, $list['path']); + $entries = is_array($entries) ? array_values($entries) : []; + + if (! array_key_exists($list['index'], $entries)) { + return null; + } + + array_splice($entries, $list['index'], 1); + + return new InspectorListEdit($this->writeEventList($map, $marker, $list['path'], $entries, 'Remove list entry'), + sprintf('Removed %s %d.', self::describeListPath($list['path']), $list['index'] + 1)); + } + + /** + * How many tile layers changing the map to this kind would clear: none + * when the kind is unchanged or the map has no kind yet, since a map + * without one keeps its tiles, which then draw from the new kind. + */ + public function countTileLayersClearedBy(ProjectMap $map, string $tilesetId): int + { + $this->assertTilesetExists($tilesetId); + $current = $map->getMapDataField(['tileset']); + + return $current === null || $current === $tilesetId ? 0 : count($map->getTileLayerSources()); + } + + /** + * Whether changing the map to this kind leaves its terrain kinds behind: it is painted in its tileset's terrain + * kinds ({@see MapTerrainKinds}) and the new tileset declares none, so its glyphs block or let pass as the + * collision dictionary says, as on any map not painted in kinds. + * + * @throws InspectorRefusal When the project has no tileset with the id. + */ + public function isKindChangeLeavingTerrainKinds(ProjectMap $map, string $tilesetId): bool + { + $this->assertTilesetExists($tilesetId); + try { + return MapTerrainKinds::isDeclared($map->getEditableData()) && $map->loadTilesetById($tilesetId)->kinds === []; + } catch (InvalidArgumentException) { + return false; // A malformed declaration or an unreadable tileset is reported where each is checked. + } + } + + /** + * Sets the map's kind, clearing its tile layers and their settings when + * asked, as one undo step. Null when it already was that kind. A map + * painted in terrain kinds stops being painted in them when the new + * tileset declares none ({@see isKindChangeLeavingTerrainKinds()}): kinds + * belong to the tileset that declares them, and a map naming a tileset + * without kinds cannot read its passage from them. + * + * @throws InspectorRefusal When the project has no tileset with the id. + * @throws Throwable When the map refuses the change; it is then exactly as it was. + */ + public function changeMapKind(ProjectMap $map, string $tilesetId, bool $clearTiles): ?Command + { + $this->assertTilesetExists($tilesetId); + + if ($map->getMapDataField(['tileset']) === $tilesetId) { + return null; + } + + $hadKind = $map->hasMapDataField(['tileset']); + $oldKind = $map->getMapDataField(['tileset']); + $oldSources = $map->getTileLayerSources(); + $hadSettings = $map->hasMapDataField([MapGraphics::SETTINGS_KEY]); + $oldSettings = $map->getMapDataField([MapGraphics::SETTINGS_KEY]); + $leavesKinds = $this->isKindChangeLeavingTerrainKinds($map, $tilesetId); + $apply = static function () use ($map, $tilesetId, $clearTiles, $leavesKinds): void { + $map->setMapDataField(['tileset'], $tilesetId); + if ($leavesKinds) { + $map->setMapDataField([MapTerrainKinds::DATA_KEY], null); + } + if ($clearTiles) { + $map->restoreTileLayerSources([]); + $map->setMapDataField([MapGraphics::SETTINGS_KEY], null); + } + }; + $revert = static function () use ($map, $hadKind, $oldKind, $oldSources, $hadSettings, $oldSettings, $leavesKinds): void { + $map->setMapDataField(['tileset'], $hadKind ? $oldKind : null); + if ($leavesKinds) { + $map->setMapDataField([MapTerrainKinds::DATA_KEY], true); + } + $map->restoreTileLayerSources($oldSources); + $map->setMapDataField([MapGraphics::SETTINGS_KEY], $hadSettings ? $oldSettings : null); + }; + + try { + $apply(); + } catch (Throwable $failure) { + // A change refused after the kind was written is undone before + // it is reported, so the kind never changes without its tiles. + // Refused at the kind itself, nothing changed to undo. + if ($map->getMapDataField(['tileset']) === $tilesetId) { + $revert(); + } + throw $failure; + } + + return new GenericCommand('Kind change', $apply, $revert); + } + + /** @throws InspectorRefusal When the project has no tileset with the id. */ + private function assertTilesetExists(string $tilesetId): void + { + if (! array_key_exists($tilesetId, $this->references->labelsFor('tilesets'))) { + throw new InspectorRefusal(sprintf('There is no tileset %s in assets/%s.', $tilesetId, Tileset::DIRECTORY)); + } + } + + /** + * Writes one value into the map's data and returns its command, or null + * when the data already held it. A null value removes the key. + * + * @param list $path + */ + public function writeMapData(ProjectMap $map, array $path, mixed $value, string $label): ?Command + { + $hadValue = $map->hasMapDataField($path); + $oldValue = $map->getMapDataField($path); + $map->setMapDataField($path, $value); + + if ($map->getMapDataField($path) === $oldValue && $map->hasMapDataField($path) === $hadValue) { + return null; + } + + return new GenericCommand( + sprintf('%s edit', $label), + static fn() => $map->setMapDataField($path, $value), + static fn() => $map->setMapDataField($path, $hadValue ? $oldValue : null), + ); + } + + /** + * Applies one music variant row's edit through the variants model, which + * owns the list's shape and preserves every key it does not edit. + * + * @param array $field + * @throws InspectorRefusal When the map's variants cannot be edited here. + */ + public function applyMapBgmVariantValue(ProjectMap $map, array $field, mixed $value): ?Command + { + $variants = $this->requireEditableBgmVariants($map); + $index = (int) ($field['index'] ?? 0); + $block = match ((string) ($field['field'] ?? '')) { + 'track' => $variants->withTrackAt($index, (string) $value), + 'conditions' => is_array($value) ? $variants->withConditionsAt($index, $value) : null, + default => null, + }; + + return $block === null ? null : $this->writeMapData($map, [MapBgmVariants::KEY], $block, sprintf('Music variant %d', $index + 1)); + } + + /** + * Places a chest's reward errors under the rows they are about. + * + * A chest whose reward the Engine cannot grant fails when opened, so the + * error is shown on the chest itself, from the same check the project + * report and the save warnings read. The chest stays editable and + * saveable: an unfinished chest is the author's draft. + * + * @param array $definition The event definition. + * @param array> $fields The event's rows so far. + * @return array> The rows, with any reward errors after the last loot row. + */ + private function describeChestRewardFields(ProjectMap $map, string $marker, array $definition, array $fields): array + { + if (! ChestLoot::isChest($definition)) { + return $fields; + } + + try { + $messages = array_map( + static fn(Issue $issue): string => trim($issue->message . ' ' . $issue->hint), + new ChestRewardValidator($this->references->getInventoryCatalog()) + ->validate($map->mapId, $marker, (array) ($definition['data'] ?? [])), + ); + } catch (Throwable $throwable) { + $messages = [sprintf('The inventory could not be read, so the reward cannot be checked: %s', $throwable->getMessage())]; + } + + if ($messages === []) { + return $fields; + } + + $rows = array_map(static fn(string $message): array => [ + 'label' => 'Reward Error', + 'value' => $message, + 'editable' => false, + 'tone' => 'error', + 'name' => 'Reward Error', + ], $messages); + $lootRows = array_keys(array_filter($fields, static fn(array $field): bool => in_array( + array_values((array) ($field['path'] ?? [])), + [['data', 'lootType'], ['data', 'loot'], ['data', 'quantity']], + true, + ) && ($field['target'] ?? null) === 'event')); + $after = $lootRows === [] ? count($fields) : max($lootRows) + 1; + array_splice($fields, $after, 0, $rows); + + return $fields; + } + + /** + * The picker an event data row is chosen from, when its value names + * another resource. + * + * @param array $field + * @param LootType|null $lootType The event's loot type, for a chest's loot row; without it that row has no picker + * (the terminal editor chooses loot in its own dialog). + * @return array{category: string, title: string}|null + */ + public static function findEventReference(array $field, ?LootType $lootType = null): ?array + { + if (($field['target'] ?? null) !== 'event') { + return null; + } + + $path = array_values((array) ($field['path'] ?? [])); + + if (($path[0] ?? null) !== 'data' || count($path) < 2) { + return null; + } + + $leaf = (string) $path[array_key_last($path)]; + + return match (true) { + $path === ['data', 'scriptId'] => ['category' => 'common_events', 'title' => 'Event Script'], + $path === ['data', 'cinematicId'] => ['category' => 'cinematics', 'title' => 'Cinematic'], + // An inn's rest stage, as InnOffer reads it. + $path === ['data', 'presentation'] => ['category' => 'stage_timelines', 'title' => 'Rest Presentation'], + $leaf === 'bgm' => ['category' => 'bgm', 'title' => 'Music'], + $leaf === 'sfx' => ['category' => 'sfx', 'title' => 'Sound Effect'], + // A shop's stock is data.items.N.item. The leaf alone would also + // match an unrelated event that happened to call a field "item". + $leaf === 'item' && ($path[1] ?? null) === 'items' => ['category' => 'inventory', 'title' => 'Item'], + // A chest's loot: the runtime gives every loot but gold from the item store, by stable definition id. + $path === ['data', 'loot'] => match ($lootType) { + LootType::ITEM => ['category' => 'items', 'title' => 'Item'], + LootType::WEAPON => ['category' => 'weapons', 'title' => 'Weapon'], + LootType::ARMOR => ['category' => 'armors', 'title' => 'Armor'], + LootType::ACCESSORY => ['category' => 'inventory', 'title' => 'Accessory'], + default => null, + }, + default => null, + }; + } + + /** + * The input a row is typed into, or null when it is read-only or chosen + * some other way. + * + * @param array $field + */ + public static function findControl(array $field): ?InputControl + { + if (($field['editable'] ?? null) === false) { + return null; + } + + $control = $field['control'] ?? null; + + return $control instanceof InputControl ? $control : null; + } + + /** + * The map's kind row: the tileset it draws from, chosen from the + * project's tilesets. + * + * @return array + */ + /** The Role row's target: a map's role in the project's Map Roles, chosen, never typed. */ + public const string ROLE_TARGET = 'map-role'; + /** The map's Disable Dashing row: true writes the Engine's `disableDashing`, false removes it. */ + public const string DASHING_TARGET = 'map-dashing'; + + /** + * The map's Role row: what it is to the world (an overworld, an outside place, an interior), one of the + * project's map roles, whose destinations validation holds every way off the map to. Not set leaves the map + * unchecked; a role the project no longer has stays visible and says so. + * + * @return array + */ + public function buildMapRoleField(ProjectMap $map): array + { + $role = $map->getMapDataField([MapRoleCatalog::MAP_KEY]); + $labels = $this->references->labelsFor('map_roles'); + + return [ + 'label' => 'Role', + 'value' => match (true) { + $role === null => self::MAP_ROLE_NONE, + ! is_string($role) => sprintf('%s · not a map role id', get_debug_type($role)), + ! isset($labels[$role]) => sprintf('%s · not in Map Roles', $role), + default => $labels[$role], + }, + 'selectedValue' => is_string($role) ? $role : '', + 'reference' => 'map_roles', + 'optional' => true, + 'target' => self::ROLE_TARGET, + 'field' => MapRoleCatalog::MAP_KEY, + ]; + } + + /** + * Makes the map one of the project's roles, or no role for an empty value, as one undo step. + * + * @throws InspectorRefusal When the role is not one the project has, or the roles cannot be read. + */ + private function applyMapRole(ProjectMap $map, string $role): ?Command + { + if ($role !== '') { + try { + $known = $this->references->getMapRoleCatalog()->hasRole($role); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal('The map roles cannot be read: ' . $error->getMessage(), previous: $error); + } + if (! $known) { + throw new InspectorRefusal(sprintf('%s is not a map role. Choose one from the Map Roles page, or make it there first.', $role)); + } + } + if ($map->getMapDataField([MapRoleCatalog::MAP_KEY]) === ($role === '' ? null : $role)) { + return null; + } + + return $this->writeMapData($map, [MapRoleCatalog::MAP_KEY], $role === '' ? null : $role, 'Role'); + } + + /** The Place Type row's target: the type of the place a map belongs to, in the project's Place Types, chosen, never typed. */ + public const string PLACE_TYPE_TARGET = 'map-place-type'; + + /** + * The map's Place Type row: what kind of place it belongs to (a town, an outpost, a shrine), one of the + * project's place types, which decides the icon every entrance into it shows on a map whose tileset gives + * place icons. Not set leaves those entrances unchecked; a type the project no longer has stays visible and + * says so. + * + * @return array + */ + public function buildPlaceTypeField(ProjectMap $map): array + { + $type = $map->getMapDataField([PlaceTypeCatalog::MAP_KEY]); + $labels = $this->references->labelsFor('place_types'); + + return [ + 'label' => 'Place Type', + 'value' => match (true) { + $type === null => self::PLACE_TYPE_NONE, + ! is_string($type) => sprintf('%s · not a place type id', get_debug_type($type)), + ! isset($labels[$type]) => sprintf('%s · not in Place Types', $type), + default => $labels[$type], + }, + 'selectedValue' => is_string($type) ? $type : '', + 'reference' => 'place_types', + 'optional' => true, + 'target' => self::PLACE_TYPE_TARGET, + 'field' => PlaceTypeCatalog::MAP_KEY, + ]; + } + + /** + * Makes the map's place one of the project's place types, or none for an empty value, as one undo step. + * + * @throws InspectorRefusal When the type is not one the project has, or the types cannot be read. + */ + private function applyPlaceType(ProjectMap $map, string $type): ?Command + { + if ($type !== '') { + try { + $known = $this->references->getPlaceTypeCatalog()->hasType($type); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal('The place types cannot be read: ' . $error->getMessage(), previous: $error); + } + if (! $known) { + throw new InspectorRefusal(sprintf('%s is not a place type. Choose one from the Place Types page, or make it there first.', $type)); + } + } + if ($map->getMapDataField([PlaceTypeCatalog::MAP_KEY]) === ($type === '' ? null : $type)) { + return null; + } + + return $this->writeMapData($map, [PlaceTypeCatalog::MAP_KEY], $type === '' ? null : $type, 'Place Type'); + } + + /** The Skit Backdrop row's target: the scene skits show on this map, a project PNG chosen, never typed. */ + public const string SKIT_BACKDROP_TARGET = 'map-skit-backdrop'; + /** How the Skit Backdrop row's choice of none reads. */ + public const string SKIT_BACKDROP_NONE = 'None: the region\'s backdrop, or none'; + + /** + * The map's Skit Backdrop row: the scene a skit's stage shows while it plays on this map, unless the skit's + * content names its own. Chosen with the project's image picker and stored as an asset-root-relative path in the + * map's `skitBackdrop`. With none, the row says what skits here show instead: the region's backdrop, or none, + * which the game reports. A path the project no longer has stays visible and says so. + * + * @return array + */ + public function buildSkitBackdropField(ProjectMap $map): array + { + $backdrop = $map->getMapDataField([SkitBackdrop::MAP_KEY]); + try { + $regions = $this->references->getRegionCatalog(); + } catch (InvalidArgumentException) { + // Unreadable regions name no backdrop here; validation says why. + $regions = new RegionCatalog(); + } + // What a skit that names none shows here, by the Engine's own choice. + $shown = SkitBackdrop::resolve([], SkitBackdrop::readMapBinding($map->getEditableData()), $regions); + + return [ + 'label' => 'Skit Backdrop', + 'value' => match (true) { + $shown->source === SkitBackdropSource::REGION => sprintf('None: region %s\'s, %s', $map->getMapDataField(['region']), $shown->asset), + $shown->source === SkitBackdropSource::NONE => 'None: skits here show no backdrop', + $shown->asset === null => sprintf('%s · %s', is_scalar($backdrop) ? (string) $backdrop : get_debug_type($backdrop), $shown->issue), + ! $this->isProjectPng($shown->asset) => $shown->asset . ' · not a PNG in assets', + default => $shown->asset, + }, + 'selectedValue' => is_string($backdrop) ? $backdrop : '', + 'reference' => 'png_assets', + 'noneLabel' => self::SKIT_BACKDROP_NONE, + 'optional' => true, + 'target' => self::SKIT_BACKDROP_TARGET, + 'field' => SkitBackdrop::MAP_KEY, + ]; + } + + /** + * Makes a project PNG the scene skits show on the map, or none for an empty value, as one undo step. + * + * @throws InspectorRefusal When the value is not one of the project's PNGs. + */ + private function applySkitBackdrop(ProjectMap $map, string $asset): ?Command + { + if ($asset !== '' && ! $this->isProjectPng($asset)) { + throw new InspectorRefusal(sprintf('%s is not a PNG in the project\'s assets. Choose one with the image picker.', $asset)); + } + + return $this->writeMapData($map, [SkitBackdrop::MAP_KEY], $asset === '' ? null : $asset, 'Skit Backdrop'); + } + + /** Whether an asset-root-relative path is a PNG inside the project's assets, read from the file, never listed. */ + private function isProjectPng(string $asset): bool + { + try { + PngAssetPreflight::inspect($this->references->getAssetRoot(), $asset); + } catch (Throwable) { + return false; + } + + return true; + } + + public function buildMapKindField(ProjectMap $map): array + { + $id = $map->getMapDataField(['tileset']); + $labels = $this->references->labelsFor('tilesets'); + + return [ + 'label' => 'Kind', + 'value' => match (true) { + $id === null => self::MAP_KIND_NONE, + ! is_string($id) => sprintf('%s · not a tileset id', get_debug_type($id)), + ! isset($labels[$id]) => sprintf('%s · not in assets/%s', $id, Tileset::DIRECTORY), + default => $labels[$id], + }, + 'selectedValue' => is_string($id) ? $id : '', + 'reference' => 'tilesets', + 'target' => 'map-kind', + 'field' => 'tileset', + ]; + } + + private function applyMapField(ProjectMap $map, string $fieldName, mixed $value, string $label): ?Command + { + $oldValue = $map->getMapField($fieldName); + $map->setMapField($fieldName, $value); + + return new GenericCommand( + sprintf('%s edit', $label), + static fn() => $map->setMapField($fieldName, $value), + static fn() => $map->setMapField($fieldName, $oldValue), + ); + } + + /** + * Resizes the map, refusing rather than clamping, deleting or truncating + * when an NPC would be left outside it. + * + * @throws InspectorRefusal When NPCs would be stranded; its details name them. + */ + private function applyMapSize(ProjectMap $map, string $axis, int $value): ?Command + { + $newWidth = $axis === 'width' ? max(1, $value) : $map->getWidth(); + $newHeight = $axis === 'height' ? max(1, $value) : $map->getHeight(); + $stranded = $map->describeNpcsStrandedBy($newWidth, $newHeight); + + if ($stranded !== []) { + // Refuse rather than clamp, delete, or truncate: the author + // moves, resizes or removes the NPC, then shrinks. + throw new InspectorRefusal( + sprintf('Cannot shrink to %dx%d: %d NPC%s would be stranded.', $newWidth, $newHeight, count($stranded), count($stranded) === 1 ? '' : 's'), + array_map(static fn(string $line): string => '- ' . $line, $stranded), + ); + } + + $snapshotBefore = $map->captureGridSnapshot(); + $map->resize($newWidth, $newHeight); + $snapshotAfter = $map->captureGridSnapshot(); + + return new GenericCommand( + 'Map resize', + static fn() => $map->restoreGridSnapshot($snapshotAfter), + static fn() => $map->restoreGridSnapshot($snapshotBefore), + ); + } + + /** @param array $field */ + private function applyEventField(ProjectMap $map, array $field, mixed $value): ?Command + { + $marker = (string) $field['marker']; + $path = (array) ($field['path'] ?? []); + if ($path === ['cue', 'kind']) { + return $this->applyEventCueKind($map, $marker, $value); + } + if ($path === ['engagement']) { + return $this->applyEventEngagement($map, $marker, $value); + } + if (is_string($field['facingHeading'] ?? null)) { + return $this->applyEventFacing($map, $marker, $field['facingHeading'], $value === true); + } + if (($field['spawnHeading'] ?? false) === true) { + return $this->applySpawnHeading($map, $marker, $value); + } + $held = $map->hasEventField($marker, $path); + $oldValue = $map->getEventField($marker, $path); + // Optional data cleared is removed, as it was before it was set. + $removed = ($field['optional'] ?? false) === true && ($value === null || $value === ''); + if ($removed && ! $held) { + return null; + } + $apply = $removed + ? static fn() => $map->removeEventField($marker, $path) + : static fn() => $map->setEventField($marker, $path, $value); + $restore = $held + ? static fn() => $map->setEventField($marker, $path, $oldValue) + : static fn() => $map->removeEventField($marker, $path); + $apply(); + + return new GenericCommand(sprintf('%s edit', $field['label'] ?? 'Event field'), $apply, $restore); + } + + /** + * Sets where the player stands to start the event. Standing on it is what an event without the key does, so + * choosing it drops the key, and undo restores the event as it was. + */ + private function applyEventEngagement(ProjectMap $map, string $marker, mixed $value): ?Command + { + $engagement = is_string($value) ? EventEngagement::tryFrom($value) : null; + if ($engagement === null) { + throw new InspectorRefusal('Choose to stand on the event or to face it from next to it.'); + } + $held = $map->hasEventField($marker, ['engagement']); + $old = $map->getEventField($marker, ['engagement']); + $next = $engagement === EventEngagement::INSIDE ? null : $engagement->value; + if ($old === $next || ($next === null && ! $held)) { + return null; + } + $apply = $next === null ? static fn() => $map->removeEventField($marker, ['engagement']) + : static fn() => $map->setEventField($marker, ['engagement'], $next); + $restore = $held ? static fn() => $map->setEventField($marker, ['engagement'], $old) + : static fn() => $map->removeEventField($marker, ['engagement']); + $apply(); + + return new GenericCommand('Engagement edit', $apply, $restore); + } + + /** + * Adds or removes one heading the player may face to start the event. The headings keep compass order, and an + * event left with none drops the key, so it starts whichever way the player faces, as before it was set. + */ + private function applyEventFacing(ProjectMap $map, string $marker, string $name, bool $chosen): ?Command + { + $held = $map->hasEventField($marker, ['facing']); + $old = $map->getEventField($marker, ['facing']); + $names = array_map(static fn(MovementHeading $heading): string => $heading->value, self::listFacingHeadings()); + if ($old !== null && (! is_array($old) || ! array_is_list($old) || array_diff($old, $names) !== [])) { + throw new InspectorRefusal('The event facing must list North, East, South or West before a heading can be changed.'); + } + $current = $old ?? []; + $next = array_values(array_filter($names, static fn(string $heading): bool => $heading === $name ? $chosen : in_array($heading, $current, true))); + if ($next === $current || ($next === [] && ! $held)) { + return null; + } + $apply = $next === [] ? static fn() => $map->removeEventField($marker, ['facing']) + : static fn() => $map->setEventField($marker, ['facing'], $next); + $restore = $held ? static fn() => $map->setEventField($marker, ['facing'], $old) + : static fn() => $map->removeEventField($marker, ['facing']); + $apply(); + + return new GenericCommand('Facing edit', $apply, $restore); + } + + /** + * Sets the heading the player faces on arrival. The Engine reads a + * spawn sprite that names one heading as that heading, so it is written + * as the one name; sprite art authored there is replaced. + */ + private function applySpawnHeading(ProjectMap $map, string $marker, mixed $value): ?Command + { + $heading = is_string($value) ? self::findSpawnHeading($value) : null; + if ($heading === null) { + throw new InspectorRefusal(sprintf('Choose %s for the spawn facing.', implode(', ', self::listSpawnHeadingNames()))); + } + $path = ['data', 'spawnSprite']; + $old = $map->getEventField($marker, $path); + $next = [$heading->value]; + if ($old === $next) { + return null; + } + $held = $map->hasEventField($marker, $path); + $apply = static fn() => $map->setEventField($marker, $path, $next); + $restore = $held ? static fn() => $map->setEventField($marker, $path, $old) : static fn() => $map->removeEventField($marker, $path); + $apply(); + + return new GenericCommand('Spawn facing edit', $apply, $restore); + } + + /** The heading a spawn facing names, or null for none of North, East, South or West. */ + public static function findSpawnHeading(string $name): ?MovementHeading + { + return array_find(self::listFacingHeadings(), static fn(MovementHeading $heading): bool => $heading->value === $name); + } + + /** @return list The headings a spawn faces, in compass order. */ + public static function listSpawnHeadingNames(): array + { + return array_map(static fn(MovementHeading $heading): string => $heading->value, self::listFacingHeadings()); + } + + /** + * An event's data rows with the spawn facing after its spawn point: a + * heading picked from the Engine's headings. Sprite art authored in its + * place is named and kept until a heading is chosen. + * + * @param list> $fields + * @return list> + */ + private static function insertSpawnHeadingField(array $fields, string $marker, mixed $spawnSprite): array + { + $heading = is_array($spawnSprite) || is_string($spawnSprite) ? PlayerSpriteSet::headingFromName($spawnSprite) : null; + $art = is_array($spawnSprite) ? implode(' ', array_map(static fn(mixed $row): string => is_scalar($row) ? (string) $row : '?', $spawnSprite)) : (is_scalar($spawnSprite) ? (string) $spawnSprite : ''); + $row = [ + 'label' => 'Spawn Facing', + 'value' => $heading?->value ?? sprintf('(sprite art: %s)', $art), + 'selectedValue' => $heading?->value ?? '', + 'choices' => array_map(static fn(string $name): array => ['label' => $name, 'value' => $name, + 'description' => sprintf('The player arrives facing %s.', strtolower($name))], self::listSpawnHeadingNames()), + 'options' => self::listSpawnHeadingNames(), + 'path' => ['data', 'spawnSprite'], + 'spawnHeading' => true, + 'target' => 'event', + 'marker' => $marker, + ]; + $spawnRows = array_keys(array_filter($fields, static fn(array $field): bool => array_slice(array_values((array) ($field['path'] ?? [])), 0, 2) === ['data', 'spawnPoint'])); + $at = $spawnRows === [] ? count($fields) : max($spawnRows) + 1; + array_splice($fields, $at, 0, [$row]); + + return $fields; + } + + /** Kind edits own the cue's optional parent so undo also restores its omission. */ + private function applyEventCueKind(ProjectMap $map, string $marker, mixed $value): ?Command + { + if (! is_string($value) || ($value !== '' && EventCueKind::tryFrom($value) === null)) { + throw new InspectorRefusal('Choose story or route, or clear the cue kind.'); + } + $held = $map->hasEventField($marker, ['cue']); + $old = $map->getEventField($marker, ['cue']); + if ($old !== null && ! is_array($old)) { + throw new InspectorRefusal('The event cue must be an array before its kind can be edited.'); + } + $next = $old ?? []; + if ($value === '') { + if (! array_key_exists('kind', $next)) { return null; } + unset($next['kind']); + } else { + $next['kind'] = $value; + } + if ($old === $next) { return null; } + $apply = static fn() => $map->setEventField($marker, ['cue'], $next); + $restore = $held ? static fn() => $map->setEventField($marker, ['cue'], $old) + : static fn() => $map->removeEventField($marker, ['cue']); + $apply(); + + return new GenericCommand('Cue kind edit', $apply, $restore); + } + + /** + * Moves or resizes an event through {@see EventAuthoring}, as the canvas + * does. Its position moves every cell, keeping its shape; a marker + * painted in another shape than a rectangle is reshaped by painting it. + * + * @throws InspectorRefusal When the event cannot move or resize as asked. + */ + private function applyEventBounds(ProjectMap $map, string $marker, string $axis, int $value): ?Command + { + $area = $map->getEventArea($marker); + + if ($area === null) { + return null; + } + + $bounds = $map->getEventBounds($marker); + + try { + if (in_array($axis, ['x', 'y'], true)) { + $delta = $value - $bounds[$axis]; + + return EventAuthoring::moveEvent($map, $marker, $axis === 'x' ? $delta : 0, $axis === 'y' ? $delta : 0, 'Event bounds edit'); + } + + if (! $area->isRectangle) { + throw new InspectorRefusal(sprintf('Marker %s is painted in its own shape; paint or erase its cells to reshape it.', $marker)); + } + + $bounds[$axis] = $value; + + return EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], $bounds['width'], $bounds['height']); + } catch (EventRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Makes an event the type an author names by its label. + * + * @throws InspectorRefusal When no type has the label or the event cannot take it. + */ + private function applyEventType(ProjectMap $map, string $marker, string $label): ?Command + { + $type = EventTypeCatalog::findByLabel($label) ?? throw new InspectorRefusal(sprintf('There is no event type %s.', $label)); + + try { + return EventAuthoring::setEventType($map, $marker, $type); + } catch (EventRefusal $refusal) { + throw new InspectorRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Writes an event's list back as one undo step; null when it already + * held exactly these entries. + * + * @param list $path The list's path. + * @param list $entries The list as it should be. + */ + private function writeEventList(ProjectMap $map, string $marker, array $path, array $entries, string $label): ?Command + { + $previous = $map->getEventField($marker, $path); + $previous = is_array($previous) ? array_values($previous) : []; + + if ($previous === $entries) { + return null; + } + + $map->setEventField($marker, $path, $entries); + + return new GenericCommand( + $label, + static fn() => $map->setEventField($marker, $path, $entries), + static fn() => $map->setEventField($marker, $path, $previous), + ); + } + + /** + * The event list a row belongs to, when it is one an author edits. + * + * @param array $field + * @return array{path: list, index: int, blank?: mixed}|null + */ + public static function findEventList(array $field): ?array + { + $list = $field['list'] ?? null; + + if (($field['target'] ?? null) !== 'event' || ! is_string($field['marker'] ?? null) || $field['marker'] === '' || ! is_array($list)) { + return null; + } + + return [...$list, 'path' => array_values(array_map('strval', (array) $list['path'])), 'index' => (int) $list['index']]; + } + + /** + * An entry shaped like the given one with nothing filled in. + * + * @param mixed $template The entry to copy the shape of. + * @return mixed The blank entry. + */ + private static function createBlankLike(mixed $template): mixed + { + if (is_array($template)) { + return array_map(self::createBlankLike(...), $template); + } + + return match (true) { + is_int($template) => 0, + is_float($template) => 0.0, + is_bool($template) => false, + default => '', + }; + } + + /** + * Names a list in the singular, for what an author reads about it. + * + * @param array $path The list's path. + */ + private static function describeListPath(array $path): string + { + $leaf = (string) ($path[array_key_last($path)] ?? 'entry'); + $leaf = str_replace(['_', '-'], ' ', $leaf); + + return mb_strtolower(rtrim($leaf, 's')); + } + + /** + * Reads a picked arena: one the battle presentation declares, or none. + * + * @throws InspectorRefusal When it names an arena the presentation does not declare. + */ + private function requireArena(string $value, string $none): ?string + { + $value = trim($value); + + if ($value === '' || $value === $none) { + return null; + } + + if (! in_array($value, $this->references->valuesFor('battle_arenas'), true)) { + throw new InspectorRefusal(sprintf('"%s" is not an arena the battle presentation declares.', $value)); + } + + return $value; + } + + /** + * The encounter terrains the map's terrain kinds meet, or null when the + * map is not painted in kinds or they cannot be read. + * + * @return list|null + */ + private function readEncounterTerrains(ProjectMap $map): ?array + { + try { + return $map->loadTerrainKinds()?->getEncounterTerrains(); + } catch (\InvalidArgumentException) { + return null; + } + } + + /** + * Reads typed encounter terrain ids for an encounter row: terrains the + * map's terrain kinds meet, separated by commas or spaces, or none for any. + * + * @return list + * @throws InspectorRefusal When the map is not painted in kinds, or a terrain is none of its kinds'. + */ + private function requireEncounterTerrains(ProjectMap $map, string $value): array + { + $value = trim($value); + + if ($value === '' || $value === self::ENCOUNTER_TERRAINS_NONE) { + return []; + } + + $known = $this->readEncounterTerrains($map) + ?? throw new InspectorRefusal('This map is not painted in terrain kinds, so its troops cannot be limited to terrains.'); + $terrains = array_values(array_unique(preg_split('/[\s,]+/', $value, flags: PREG_SPLIT_NO_EMPTY) ?: [])); + $unknown = array_diff($terrains, $known); + + if ($unknown !== []) { + throw new InspectorRefusal(sprintf('%s %s no encounter terrain of this map\'s kinds; they are %s.', implode(', ', $unknown), + count($unknown) === 1 ? 'names' : 'name', $known === [] ? 'none' : implode(', ', $known))); + } + + return $terrains; + } + + /** + * Reads typed area ids for an encounter row: the map's named areas, + * separated by commas or spaces, or none for the whole map. + * + * @return list + * @throws InspectorRefusal When the map's areas cannot be read, or one is not declared. + */ + private function requireEncounterAreas(ProjectMap $map, string $value): array + { + $value = trim($value); + + if ($value === '' || $value === self::ENCOUNTER_AREAS_NONE) { + return []; + } + + try { + $declared = array_keys(MapAreaAuthoring::readCatalog($map)->areas); + } catch (MapAreaRefusal $error) { + throw new InspectorRefusal($error->getMessage()); + } + + $areas = array_values(array_unique(preg_split('/[\s,]+/', $value, flags: PREG_SPLIT_NO_EMPTY) ?: [])); + $unknown = array_diff($areas, $declared); + + if ($unknown !== []) { + throw new InspectorRefusal(sprintf('%s %s no area of this map; its areas are %s.', implode(', ', $unknown), + count($unknown) === 1 ? 'names' : 'name', $declared === [] ? 'none yet (select cells on the map to make one)' : implode(', ', $declared))); + } + + return $areas; + } + + /** + * @param Closure(): array{entries: list>, summary: string} $edit + * @throws InspectorRefusal When the list cannot take the edit or the map would not load the result. + */ + private function editLandmarkList(ProjectMap $map, Closure $edit, string $action): InspectorListEdit + { + try { + $result = $edit(); + } catch (InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + + return new InspectorListEdit($this->writeLandmarks($map, $result['entries'], $action), $result['summary']); + } + + /** @throws InspectorRefusal When the map's encounters cannot be edited here. */ + private function requireEditableEncounters(ProjectMap $map): MapEncounters + { + $encounters = MapEncounters::fromMap($map); + + if (! $encounters->isSupported()) { + throw new InspectorRefusal(sprintf('Encounters are read-only here: %s.', $encounters->unsupportedReason())); + } + + return $encounters; + } + + /** @throws InspectorRefusal When the map's music variants cannot be edited here. */ + private function requireEditableBgmVariants(ProjectMap $map): MapBgmVariants + { + $variants = MapBgmVariants::fromMap($map); + + if (! $variants->isSupported()) { + throw new InspectorRefusal(sprintf('Music variants are read-only here: %s.', $variants->unsupportedReason())); + } + + return $variants; + } + + /** + * Applies one encounter row's edit through the encounters model. + * + * @param array $field + * @throws InspectorRefusal When the map's encounters cannot be edited here. + */ + private function applyMapEncounterValue(ProjectMap $map, array $field, mixed $value): ?Command + { + $encounters = $this->requireEditableEncounters($map); + $index = (int) ($field['index'] ?? 0); + $block = match ((string) ($field['field'] ?? '')) { + 'rate' => $encounters->withRate((int) $value), + 'tiles' => $encounters->withTiles((string) $value), + 'weight' => $encounters->withWeightAt($index, (int) $value), + 'troop' => $encounters->withTroopAt($index, (string) $value), + 'arena' => $encounters->withArenaAt($index, $this->requireArena((string) $value, self::ENCOUNTER_ARENA_NONE)), + 'areas' => $encounters->withAreasAt($index, $this->requireEncounterAreas($map, (string) $value)), + 'terrains' => $encounters->withTerrainsAt($index, $this->requireEncounterTerrains($map, (string) $value)), + 'mapArena' => $encounters->withMapArena($this->requireArena((string) $value, self::MAP_ARENA_NONE)), + 'ruleArea', 'ruleTerrain', 'ruleArena' => $this->applyArenaRuleValue($map, $encounters, $index, (string) $field['field'], (string) $value), + default => null, + }; + + if ($block === null && ! $encounters->isDeclared()) { + return null; + } + + return $this->writeMapData($map, [MapEncounters::KEY], $block, 'Encounters'); + } + /** + * Builds the map's runtime metadata rows: the music it plays and the + * random encounters it offers. + * + * Both are read from what the map actually holds. An absent rate or tile + * mode is shown as the engine's default in parentheses and is not + * written until an author sets one, so opening a map never puts a + * default into a file. + * + * @return array> + */ + /** + * The map's movement rows: whether it turns the project's dash off, as RPG Maker's Disable Dashing. The Engine + * reads the same key ({@see DashSettings::readMapDisablesDashing()}); a value it would refuse stays visible. + * + * @return list> + */ + private function mapMovementFields(ProjectMap $map): array + { + $path = [DashSettings::MAP_DISABLE_DASHING]; + $value = $map->getMapDataField($path); + $disabled = $value === true; + + return [ + ['label' => 'Movement', 'value' => '', 'editable' => false], + [ + 'label' => ' Disable Dashing', + 'value' => $value === null || is_bool($value) ? ($disabled ? 'true' : 'false') + : sprintf('%s · must be true or false', var_export($value, true)), + 'control' => new InputControl(InputControlType::BOOLEAN, $disabled ? 'true' : 'false'), + 'target' => self::DASHING_TARGET, + 'field' => DashSettings::MAP_DISABLE_DASHING, + ], + ]; + } + + private function mapRuntimeFields(ProjectMap $map): array + { + $bgm = $map->getMapDataField(['bgm']); + $bgm = is_string($bgm) ? $bgm : ''; + $known = $this->references->valuesFor('bgm'); + $missing = $bgm !== '' && ! in_array($bgm, $known, true); + $fields = [ + [ + 'label' => 'Audio', + 'value' => '', + 'editable' => false, + ], + [ + // A track is chosen from what the project has, never spelled. + // A track the project no longer has stays visible and says + // so, rather than being quietly swapped for a valid one. + 'label' => ' Background Music', + 'value' => match (true) { + $bgm === '' => self::MAP_BGM_NONE, + $missing => $bgm . ' · not in assets/Audio/BGM', + default => $bgm, + }, + 'reference' => 'bgm', + 'target' => 'map-data', + 'path' => ['bgm'], + 'field' => 'bgm', + ], + ]; + + $fields = [...$fields, ...$this->mapBgmVariantFields($map, $known), ...$this->mapMovementFields($map)]; + + $encounters = MapEncounters::fromMap($map); + $fields[] = [ + 'label' => 'Encounters', + 'value' => $encounters->summary(), + 'editable' => false, + ]; + + if (! $encounters->isSupported()) { + $fields[] = [ + 'label' => ' ! Read-only', + 'value' => (string) $encounters->unsupportedReason(), + 'editable' => false, + ]; + + return $fields; + } + + $rows = $encounters->rows(); + $fields[] = [ + // The Inspector's own list heading, so Shift+O and Del mean here + // what they mean on every other list in the pane. + 'label' => sprintf(' Troops · %d', count($rows)), + 'value' => '', + 'editable' => false, + 'target' => 'map-encounters', + 'encounterList' => ['index' => max(0, count($rows) - 1)], + ]; + + foreach ($rows as $index => $row) { + $weight = $row['weight']; + $fields[] = [ + 'label' => ' Troop', + 'value' => $row['name'], + 'reference' => 'troops', + 'target' => 'map-encounters', + 'field' => 'troop', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + $usable = is_numeric($weight) && (int) $weight >= 1; + $fields[] = [ + 'label' => ' Weight', + 'value' => (is_scalar($weight) ? (string) $weight : '') . ($usable ? '' : ' · the engine drops a troop it cannot weigh'), + 'control' => new InputControl(InputControlType::INTEGER, $usable ? (string) (int) $weight : '1'), + 'target' => 'map-encounters', + 'field' => 'weight', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + + // Typed as area ids, the way the Terminal names an area elsewhere. + $fields[] = [ + 'label' => ' Areas', + 'value' => $row['areas'] === [] ? self::ENCOUNTER_AREAS_NONE : implode(', ', $row['areas']), + 'control' => new InputControl(InputControlType::TEXT, implode(', ', $row['areas'])), + 'target' => 'map-encounters', + 'field' => 'areas', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + + // Typed as encounter terrain ids, offered on a map painted in terrain kinds or a row that names some. + $terrains = $this->readEncounterTerrains($map); + if ($terrains !== null || $row['terrains'] !== []) { + $fields[] = [ + 'label' => ' Terrains', + 'value' => $row['terrains'] === [] ? self::ENCOUNTER_TERRAINS_NONE : implode(', ', $row['terrains']), + 'control' => new InputControl(InputControlType::TEXT, implode(', ', $row['terrains'])), + 'target' => 'map-encounters', + 'field' => 'terrains', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + } + + if ($this->graphical) { + $fields[] = [ + 'label' => ' Arena', + 'value' => $this->describeArena($row['arena'], self::ENCOUNTER_ARENA_NONE), + 'reference' => 'battle_arenas', + 'noneLabel' => self::ENCOUNTER_ARENA_NONE, + 'target' => 'map-encounters', + 'field' => 'arena', + 'index' => $index, + 'encounterList' => ['index' => $index], + ]; + } + } + + if ($rows === []) { + return $fields; + } + + $rate = $encounters->authoredRate(); + $fields[] = [ + 'label' => ' Rate', + 'value' => $rate === null ? sprintf('(engine default: %d)', MapEncounters::DEFAULT_RATE) : (string) $rate, + 'control' => new InputControl(InputControlType::INTEGER, (string) ($rate ?? MapEncounters::DEFAULT_RATE)), + 'target' => 'map-encounters', + 'field' => 'rate', + ]; + $tiles = $encounters->authoredTiles(); + $fields[] = [ + 'label' => ' Tiles', + 'value' => $tiles === null ? sprintf('(engine default: %s)', MapEncounters::DEFAULT_TILES) : $tiles, + 'options' => MapEncounters::TILE_MODES, + 'target' => 'map-encounters', + 'field' => 'tiles', + ]; + + if ($this->graphical) { + $fields[] = [ + 'label' => ' Arena', + 'value' => $this->describeArena($encounters->mapArena(), self::MAP_ARENA_NONE), + 'reference' => 'battle_arenas', + 'noneLabel' => self::MAP_ARENA_NONE, + 'target' => 'map-encounters', + 'field' => 'mapArena', + ]; + $fields = [...$fields, ...$this->mapArenaRuleFields($map, $encounters)]; + } + + return $fields; + } + + /** + * The map's battle arena rules, a graphical editor's: for each, the area + * and encounter terrain it holds for, chosen from the map's own, and the + * arena it picks ({@see \Ichiloto\Engine\Field\EncounterArenaRule}). + * + * @return list> + */ + private function mapArenaRuleFields(ProjectMap $map, MapEncounters $encounters): array + { + $rules = $encounters->arenaRules(); + $areas = $this->readMapAreaIds($map); + $terrains = $this->readEncounterTerrains($map) ?? []; + $fields = [[ + 'label' => sprintf(' Arena Rules · %d', count($rules)), + 'value' => '', + 'editable' => false, + 'target' => 'map-encounters', + // Named apart from the troop list's heading, which shares the target. + 'field' => 'arenaRules', + self::ARENA_RULE_LIST => ['index' => max(0, count($rules) - 1)], + ]]; + + foreach ($rules as $index => $rule) { + $list = [self::ARENA_RULE_LIST => ['index' => $index]]; + $fields[] = [ + 'label' => ' Area', + 'value' => $rule['area'] ?? self::ENCOUNTER_AREAS_NONE, + 'options' => [self::ENCOUNTER_AREAS_NONE, ...array_values(array_unique([...$areas, ...($rule['area'] === null ? [] : [$rule['area']])]))], + 'target' => 'map-encounters', + 'field' => 'ruleArea', + 'index' => $index, + ...$list, + ]; + $fields[] = [ + 'label' => ' Terrain', + 'value' => $rule['terrain'] ?? self::ENCOUNTER_TERRAINS_NONE, + 'options' => [self::ENCOUNTER_TERRAINS_NONE, ...array_values(array_unique([...$terrains, ...($rule['terrain'] === null ? [] : [$rule['terrain']])]))], + 'target' => 'map-encounters', + 'field' => 'ruleTerrain', + 'index' => $index, + ...$list, + ]; + $fields[] = [ + 'label' => ' Arena', + 'value' => $this->describeArena($rule['arena'], self::RULE_ARENA_NONE), + 'reference' => 'battle_arenas', + 'target' => 'map-encounters', + 'field' => 'ruleArena', + 'index' => $index, + ...$list, + ]; + } + + return $fields; + } + + /** + * The map's named areas, or none when they cannot be read. + * + * @return list + */ + private function readMapAreaIds(ProjectMap $map): array + { + try { + return array_map(strval(...), array_keys(MapAreaAuthoring::readCatalog($map)->areas)); + } catch (MapAreaRefusal) { + return []; + } + } + + /** + * Applies an edit to one arena rule: an area of the map or the whole map, an + * encounter terrain of its kinds or any, or a declared arena. A rule keeps an + * area or a terrain, since the map's own Arena covers the whole map. + * + * @throws InspectorRefusal When the value is not the map's, or the rule would name neither. + */ + private function applyArenaRuleValue(ProjectMap $map, MapEncounters $encounters, int $index, string $field, string $value): ?array + { + $rule = $encounters->arenaRules()[$index] ?? throw new InspectorRefusal('There is no such arena rule.'); + $value = trim($value); + [$key, $chosen] = match ($field) { + 'ruleArea' => ['area', $value === '' || $value === self::ENCOUNTER_AREAS_NONE ? null + : ($this->requireEncounterAreas($map, $value)[0] ?? null)], + 'ruleTerrain' => ['terrain', $value === '' || $value === self::ENCOUNTER_TERRAINS_NONE ? null + : ($this->requireEncounterTerrains($map, $value)[0] ?? null)], + default => ['arena', $this->requireArena($value, self::RULE_ARENA_NONE) + ?? throw new InspectorRefusal('An arena rule picks an arena; remove the rule to leave battles to the map\'s.')], + }; + $next = [$key => $chosen] + $rule; + + if ($next['area'] === null && $next['terrain'] === null) { + throw new InspectorRefusal('An arena rule holds for an area, a terrain or both; the map\'s own Arena covers the whole map.'); + } + + return $encounters->withArenaRuleAt($index, $key, $chosen); + } + + /** + * How an arena reads on a row: its name, what applies when none is set, + * or a key the battle presentation does not declare, kept and said so. + */ + private function describeArena(?string $arena, string $none): string + { + if ($arena === null) { + return $none; + } + + $name = $this->references->labelsFor('battle_arenas')[$arena] ?? null; + + return $name === null ? $arena . ' · not an arena the battle presentation declares' : sprintf('%s (%s)', $name, $arena); + } + + /** + * Builds the ordered Music Variants rows: the map's conditional music, + * evaluated by the engine in declaration order, first match wins. + * + * @param string[] $known The project's BGM tracks. + * @return array> + */ + private function mapBgmVariantFields(ProjectMap $map, array $known): array + { + $variants = MapBgmVariants::fromMap($map); + $fields = [ + [ + 'label' => ' Music Variants', + 'value' => $variants->summary(), + 'editable' => false, + 'target' => 'map-bgm-variants', + 'bgmVariantList' => ['index' => max(0, $variants->count() - 1)], + ], + ]; + + if (! $variants->isSupported()) { + $fields[] = [ + 'label' => ' ! Read-only', + 'value' => (string) $variants->unsupportedReason(), + 'editable' => false, + ]; + + return $fields; + } + + foreach ($variants->rows() as $index => $row) { + $track = $variants->trackAt($index); + $raw = $variants->rawTrackAt($index); + $fields[] = [ + 'label' => sprintf(' Variant %d Track', $index + 1), + 'value' => match (true) { + $track === null && $raw !== null => var_export($raw, true) . ' · not a track name', + $track === null || $track === '' => '(no track yet)', + ! in_array($track, $known, true) => $track . ' · not in assets/Audio/BGM', + default => $track, + }, + 'reference' => 'bgm', + 'target' => 'map-bgm-variants', + 'field' => 'track', + 'index' => $index, + 'bgmVariantList' => ['index' => $index], + ]; + + $issue = $variants->conditionsIssueAt($index); + $conditions = $variants->conditionsAt($index); + $fields[] = [ + 'label' => sprintf(' Variant %d When', $index + 1), + 'value' => $issue !== null + ? '! ' . $issue + : ($conditions === [] ? '(always · shadows later variants)' : ConditionCodec::encodeAll($conditions)), + 'target' => 'map-bgm-variants', + 'field' => 'conditions', + 'index' => $index, + 'bgmVariantList' => ['index' => $index], + 'editable' => $issue === null, + 'mapConditions' => $issue === null, + 'encoded' => $issue === null ? ConditionCodec::encodeAll($conditions) : null, + 'conditionsTitle' => sprintf('Variant %d Conditions', $index + 1), + 'conditionsHint' => 'Building conditions. An empty list always matches.', + ]; + } + + return $fields; + } + + /** + * Builds editable inspector fields for event data. + * + * The fields every trigger reads beside its class and data, and the lists + * a type keeps that edit as an NPC's do, are the event's record rows + * ({@see EventRecordPane}); the rest of its data and its cue are rows of + * their own. + * + * @param string $marker The event marker. + * @param array $definition The event definition. + * @return array> + */ + public function buildEventDataFields(string $marker, array $definition): array + { + $pane = $this->createEventRecordPane($marker, $definition); + $shown = $definition; + foreach ($pane->listOwnedPaths() as $path) { + // Only this display copy changes: the record rows describe these. + $shown = self::withoutPath($shown, $path); + } + $recordRows = $pane->getFields() ?? []; + $cueRecordRows = array_values(array_filter($recordRows, static fn(array $row): bool => ($row['field'] ?? null) === 'cue.conditions')); + $recordRows = array_values(array_filter($recordRows, static fn(array $row): bool => ($row['field'] ?? null) !== 'cue.conditions')); + $fields = []; + $eventData = $shown['data'] ?? []; + + if (is_array($eventData)) { + // The type's optional data is offered as it reads when absent, + // and stays out of the stored event until an author sets it. + $className = is_string($definition['class'] ?? null) ? ltrim($definition['class'], '\\') : null; + $optional = EventTypeCatalog::findByClass($className)?->optionalData ?? []; + $modes = self::listEventModes($className); + $hasPresentation = array_key_exists('presentation', $optional) || array_key_exists('presentation', $eventData); + $presentation = $eventData['presentation'] ?? null; + if ($hasPresentation) { unset($eventData['presentation'], $optional['presentation']); } + $hasSpawnSprite = array_key_exists('spawnSprite', $eventData); + $spawnSprite = $eventData['spawnSprite'] ?? null; + unset($eventData['spawnSprite']); + $pages = self::readDialoguePages($shown); + $fields = array_map(static function (array $field) use ($optional, $modes): array { + $path = array_values((array) ($field['path'] ?? [])); + if (count($path) === 2 && array_key_exists((string) $path[1], $optional)) { + $field['optional'] = true; + } + if ($path === ['data', 'mode'] && $modes !== null) { + $field['options'] = $modes; + unset($field['control']); + } + + return $field; + }, $this->decorateEventInspectorFields( + $marker, + $this->flattenInspectorFields([...self::withPageEmotions($eventData, $pages), ...array_diff_key($optional, $eventData)], ['data']), + )); + $fields = $this->describePageEmotions($fields, $pages); + if ($hasSpawnSprite) { $fields = self::insertSpawnHeadingField($fields, $marker, $spawnSprite); } + if ($hasPresentation) { $fields = [...$fields, ...$this->buildInnPresentationFields($marker, $presentation)]; } + } + + // The subject an event is bound to and its area are set by their own operations, never as rows. + $rootFields = array_diff_key($shown, ['class' => true, 'data' => true, 'facing' => true, 'engagement' => true, 'subject' => true, 'area' => true]); + // Cue is a generic trigger capability, including for definitions + // created before the field existed. Supplying an empty editor-only + // default exposes the opt-in without changing the stored event until + // the author actually edits it. + if (! isset($rootFields['cue']) || $rootFields['cue'] === []) { + // A cue holding only its conditions is still offered its symbol and colour. + $rootFields['cue'] = ['symbol' => '', 'color' => 'bright-yellow']; + } + $cueKindFields = []; + if (is_array($rootFields['cue'])) { + $hasKind = array_key_exists('kind', $rootFields['cue']); + $kind = $hasKind ? $rootFields['cue']['kind'] : ''; + $validKind = ! $hasKind || (is_string($kind) && EventCueKind::tryFrom($kind) !== null); + // A malformed kind remains a single repairable property, not a + // nested array of freely editable fields. Only this display copy changes. + unset($rootFields['cue']['kind']); + $cueKindFields[] = [ + 'label' => 'Cue Kind', + 'value' => $validKind ? $kind : '(invalid kind)', + 'selectedValue' => $validKind ? $kind : '(invalid kind)', + 'optionLabels' => array_column(self::EVENT_CUE_KIND_CHOICES, 'label', 'value'), + 'path' => ['cue', 'kind'], + 'optional' => true, + ]; + } + + return [ + ...$fields, + ...$recordRows, + ...$this->decorateEventInspectorFields( + $marker, + [...$this->flattenInspectorFields($rootFields, []), ...$cueKindFields], + ), + ...$cueRecordRows, + ...$this->decorateEventInspectorFields($marker, [self::buildEventEngagementField($definition), ...self::buildEventFacingFields($definition)]), + ]; + } + + /** + * The rows of one frame of an event's inline commands, or null when the + * event or the frame is gone. The frame [] is the event's record rows. + * + * @param list $frame + * @return list>|null + */ + public function getEventFrameFields(ProjectMap $map, string $marker, array $frame): ?array + { + $definition = $map->getEventDefinition($marker); + + return $definition === null ? null : $this->createEventRecordPane($marker, $definition)->getFields($frame); + } + + /** What an author reads for a frame of an event's inline commands. */ + public function describeEventFrame(ProjectMap $map, string $marker, array $frame): string + { + return $this->createEventRecordPane($marker, $map->getEventDefinition($marker) ?? [])->describeFrame($frame); + } + + /** The record rows of one event, over the project's references as this inspector reads them. */ + public function createEventRecordPane(string $marker, array $definition): EventRecordPane + { + return new EventRecordPane($marker, $definition, $this->references, $this->graphical); + } + + /** + * Applies one event record row's edit: the definition the pane leaves is + * written as one undo step. + * + * @param array $field + * @throws InspectorRefusal When the event is gone or the pane refuses the edit. + */ + private function applyEventRecordField(ProjectMap $map, array $field, string $rawValue): ?Command + { + $marker = (string) ($field['marker'] ?? ''); + $current = $map->getEventDefinition($marker) ?? throw new InspectorRefusal(sprintf('%s has no event %s.', $map->mapId, $marker)); + $next = $this->createEventRecordPane($marker, $current)->applyField(array_values((array) ($field['eventFrame'] ?? [])), (string) ($field['field'] ?? ''), $rawValue); + + return $next === null ? null : $this->writeEventDefinition($map, $marker, $current, $next, sprintf('%s edit', trim((string) ($field['label'] ?? 'Event field')))); + } + + /** + * Adds or removes the item an event record row belongs to (a command, a + * dialogue variant, a line, an option), as one undo step. + * + * @param array $field + * @throws InspectorRefusal When the event or frame is gone, or the row's item cannot change so. + */ + private function editEventRecordItems(ProjectMap $map, array $field, bool $remove): InspectorListEdit + { + $marker = (string) ($field['marker'] ?? ''); + $current = $map->getEventDefinition($marker) ?? throw new InspectorRefusal(sprintf('%s has no event %s.', $map->mapId, $marker)); + $pane = $this->createEventRecordPane($marker, $current); + $frame = array_values((array) ($field['eventFrame'] ?? [])); + $fieldId = is_string($field['field'] ?? null) && $field['field'] !== '' ? $field['field'] : null; + $edit = $remove ? $pane->removeItem($frame, (string) $fieldId) : $pane->addItem($frame, $fieldId); + + return new InspectorListEdit($edit['definition'] === null ? null + : $this->writeEventDefinition($map, $marker, $current, $edit['definition'], $remove ? 'Remove list entry' : 'Add list entry'), $edit['summary']); + } + + /** + * Writes an event's whole definition and returns the command that puts + * either one back. + * + * @param array $current + * @param array $next + */ + private function writeEventDefinition(ProjectMap $map, string $marker, array $current, array $next, string $label): Command + { + $map->setEventDefinition($marker, $next); + + return new GenericCommand( + $label, + static fn() => $map->setEventDefinition($marker, $next), + static fn() => $map->setEventDefinition($marker, $current), + ); + } + + /** + * An array without the value at a path; parents it leaves empty stay. + * + * @param array $array + * @param list $path + * @return array + */ + private static function withoutPath(array $array, array $path): array + { + $key = array_shift($path); + if ($key === null || ! array_key_exists($key, $array)) { + return $array; + } + if ($path === []) { + unset($array[$key]); + } elseif (is_array($array[$key])) { + $array[$key] = self::withoutPath($array[$key], $path); + } + + return $array; + } + + /** + * Which way the player faces to start the event: one row per heading, none chosen for any. A facing the Engine + * would refuse stays as one row that names it, for the author to repair in the source, never flattened. + * + * @param array $definition + * @return list> + */ + private static function buildEventFacingFields(array $definition): array + { + $facing = $definition['facing'] ?? []; + $names = array_map(static fn(MovementHeading $heading): string => $heading->value, self::listFacingHeadings()); + if (! is_array($facing) || ! array_is_list($facing) || array_diff($facing, $names) !== []) { + return [['label' => 'Facing', 'value' => '(not North, East, South or West)', 'editable' => false, 'path' => ['facing']]]; + } + + return array_map(static fn(string $name): array => [ + 'label' => "Facing {$name}", + 'value' => in_array($name, $facing, true) ? 'true' : 'false', + 'control' => new InputControl(InputControlType::BOOLEAN, in_array($name, $facing, true) ? 'true' : 'false'), + 'path' => ['facing', $name], + 'facingHeading' => $name, + ], $names); + } + + /** + * Where the player stands to start the event: on its cells, or next to them facing one, for an event placed on an + * object. An engagement the Engine would refuse is named rather than offered, never rewritten. + * + * @param array $definition + * @return array + */ + private static function buildEventEngagementField(array $definition): array + { + $engagement = $definition['engagement'] ?? EventEngagement::INSIDE->value; + if (! is_string($engagement) || EventEngagement::tryFrom($engagement) === null) { + return ['label' => 'Engagement', 'value' => '(not inside or facing)', 'editable' => false, 'path' => ['engagement']]; + } + + return [ + 'label' => 'Engagement', + 'value' => $engagement, + 'selectedValue' => $engagement, + 'optionLabels' => array_column(self::EVENT_ENGAGEMENT_CHOICES, 'label', 'value'), + 'path' => ['engagement'], + ]; + } + + /** @return list The headings an event may require, in compass order. */ + private static function listFacingHeadings(): array + { + return array_values(array_filter(MovementHeading::cases(), static fn(MovementHeading $heading): bool => $heading !== MovementHeading::NONE)); + } + + /** Inline routes use the same schema/coercion as command records; the map remains their owner. */ + private function projectMovementRoute(array $route, ?array $ownerCommands = null): ProjectRecordDatabase + { + $schema = RecordSchemaCatalog::forKey('common_events') + ?? throw new \LogicException('The event command schema is unavailable.'); + $records = ProjectRecordDatabase::overOwnedList($schema, '', [['commands' => [$route]]], static function (array $entries): void {}, $this->graphical); + $records->useAuthoringReferences($ownerCommands === null ? $this->references + : $this->references->createMovementContext($ownerCommands)); + return $records; + } + + /** @return list> */ + private function buildMovementRouteFields(array $route, array $path, ?array $parentList): array + { + $fields = []; + $points = MovementRouteFields::getPointList($route, graphical: $this->graphical); + foreach ($this->projectMovementRoute($route)->getSettingsFields(0) as $row) { + if (! isset($row['dataPath']) || ($row['sourceKey'] ?? '') === 'type') { continue; } + $relative = array_slice($row['dataPath'], 2); + $row['path'] = [...$path, ...array_map('strval', $relative)]; + $row['movementRouteField'] = $row['field']; + $row['movementRoutePath'] = $path; + unset($row['mapPlacement']); + if (count($relative) > 1 && $points !== null) { + $row['movementRoutePoints'] = true; + $row['list'] = ['path' => [...$path, $points->key], 'index' => (int) $relative[1], 'blank' => $points->blank]; + } elseif ($parentList !== null) { $row['list'] = $parentList; } + $fields[] = $row; + } + if ($points !== null) { + $entries = $points->readEntries($route[$points->key] ?? []); + $fields[] = ['label' => ucfirst($points->key), 'value' => strval(count($entries)), 'editable' => false, + 'path' => [...$path, $points->key], 'movementRoutePath' => $path, 'movementRoutePoints' => true, + 'list' => ['path' => [...$path, $points->key], 'index' => count($entries) - 1, 'blank' => $points->blank]]; + } + return $fields; + } + + private function applyMovementRouteField(ProjectMap $map, array $field, string $raw): ?Command + { + $marker = (string) $field['marker']; + $path = (array) $field['movementRoutePath']; + $old = $map->getEventField($marker, $path); + if (! is_array($old) || ($old['type'] ?? '') !== 'move_route') { + throw new InspectorRefusal('That movement route is no longer present.'); + } + try { + $commands = MovementRouteFields::getOwnerCommands($map->getEventDefinition($marker) ?? [], $path); + $records = $this->projectMovementRoute($old, $commands); + $records->setField(0, (string) $field['movementRouteField'], $raw); + $next = $records->getRecordByIndex(0)?->getSubList('commands')[0] ?? $old; + } catch (\Ichiloto\Editor\Database\RecordRefusal|InvalidArgumentException $error) { + throw new InspectorRefusal($error->getMessage(), previous: $error); + } + if ($old === $next) { return null; } + $apply = static fn() => $map->setEventField($marker, $path, $next); + $apply(); + return new GenericCommand('Movement route edit', $apply, static fn() => $map->setEventField($marker, $path, $old)); + } + + /** The same actor/stage pickers and binding lists that Configuration and commands use. */ + private function buildInnPresentationFields(string $marker, mixed $value, array $presentationPath = ['data', 'presentation']): array + { + $fields = []; + foreach (InnPresentationFields::getFields($value, 'presentation', 'Rest Presentation', $this->graphical) as $field) { + $selected = $field->key === 'presentation' ? $value : (is_array($value) ? ($value['treatment'] ?? '') : ''); + $path = $field->key === 'presentation' ? $presentationPath : [...$presentationPath, 'treatment']; + $fields[] = [...$this->describeInnField($marker, $field, $selected, $path), 'innPresentationPath' => $presentationPath]; + } + $list = $this->graphical ? InnPresentationFields::getBindingList($value, 'presentation') : null; + if ($list === null) { return $fields; } + $entries = $list->readEntries($value[substr($list->key, strlen('presentation.'))] ?? []); + $context = ['key' => $list->key, 'index' => -1]; + $listPath = [...$presentationPath, substr($list->key, strlen('presentation.'))]; + $fields[] = ['label' => $list->heading, 'value' => (string) count($entries), 'editable' => false, + 'target' => 'event', 'marker' => $marker, 'path' => $listPath, 'innPresentationPath' => $presentationPath, + 'innPresentationList' => $context, 'list' => ['path' => $listPath, 'index' => -1]]; + foreach ($entries as $index => $entry) { + if (! is_array($entry)) { continue; } + foreach ($list->fieldsFor($entry) as $field) { + $row = $this->describeInnField($marker, $field, $entry[$field->key] ?? '', + [...$listPath, (string) $index, $field->key]); + $row['innPresentationPath'] = $presentationPath; + $row['innPresentationList'] = [...$context, 'index' => $index]; + $row['innEntryField'] = $field->key; + $row['list'] = ['path' => $listPath, 'index' => $index]; + $fields[] = $row; + } + } + return $fields; + } + + private function describeInnField(string $marker, RecordField $field, mixed $value, array $path): array + { + $display = $field->codec === RecordFieldCodec::CSV_LIST && is_array($value) ? implode(', ', $value) : ProjectRecord::stringify($value); + $row = ['label' => $field->label, 'value' => $display, 'selectedValue' => $display, 'path' => $path, + 'marker' => $marker, 'target' => 'event', 'innPresentation' => true, 'optional' => $field->allowsNone]; + if ($field->isReadOnly) { $row['editable'] = false; } + elseif ($field->reference !== null) { + $row['reference'] = $field->reference; + $row['multi'] = $field->codec === RecordFieldCodec::CSV_LIST; + } else { $row['options'] = $field->options; } + return $row; + } + + private function applyInnPresentationField(ProjectMap $map, array $field, string $raw): ?Command + { + if (($field['editable'] ?? true) === false) { throw new InspectorRefusal('Graphical rest bindings are preserved read-only in Terminal.'); } + $marker = (string) $field['marker']; + $presentationPath = $field['innPresentationPath']; + $old = $map->getEventField($marker, $presentationPath); + $record = new ProjectRecord(['presentation' => $old]); + if (isset($field['innEntryField'])) { + if (! $this->graphical) { throw new InspectorRefusal('Graphical rest bindings are not authored in Terminal.'); } + $list = InnPresentationFields::getBindingList($old, 'presentation'); + $index = (int) ($field['innPresentationList']['index'] ?? -1); + $entries = $list?->readEntries(is_array($old) ? ($old[substr($list->key, strlen('presentation.'))] ?? []) : []) ?? []; + if ($list === null || ! is_array($entries[$index] ?? null)) { throw new InspectorRefusal('That binding is no longer present.'); } + $key = (string) $field['innEntryField']; + $selected = ($field['multi'] ?? false) === true ? array_values(array_filter(array_map(trim(...), explode(',', $raw)), static fn(string $id): bool => $id !== '')) : trim($raw); + if ($key === $list->keyField && array_any($entries, static fn(array $entry, int $at): bool => $at !== $index && ($entry[$key] ?? null) === $selected)) { + throw new InspectorRefusal('That actor already has a leader binding.'); + } + $entries[$index][$key] = $selected; + $record->setEntries($list, $entries); + } else { + $path = array_slice((array) $field['path'], count($presentationPath)); + if ($path !== [] && ! $this->graphical) { throw new InspectorRefusal('Graphical rest bindings are not authored in Terminal.'); } + $record->set(implode('.', ['presentation', ...$path]), $raw === '' ? null : $raw); + } + return $this->writeInnPresentation($map, $marker, $record->get('presentation'), 'Rest presentation edit', $presentationPath); + } + + private function editInnPresentationList(ProjectMap $map, array $field, bool $remove): ?InspectorListEdit + { + if (! $this->graphical) { throw new InspectorRefusal('Graphical rest bindings are not authored in Terminal.'); } + $marker = (string) $field['marker']; + $presentationPath = $field['innPresentationPath']; + $value = $map->getEventField($marker, $presentationPath); + $list = InnPresentationFields::getBindingList($value, 'presentation'); + if ($list === null || $list->key !== ($field['innPresentationList']['key'] ?? null)) { throw new InspectorRefusal('That binding list is no longer present.'); } + $record = new ProjectRecord(['presentation' => $value]); + $entries = $record->getEntries($list); + $at = (int) $field['innPresentationList']['index']; + if ($remove) { + if (! array_key_exists($at, $entries)) { return null; } + array_splice($entries, $at, 1); + } else { + if ($list->keyField !== null && array_any($entries, static fn(array $entry): bool => ($entry[$list->keyField] ?? '') === '')) { + throw new InspectorRefusal('Finish choosing the unbound leader before adding another binding.'); + } + array_splice($entries, min(count($entries), $at + 1), 0, [$list->blank]); + } + $record->setEntries($list, $entries); + return new InspectorListEdit($this->writeInnPresentation($map, $marker, $record->get('presentation'), $remove ? 'Rest binding remove' : 'Rest binding add', $presentationPath), + $remove ? 'Removed rest binding.' : 'Added rest binding; choose its guests and stage before saving.'); + } + + private function writeInnPresentation(ProjectMap $map, string $marker, mixed $value, string $label, array $presentationPath): ?Command + { + try { InnPresentationFields::assertValid($value, $this->references, complete: false); } + catch (InvalidArgumentException $error) { throw new InspectorRefusal($error->getMessage(), previous: $error); } + $old = $map->getEventField($marker, $presentationPath); + if ($old === $value) { return null; } + $held = $map->hasEventField($marker, $presentationPath); + $apply = $value === null ? static fn() => $map->removeEventField($marker, $presentationPath) + : static fn() => $map->setEventField($marker, $presentationPath, $value); + $undo = $held ? static fn() => $map->setEventField($marker, $presentationPath, $old) + : static fn() => $map->removeEventField($marker, $presentationPath); + $apply(); + return new GenericCommand($label, $apply, $undo); + } + + /** + * The dialogue pages an event shows as the Engine reads them (a Dialogue event's, a shop's merchant lines), by + * their path under the event: each a line whose `name` speaks it and whose `emotion` picks the speaker's artwork. + * A list of conditional variants holds its pages under each variant's `lines`. + * + * @return array, page: array}> By the emotion row's path. + */ + private static function readDialoguePages(array $definition): array + { + $class = ltrim((string) ($definition['class'] ?? ''), '\\'); + $dialogue = $definition['data']['dialogue'] ?? null; + if (! in_array($class, [DialogueEventTrigger::class, ShopEventTrigger::class], true) || ! is_array($dialogue)) { + return []; + } + $pages = []; + $variants = ConditionalDialogue::isVariantList($dialogue); + foreach ($dialogue as $index => $entry) { + $lines = $variants ? ($entry['lines'] ?? null) : [$entry]; + foreach (is_array($lines) ? $lines : [] as $line => $page) { + if (! is_array($page)) { continue; } + $path = $variants ? ['data', 'dialogue', (string) $index, 'lines', (string) $line] : ['data', 'dialogue', (string) $index]; + $pages[implode('/', [...$path, 'emotion'])] = ['path' => $path, 'page' => $page]; + } + } + + return $pages; + } + + /** + * The event data as its rows show it: each dialogue page offers its emotion, Neutral where it names none, which + * stays out of the stored event until an author chooses one. + */ + private static function withPageEmotions(array $eventData, array $pages): array + { + $data = ['data' => $eventData]; + foreach ($pages as $page) { + $entry = &$data; + foreach ($page['path'] as $segment) { $entry = &$entry[$segment]; } + if (! array_key_exists('emotion', $entry)) { + // Shown after the speaker it is chosen for. + $position = array_search('name', array_keys($entry), true); + $at = $position === false ? 0 : $position + 1; + $entry = [...array_slice($entry, 0, $at, true), 'emotion' => DialogueContext::DEFAULT_EMOTION, ...array_slice($entry, $at, null, true)]; + } + unset($entry); + } + + return $data['data']; + } + + /** + * A dialogue page's emotion row offers the emotions its speaker's identity has, and says so when the page holds + * one that identity lacks; one the page leaves out is optional, removed again when chosen away. + * + * @param list> $fields + * @param array, page: array}> $pages + * @return list> + */ + private function describePageEmotions(array $fields, array $pages): array + { + if ($pages === []) { + return $fields; + } + $described = []; + foreach ($fields as $field) { + $page = $pages[implode('/', array_map(strval(...), (array) ($field['path'] ?? [])))] ?? null; + if ($page === null) { + $described[] = $field; + continue; + } + $emotion = $page['page']['emotion'] ?? null; + $field['optional'] = ! array_key_exists('emotion', $page['page']); + array_push($described, ...DialogueEmotionFields::describeRows($field, $emotion, + $this->references->describeDialogueEmotions(DialogueLineSpeaker::forText(), $page['page']))); + } + + return $described; + } + + /** + * Returns the modes a trigger class runs in, as the Engine exports them, + * or null for a class that reads no mode. + * + * @return list|null + */ + private static function listEventModes(?string $className): ?array + { + return match ($className) { + ScriptEventTrigger::class => ScriptEventTrigger::MODES, + CinematicCommandSchema::CINEMATIC_TRIGGER_CLASS => CinematicCommandSchema::CINEMATIC_TRIGGER_MODES, + default => null, + }; + } + + /** + * Adds event context, pickers, and enum controls to flattened fields. + * + * @param array> $fields The raw fields. + * @return array> + */ + private function decorateEventInspectorFields(string $marker, array $fields): array + { + $decorated = []; + // A chest's loot is chosen from the database its loot type names, as the runtime reads an unset type: an item. + $lootType = ChestLoot::readLootType(array_find($fields, + static fn(array $field): bool => array_values((array) ($field['path'] ?? [])) === ['data', 'lootType'])['value'] ?? null) ?? LootType::ITEM; + + foreach ($fields as $field) { + $field['marker'] = $marker; + $field['target'] = 'event'; + + $path = array_values((array) ($field['path'] ?? [])); + + $choices = match ($path) { + ['cue', 'kind'] => self::EVENT_CUE_KIND_CHOICES, + ['engagement'] => self::EVENT_ENGAGEMENT_CHOICES, + ['data', 'chestType'] => self::CHEST_TYPE_CHOICES, + ['data', 'lootType'] => self::listLootTypeChoices(), + default => null, + }; + + if ($choices !== null) { + // Picked or stepped through, never spelled. + $field['choices'] = $choices; + $field['options'] = array_column($choices, 'value'); + unset($field['control']); + } + + if ($path === ['data', 'destinationMap']) { + // Set with its spawn point, never alone ({@see setTransferDestination()}). + $field['destination'] = true; + } + + $reference = self::findEventReference($field, $lootType); + + if (is_array($reference)) { + // A reference is chosen, never spelled: dropping the control + // is what stops the field being typed into, leaving the + // picker as the only way to set it. + $field['reference'] = $reference['category']; + unset($field['control']); + } + + $decorated[] = $field; + } + + return $decorated; + } + + /** + * A data key as an author reads it: words split at underscores, hyphens + * and camel case, each capitalised (`spawnPoint` reads Spawn Point). + */ + private static function formatFieldLabel(string $part): string + { + return ucwords(str_replace(['_', '-'], ' ', (string) preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', ' ', $part))); + } + + /** + * Flattens nested scalar data into editable inspector fields. + * + * @param array $data The data to flatten. + * @param array $path The current path. + * @return array> + */ + private function flattenInspectorFields(array $data, array $path, ?array $list = null): array + { + $fields = []; + $definition = is_string($data['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$data['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $segments = explode('.', $field->key); + $value = new ProjectRecord($data)->get($field->key); + $fields = [...$fields, ...$this->buildInnPresentationFields('', $value, [...$path, ...$segments])]; + // This is only the display copy; the map owner retains every authored key. + $parent = &$data; + $present = true; + foreach (array_slice($segments, 0, -1) as $segment) { + if (! is_array($parent[$segment] ?? null)) { $present = false; break; } + $parent = &$parent[$segment]; + } + if ($present) { unset($parent[end($segments)]); } + unset($parent); + } + + if (($data['type'] ?? '') === 'move_route') { + $fields = [...$fields, ...$this->buildMovementRouteFields($data, $path, $list)]; + // Only the display projection is removed; unrecognised authored keys stay visible and intact. + foreach (MovementRouteFields::getFields($data) as $field) { unset($data[$field->key]); } + unset($data['steps'], $data['waypoints'], $data['retrace']); + } + + foreach ($data as $key => $value) { + $segment = (string) $key; + $nextPath = [...$path, $segment]; + + if (is_array($value)) { + // A list of entries -- a shop's stock, an event's dialogue -- + // is something an author adds to and removes from, so every + // field inside one remembers which list it belongs to. + if ($this->isInspectorListValue($nextPath, $value)) { + $label = implode(' ', array_map( + self::formatFieldLabel(...), + ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath + )); + $fields[] = [ + 'label' => sprintf('%s · %d', $label, count($value)), + 'value' => '', + 'editable' => false, + 'list' => [ + 'path' => $nextPath, + 'index' => max(0, count($value) - 1), + 'blank' => $this->blankInspectorListEntry($nextPath), + ], + ]; + + foreach ($value as $index => $entry) { + $entryPath = [...$nextPath, (string) $index]; + $entryList = ['path' => $nextPath, 'index' => (int) $index]; + + $fields = [ + ...$fields, + ...(is_array($entry) + ? $this->flattenInspectorFields($entry, $entryPath, $entryList) + : $this->flattenInspectorFields([(string) $index => $entry], $nextPath, $entryList)), + ]; + } + + continue; + } + + if (array_key_exists('x', $value) && array_key_exists('y', $value) && is_scalar($value['x']) && is_scalar($value['y'])) { + $label = implode(' ', array_map( + self::formatFieldLabel(...), + ($nextPath[0] ?? null) === 'data' ? array_slice($nextPath, 1) : $nextPath + )); + $fields[] = [ + 'label' => $label, + 'value' => '', + 'editable' => false, + ]; + $fields[] = [ + 'label' => ' X', + 'value' => (string) $value['x'], + 'control' => new InputControl( + is_int($value['x']) ? InputControlType::INTEGER : InputControlType::TEXT, + (string) $value['x'], + ), + 'path' => [...$nextPath, 'x'], + 'axis' => 'x', + ]; + $fields[] = [ + 'label' => ' Y', + 'value' => (string) $value['y'], + 'control' => new InputControl( + is_int($value['y']) ? InputControlType::INTEGER : InputControlType::TEXT, + (string) $value['y'], + ), + 'path' => [...$nextPath, 'y'], + 'axis' => 'y', + ]; + continue; + } + + $fields = [...$fields, ...$this->flattenInspectorFields($value, $nextPath, $list)]; + continue; + } + + if (! is_scalar($value) && $value !== null) { + continue; + } + + $displayPath = ($nextPath[0] ?? null) === 'data' + ? array_slice($nextPath, 1) + : $nextPath; + $label = implode(' ', array_map( + static fn(string $part): string => ctype_digit($part) + ? '#' . ((int) $part + 1) + : self::formatFieldLabel($part), + $displayPath + )); + $stringValue = match (true) { + is_bool($value) => $value ? 'true' : 'false', + is_float($value) => InputControl::formatFloat($value), + default => (string) $value, + }; + $controlType = match (true) { + is_bool($value) => InputControlType::BOOLEAN, + is_int($value) => InputControlType::INTEGER, + is_float($value) => InputControlType::FLOAT, + default => InputControlType::TEXT, + }; + $leaf = [ + 'label' => $label, + 'value' => $stringValue, + 'control' => new InputControl($controlType, $stringValue), + 'path' => $nextPath, + ]; + + if (is_array($list)) { + $leaf['list'] = $list; + } + + $fields[] = $leaf; + } + + return $fields; + } + + /** + * Distinguishes authored lists from associative configuration blocks. + * + * Empty arrays need an explicit known-list name because PHP cannot tell + * an empty list from an empty map. + * + * @param array $path The candidate path. + * @param array $value The candidate value. + */ + private function isInspectorListValue(array $path, array $value): bool + { + if (! array_is_list($value) || array_key_exists('x', $value)) { + return false; + } + + if ($value !== []) { + return true; + } + + return in_array( + (string) ($path[array_key_last($path)] ?? ''), + ['conditions', 'sets', 'dialogue', 'items', 'script', 'steps', 'options'], + true, + ); + } + + /** + * Returns the structured first row for an empty inspector list. + * + * @param array $path The list path. + * @return array + */ + private function blankInspectorListEntry(array $path): array + { + return match ((string) ($path[array_key_last($path)] ?? '')) { + 'conditions' => ['type' => 'switch', 'name' => '', 'value' => true], + 'sets' => ['type' => 'switch', 'name' => '', 'value' => true], + 'script' => ['type' => 'text', 'name' => '', 'text' => ''], + 'steps' => ['direction' => 'down', 'count' => 1, 'faceOnly' => false], + 'options' => ['text' => '', 'then' => []], + 'items' => ['item' => '', 'price' => 0], + default => ['name' => '', 'text' => ''], + }; + } + +} diff --git a/src/Inspector/MapKindField.php b/src/Inspector/MapKindField.php new file mode 100644 index 00000000..31502324 --- /dev/null +++ b/src/Inspector/MapKindField.php @@ -0,0 +1,130 @@ +referenceCatalog()->labelsFor('tilesets'); + if ($kinds === []) { + $this->createNewMap(); + return; + } + $this->optionDialogField = ['newMapKind' => true]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = 'New map kind'; + $this->eventOptionDialogEntries = array_map(static fn(string $id, string $name): array => + ['label' => $name, 'value' => $id, 'description' => sprintf('Draws from assets/%s/%s.php.', Tileset::DIRECTORY, $id)], + array_keys($kinds), $kinds); + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = 'Choose the kind of map to create. Its tiles and pieces come from it.'; + $this->renderSelectionDependentArea(); + } + + /** @return array The Kind row for the Inspector. */ + private function buildMapKindField(ProjectMap $map): array + { + return $this->createMapInspector()->buildMapKindField($map); + } + + /** + * Makes the selected map the chosen kind. A map without a kind keeps + * whatever tiles it has, which now draw from that kind; a map changing + * from one kind to another with tiles asks first. + */ + private function chooseMapKind(string $id, string $label): void + { + $map = $this->getSelectedMap(); + if (! $map instanceof ProjectMap) { + return; + } + $current = $map->getMapDataField(['tileset']); + if ($current === $id) { + $this->setStatus(sprintf('%s\'s kind is already %s.', $map->getDisplayName(), $label)); + $this->renderSelectionDependentArea(); + return; + } + $layers = $this->createMapInspector()->countTileLayersClearedBy($map, $id); + if ($layers === 0) { + $this->changeMapKind($map, $id, $label, false); + return; + } + + $currentLabel = $this->referenceCatalog()->labelsFor('tilesets')[(string) $current] ?? (string) $current; + $this->optionDialogField = ['mapKindChange' => ['id' => $id, 'label' => $label]]; + $this->eventOptionDialogMarker = null; + $this->eventOptionDialogPath = null; + $this->eventOptionDialogTitle = sprintf('Change %s\'s kind to %s', $map->getDisplayName(), $label); + $this->eventOptionDialogEntries = [ + ['label' => 'Cancel', 'value' => 'cancel', 'description' => sprintf('Keep its kind, %s, and its tiles.', $currentLabel)], + ['label' => sprintf('Clear %d tile %s and change', $layers, $layers === 1 ? 'layer' : 'layers'), 'value' => 'clear', + 'description' => sprintf('Its tiles come from %s and would show the wrong art as %s. Glyphs stay%s. Ctrl+Z restores them.', + $currentLabel, $label, $this->createMapInspector()->isKindChangeLeavingTerrainKinds($map, $id) + ? sprintf(', and with no terrain kinds in %s they block or let pass as the collision dictionary says', $label) : '')], + ]; + $this->selectedEventOptionIndex = 0; + $this->dialogFilter->clear(); + $this->isEventOptionDialogOpen = true; + $this->statusMessage = sprintf('Confirm the change: it clears the map\'s %s tiles.', $currentLabel); + $this->renderSelectionDependentArea(); + } + + /** @param array{id: string, label: string} $change */ + private function confirmMapKindChange(array $change, bool $confirmed): void + { + $this->closeEventOptionDialog(); + $map = $this->getSelectedMap(); + if (! $confirmed || ! $map instanceof ProjectMap) { + $this->setStatus('Kind unchanged.'); + return; + } + $this->changeMapKind($map, $change['id'], $change['label'], true); + } + + /** + * Sets the map's kind, clearing its tile layers and their settings when + * asked, as one undo step ({@see MapInspector::changeMapKind()}). + */ + private function changeMapKind(ProjectMap $map, string $id, string $label, bool $clearTiles): void + { + $oldSources = $map->getTileLayerSources(); + try { + $command = $this->createMapInspector()->changeMapKind($map, $id, $clearTiles); + } catch (Throwable $failure) { + $this->setErrorStatus($failure, 'Kind change'); + $this->renderSelectionDependentArea(); + return; + } + if ($command !== null) { + $this->recordCommand($command); + } + $this->setStatus(sprintf('%s\'s kind is now %s%s; save to keep it.', $map->getDisplayName(), $label, + $clearTiles ? sprintf(', and its %d tile %s cleared', count($oldSources), count($oldSources) === 1 ? 'layer is' : 'layers are') : ''), + StatusLevel::INFO); + $this->renderSelectionDependentArea(); + } +} diff --git a/src/Inspector/MapLandmarkFields.php b/src/Inspector/MapLandmarkFields.php new file mode 100644 index 00000000..92db6906 --- /dev/null +++ b/src/Inspector/MapLandmarkFields.php @@ -0,0 +1,407 @@ +> */ + public function describeMapFields(ProjectMap $map): array + { + try { + $landmarks = MapLandmarks::readMap($map); + $anchors = $landmarks->describeAnchors(); + } catch (InvalidArgumentException $error) { + return [['label' => 'Minimap Landmarks', 'value' => '', 'editable' => false], + ['label' => ' ! Read-only', 'value' => $error->getMessage(), 'editable' => false]]; + } + $automatic = count(array_filter($anchors, static fn(array $anchor): bool => $anchor['transfer'] && $anchor['landmark'] === null)); + $count = count($landmarks->entries); + $fields = [ + ['label' => 'Minimap Landmarks', 'value' => '', 'editable' => false], + // Every transfer no landmark describes is still marked, without a name. + ['label' => ' Unnamed Route Markers', 'value' => (string) $automatic, 'editable' => false], + // The list's own heading, so adding works on an empty list as on every other inspector list. + ['label' => sprintf(' Landmarks · %d', $count), 'value' => '', 'editable' => false, + 'target' => self::TARGET, 'landmarkList' => ['index' => max(0, $count - 1)]], + ]; + $problem = $this->findCurrentProblem($map, $landmarks->entries); + if ($problem !== null) { + $fields[] = ['label' => ' ! Problem', 'value' => $problem, 'editable' => false]; + } + foreach ($landmarks->entries as $index => $entry) { + array_push($fields, ...$this->describeEntryFields($landmarks, $anchors, $index, $entry)); + } + return $fields; + } + + /** + * An event's minimap row: what the event is on the minimap, chosen among the kinds it may be. A transfer that + * no landmark describes still shows as an unnamed route; choosing a kind describes it, and choosing the first + * option removes the description. An event the map's resolver gives no cells (neither painted nor its + * subject's) has nothing to mark. + * + * @return list> + */ + public function describeEventFields(ProjectMap $map, string $marker): array + { + $resolved = array_any(MapLandmarks::readResolvedSource($map)['data']['events'], + static fn(array $event): bool => ($event['marker'] ?? null) === $marker); + + return $resolved ? $this->describeAnchorFields($map, ['event' => $marker]) : []; + } + + /** + * A world object's minimap row, as an event's: a building or a landmark once described. + * + * @return list> + */ + public function describeObjectFields(ProjectMap $map, string $id): array + { + return $this->describeAnchorFields($map, ['object' => $id]); + } + + /** + * @param array $anchor + * @return list> + */ + private function describeAnchorFields(ProjectMap $map, array $anchor): array + { + $key = isset($anchor['event']) ? ['target' => self::EVENT_TARGET, 'marker' => $anchor['event']] + : ['target' => self::OBJECT_TARGET, 'path' => [$anchor['object']]]; + try { + $landmarks = MapLandmarks::readMap($map); + } catch (InvalidArgumentException $error) { + return [['label' => 'Minimap', 'value' => '! ' . $error->getMessage(), 'editable' => false]]; + } + $index = $landmarks->findAnchorIndex($anchor); + $route = $landmarks->isRouteAnchor($anchor); + $choices = [['label' => $route ? 'Unnamed route marker' : 'Not on the minimap', 'value' => self::UNDESCRIBED, 'description' => $route + ? 'Every transfer is marked at once; describe it to name it or keep it secret.' + : 'Only described places are marked.']]; + foreach ($landmarks->findKindsFor($anchor) as $kind) { + $choices[] = ['label' => self::describeKind($kind), 'value' => $kind->value, 'description' => self::explainKind($kind)]; + } + $entry = $index === null ? null : $landmarks->entries[$index]; + // A short enum: picked or stepped through, as an event's cue kind is. + $fields = [['label' => 'Minimap', 'value' => is_string($entry['kind'] ?? null) ? $entry['kind'] : self::UNDESCRIBED, + 'editable' => true, 'choices' => $choices, 'options' => array_column($choices, 'value'), ...$key]]; + if ($entry !== null) { + $fields[] = ['label' => ' Landmark', 'value' => sprintf('%s · %s', $entry['id'] ?? '?', self::describeName($entry)), 'editable' => false]; + } + return $fields; + } + + /** + * The declarations an edit to one of these rows leaves, or null when it changes nothing. + * + * @param array $field + * @return list>|null + * @throws InvalidArgumentException When the edit cannot be made as asked. + */ + public function applyValue(ProjectMap $map, array $field, mixed $value): ?array + { + $landmarks = MapLandmarks::readMap($map); + $anchor = match ($field['target'] ?? null) { + self::EVENT_TARGET => ['event' => (string) $field['marker']], + self::OBJECT_TARGET => ['object' => (string) ($field['path'][0] ?? '')], + default => null, + }; + if ($anchor !== null) { + $index = $landmarks->findAnchorIndex($anchor); + if ($value === self::UNDESCRIBED) { + if ($index === null) { return null; } + $this->assertUnreferenced($map, $landmarks->entries[$index]); + return $landmarks->removeLandmark($index); + } + $kind = self::requireKind($landmarks, $anchor, (string) $value); + return $index === null ? $landmarks->addLandmark($anchor, $kind)['entries'] : $landmarks->setKind($index, $kind); + } + $index = (int) ($field['index'] ?? -1); + $entry = $landmarks->entries[$index] ?? throw new InvalidArgumentException('That landmark is no longer here; read the inspector again.'); + $anchor = MapLandmarks::readAnchor($entry); + return match ((string) ($field['field'] ?? '')) { + 'anchor' => $landmarks->setAnchor($index, MapLandmarks::decodeAnchor((string) $value)), + 'kind' => $landmarks->setKind($index, self::requireKind($landmarks, $anchor + ?? throw new InvalidArgumentException('Choose this landmark\'s anchor first.'), (string) $value)), + 'category' => $landmarks->setCategory($index, $this->requireCategory((string) $value)), + 'name' => $landmarks->setName($index, (string) $value), + 'nameMessage' => $landmarks->setNameMessage($index, (string) $value), + 'secret' => $landmarks->setSecret($index, (bool) $value), + 'approach' => $value === true + ? (array_key_exists('approachDistance', $entry) && $entry['approachDistance'] === null ? $landmarks->resetApproachDistance($index) : null) + : $landmarks->setApproachDistance($index, null), + 'approachDistance' => $landmarks->setApproachDistance($index, self::requireDistance($value)), + 'revealConditions' => $landmarks->setRevealConditions($index, is_array($value) ? $value : []), + // A rename reaches other maps too, so it is never one map's declarations alone. + self::ID_FIELD => throw new LogicException('A landmark is renamed through renameEntry(), with the maps that name it.'), + default => null, + }; + } + + /** + * What renaming a landmark leaves: the map's declarations, and the other maps whose atlas entry it is, which + * follow the rename in the same step. Null when the id is unchanged. + * + * @param array $field The landmark's id row. + * @return array{entries: list>, from: ?string, to: string, children: list}|null + * @throws InvalidArgumentException When the landmark is gone or the id is refused. + */ + public function renameEntry(ProjectMap $map, array $field, string $id): ?array + { + $landmarks = MapLandmarks::readMap($map); + $index = (int) ($field['index'] ?? -1); + $entry = $landmarks->entries[$index] ?? throw new InvalidArgumentException('That landmark is no longer here; read the inspector again.'); + $from = is_string($entry['id'] ?? null) ? $entry['id'] : null; + $entries = $landmarks->setId($index, $id); + $to = (string) $entries[$index]['id']; + if ($to === $from) { return null; } + return ['entries' => $entries, 'from' => $from, 'to' => $to, + 'children' => $from === null ? [] : MapAtlas::findChildMaps($this->references->listMaps(), $map->mapId, $from)]; + } + + /** + * Describes the next place no landmark describes yet, in map order: events, then world objects, then legacy + * transfers. Its anchor row then chooses any other. + * + * @return array{entries: list>, summary: string} + */ + public function addEntry(ProjectMap $map): array + { + $landmarks = MapLandmarks::readMap($map); + $anchors = $landmarks->describeAnchors(); + $free = array_find($anchors, static fn(array $anchor): bool => $anchor['landmark'] === null) + ?? throw new InvalidArgumentException($anchors === [] + ? 'This map has no placed event, world object, area or legacy transfer to mark.' + : 'Every event, world object and legacy transfer here already anchors a landmark.'); + $added = $landmarks->addLandmark($free['anchor']); + return ['entries' => $added['entries'], 'summary' => sprintf('Added landmark %s on %s; choose its anchor and kind.', $added['id'], $free['label'])]; + } + + /** + * @param array $field + * @return array{entries: list>, summary: string} + */ + public function removeEntry(ProjectMap $map, array $field): array + { + $landmarks = MapLandmarks::readMap($map); + $index = (int) ($field['landmarkList']['index'] ?? -1); + $entry = $landmarks->entries[$index] ?? throw new InvalidArgumentException('There is no landmark here to remove.'); + $this->assertUnreferenced($map, $entry); + $anchor = MapLandmarks::readAnchor($entry); + $route = $anchor !== null && $landmarks->isRouteAnchor($anchor); + return ['entries' => $landmarks->removeLandmark($index), 'summary' => sprintf('Removed landmark %s%s.', $entry['id'] ?? $index + 1, + $route ? '; its route shows as an unnamed marker again' : '')]; + } + + /** + * Why the map's declarations would not load, by the Engine's own check against these place categories, or null + * when they would. + * + * @param list> $entries + */ + public static function findProblem(ProjectMap $map, array $entries, ?MapCategoryCatalog $categories = null): ?string + { + try { + MapLandmarks::validateEntries($map, $entries, $categories); + return null; + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + } + + /** + * Why the declarations would not load beside the project's place categories as they stand, or null. + * + * @param list> $entries + */ + public function findCurrentProblem(ProjectMap $map, array $entries): ?string + { + try { + $categories = $this->references->getMapCategoryCatalog(); + } catch (InvalidArgumentException $error) { + return 'Place categories cannot be read: ' . $error->getMessage(); + } + return self::findProblem($map, $entries, $categories); + } + + /** + * Refuses to remove a landmark another map names as its atlas entry: that map would lose its place above. + * + * @param array $entry + */ + private function assertUnreferenced(ProjectMap $map, array $entry): void + { + $id = $entry['id'] ?? null; + if (!is_string($id)) { return; } + $references = MapAtlas::describeParentReferences($this->references->listMaps(), $map->mapId, $id); + if ($references !== []) { + throw new InvalidArgumentException(sprintf('Landmark %s is %s; choose another entry there first.', $id, implode(', ', $references))); + } + } + + /** A chosen place category: none, or one the Engine's or the project's catalogue declares. */ + private function requireCategory(string $value): ?string + { + if ($value === '') { return null; } + if (!in_array($value, $this->references->getMapCategoryCatalog()->getIds(), true)) { + throw new InvalidArgumentException(sprintf('%s is not a place category this project declares.', $value)); + } + return $value; + } + + /** + * The place categories a landmark may have, generic ones first, each by its name; a category the landmark names + * that no longer exists stays listed, said so. + * + * @return list + */ + private function describeCategoryChoices(string $current): array + { + $choices = [['label' => '(none)', 'value' => '', 'description' => 'The marker follows the kind.']]; + try { + $catalog = $this->references->getMapCategoryCatalog(); + } catch (InvalidArgumentException $error) { + return [...$choices, ...($current === '' ? [] : [['label' => $current, 'value' => $current, 'description' => $error->getMessage()]])]; + } + foreach ($catalog->getIds() as $id) { + $base = $catalog->getBase($id); + $choices[] = ['label' => $catalog->getName($id), 'value' => $id, 'description' => $base->value === $id + ? 'A place category every game has.' : sprintf('This project\'s own category; shown as %s where it has no marker of its own.', $catalog->getName($base->value))]; + } + if ($current !== '' && !in_array($current, array_column($choices, 'value'), true)) { + $choices[] = ['label' => $current . ' · not declared', 'value' => $current, 'description' => 'Choose a declared category or none.']; + } + return $choices; + } + + /** + * @param list, value: string, label: string, transfer: bool, landmark: ?string}> $anchors + * @return list> + */ + private function describeEntryFields(MapLandmarks $landmarks, array $anchors, int $index, array $entry): array + { + $row = static fn(array $field): array => [...$field, 'target' => self::TARGET, 'index' => $index, 'landmarkList' => ['index' => $index]]; + $id = is_string($entry['id'] ?? null) ? $entry['id'] : sprintf('(entry %d has no id)', $index + 1); + $anchor = MapLandmarks::readAnchor($entry); + $anchorValue = $anchor === null ? '' : MapLandmarks::encodeAnchor($anchor); + $anchorChoices = array_map(static fn(array $choice): array => ['label' => $choice['label'], 'value' => $choice['value'], + 'description' => $choice['transfer'] ? 'A route: a passage, entrance or exit.' : 'A building or a landmark.'], + array_values(array_filter($anchors, static fn(array $choice): bool => $choice['landmark'] === null || $choice['value'] === $anchorValue))); + $kinds = $anchor === null ? [] : $landmarks->findKindsFor($anchor); + $secret = ($entry['secret'] ?? false) === true; + $named = isset($entry['name']); + $conditions = is_array($entry['revealConditions'] ?? null) ? $entry['revealConditions'] : []; + $fields = [ + $row(['label' => ' Landmark ' . $id, 'value' => $anchor === null ? '(no anchor)' : $landmarks->describeAnchor($anchor), 'editable' => false]), + // The identity saves remember discoveries by and other maps name as their atlas entry, which follow a rename. + $row(['label' => ' Id', 'value' => is_string($entry['id'] ?? null) ? $entry['id'] : '', + 'control' => new InputControl(InputControlType::TEXT, is_string($entry['id'] ?? null) ? $entry['id'] : ''), 'field' => self::ID_FIELD]), + $row(['label' => ' Anchor', 'value' => $anchorValue, 'editable' => true, 'choices' => $anchorChoices, 'field' => 'anchor']), + $row(['label' => ' Kind', 'value' => (string) ($entry['kind'] ?? ''), 'editable' => true, 'field' => 'kind', + 'choices' => array_map(static fn(MapLandmarkKind $kind): array => ['label' => self::describeKind($kind), + 'value' => $kind->value, 'description' => self::explainKind($kind)], $kinds), + 'options' => array_map(static fn(MapLandmarkKind $kind): string => $kind->value, $kinds)]), + $row(['label' => ' Category', 'value' => is_string($entry['category'] ?? null) ? $entry['category'] : '', 'editable' => true, + 'field' => 'category', 'choices' => $this->describeCategoryChoices(is_string($entry['category'] ?? null) ? $entry['category'] : '')]), + $row(['label' => ' Name', 'value' => $named ? (string) ($entry['name']['default'] ?? '') : '(unnamed · the minimap shows no label)', + 'control' => new InputControl(InputControlType::TEXT, $named ? (string) ($entry['name']['default'] ?? '') : ''), 'field' => 'name']), + ]; + if ($named) { + $fields[] = $row(['label' => ' Name Translation Key', 'value' => (string) ($entry['name']['message'] ?? ''), + 'control' => new InputControl(InputControlType::TEXT, (string) ($entry['name']['message'] ?? '')), 'field' => 'nameMessage']); + } + $fields[] = $row(['label' => ' Secret', 'value' => $secret ? 'yes · hidden until explicitly revealed' : 'no', + 'control' => new InputControl(InputControlType::BOOLEAN, $secret ? 'true' : 'false'), 'field' => 'secret']); + if (!$secret) { + $distance = array_key_exists('approachDistance', $entry) ? $entry['approachDistance'] : MapLandmarkCatalog::DEFAULT_APPROACH_DISTANCE; + $fields[] = $row(['label' => ' Learned On Approach', 'value' => $distance === null ? 'no · only by its reveal conditions' : 'yes', + 'control' => new InputControl(InputControlType::BOOLEAN, $distance === null ? 'false' : 'true'), 'field' => 'approach']); + if ($distance !== null) { + $fields[] = $row(['label' => ' Approach Distance (cells)', 'value' => array_key_exists('approachDistance', $entry) + ? (string) $distance : sprintf('(engine default: %d)', MapLandmarkCatalog::DEFAULT_APPROACH_DISTANCE), + 'control' => new InputControl(InputControlType::INTEGER, (string) $distance), 'field' => 'approachDistance']); + } + } + $fields[] = $row(['label' => ' Revealed When', 'value' => $conditions === [] + ? ($secret ? '(no conditions · stays hidden)' : '(no conditions · learned on approach only)') + : ConditionCodec::encodeAll($conditions), + 'field' => 'revealConditions', 'mapConditions' => true, 'encoded' => ConditionCodec::encodeAll($conditions), + 'conditionsTitle' => sprintf('Landmark %s Reveal Conditions', $id), + 'conditionsHint' => 'All conditions must hold to reveal the landmark. An empty list removes them.']); + return $fields; + } + + private static function requireDistance(mixed $value): int + { + if (!is_int($value) || $value < MapLandmarkCatalog::MIN_APPROACH_DISTANCE || $value > MapLandmarkCatalog::MAX_APPROACH_DISTANCE) { + throw new InvalidArgumentException(sprintf('An approach distance is %d to %d cells.', + MapLandmarkCatalog::MIN_APPROACH_DISTANCE, MapLandmarkCatalog::MAX_APPROACH_DISTANCE)); + } + return $value; + } + + /** @param array $anchor */ + private static function requireKind(MapLandmarks $landmarks, array $anchor, string $value): MapLandmarkKind + { + $kind = MapLandmarkKind::tryFrom($value); + if ($kind === null || !in_array($kind, $landmarks->findKindsFor($anchor), true)) { + throw new InvalidArgumentException(sprintf('%s cannot be a %s; choose one of: %s.', $landmarks->describeAnchor($anchor), $value, + implode(', ', array_map(static fn(MapLandmarkKind $kind): string => $kind->value, $landmarks->findKindsFor($anchor))))); + } + return $kind; + } + + private static function describeName(array $entry): string + { + return isset($entry['name']['default']) ? (string) $entry['name']['default'] : 'unnamed'; + } + + private static function describeKind(MapLandmarkKind $kind): string + { + return ucfirst($kind->value); + } + + private static function explainKind(MapLandmarkKind $kind): string + { + return match ($kind) { + MapLandmarkKind::PASSAGE => 'A way through, marked at once.', + MapLandmarkKind::ENTRANCE => 'A way in, marked at once.', + MapLandmarkKind::EXIT => 'A way out, marked at once.', + MapLandmarkKind::BUILDING => 'A building, marked once learned.', + MapLandmarkKind::LANDMARK => 'A notable place, marked once learned.', + }; + } +} diff --git a/src/Inspector/MapSpeakerFields.php b/src/Inspector/MapSpeakerFields.php new file mode 100644 index 00000000..724281f2 --- /dev/null +++ b/src/Inspector/MapSpeakerFields.php @@ -0,0 +1,187 @@ +> + */ + public static function describeEventFields(ProjectMap $map, string $marker): array + { + $definition = $map->getEventDefinition($marker); + if ($definition === null) { + return []; + } + $speakers = $definition['speakers'] ?? []; + if (! is_array($speakers) || ! array_is_list($speakers) || array_filter($speakers, is_string(...)) !== $speakers) { + return [['label' => 'Speakers', 'value' => '! Not a list of NPC ids; fix it in the source.', 'editable' => false]]; + } + $choices = self::describeChoices($map); + $fields = [['label' => sprintf('Speakers · %d', count($speakers)), + 'value' => $speakers === [] ? ($choices === [] ? 'No NPC on this map has an id' : 'No one speaks it') : '', + 'editable' => false, 'target' => self::TARGET, 'marker' => $marker, 'speakerList' => ['index' => max(0, count($speakers) - 1)]]]; + foreach ($speakers as $index => $id) { + $known = array_find($choices, static fn(array $choice): bool => $choice['value'] === $id); + $fields[] = ['label' => ' Speaker', 'value' => $id, 'editable' => true, + 'choices' => $known === null ? [...$choices, ['label' => $id . ' (not on this map)', 'value' => $id, + 'description' => 'No NPC on this map has this id.']] : $choices, + 'target' => self::TARGET, 'marker' => $marker, 'field' => self::SPEAKER_FIELD, 'index' => $index, + 'speakerList' => ['index' => $index]]; + } + + return $fields; + } + + /** + * Names another speaker: the nearest NPC to the event not already named, + * for the author to change. + * + * @param array $field + * @throws InspectorRefusal When every NPC with an id already speaks it, or there is none. + */ + public static function addSpeaker(ProjectMap $map, array $field): InspectorListEdit + { + $marker = (string) $field['marker']; + $speakers = self::readSpeakers($map, $marker); + if (count($speakers) >= MapEventDefinition::MAX_SPEAKERS) { + throw new InspectorRefusal(sprintf('An event names at most %d speakers.', MapEventDefinition::MAX_SPEAKERS)); + } + $cells = $map->getEventTriggerArea($marker)?->cells ?? []; + $candidates = []; + foreach ($map->getNpcs()->all() as $npc) { + $id = $npc->getId(); + if ($id === null || in_array($id, $speakers, true)) { + continue; + } + $distance = $cells === [] ? 0 : min(array_map(static fn(array $cell): int + => max(abs($cell[0] - $npc->getX()), abs($cell[1] - $npc->getY())), $cells)); + $candidates[$id] = min($candidates[$id] ?? PHP_INT_MAX, $distance); + } + if ($candidates === []) { + throw new InspectorRefusal($speakers === [] ? 'No NPC on this map has an id to name as a speaker.' + : 'Every NPC with an id already speaks this event.'); + } + asort($candidates); + $id = (string) array_key_first($candidates); + + return new InspectorListEdit(self::writeSpeakers($map, $marker, [...$speakers, $id], 'Speaker add'), + sprintf('Added speaker %s; choose another NPC on its row.', $id)); + } + + /** + * @param array $field + * @throws InspectorRefusal When the row's speaker is gone. + */ + public static function removeSpeaker(ProjectMap $map, array $field): InspectorListEdit + { + $marker = (string) $field['marker']; + $speakers = self::readSpeakers($map, $marker); + $index = (int) ($field['index'] ?? -1); + if (! array_key_exists($index, $speakers)) { + throw new InspectorRefusal('That speaker is no longer on the event; read it again.'); + } + $removed = $speakers[$index]; + array_splice($speakers, $index, 1); + + return new InspectorListEdit(self::writeSpeakers($map, $marker, $speakers, 'Speaker remove'), sprintf('Removed speaker %s.', $removed)); + } + + /** + * Chooses the NPC a speaker row names. Null when it already names it. + * + * @param array $field + * @throws InspectorRefusal When the choice is not an NPC id on the map, or already speaks it. + */ + public static function applyValue(ProjectMap $map, array $field, string $value): ?Command + { + $marker = (string) $field['marker']; + $speakers = self::readSpeakers($map, $marker); + $index = (int) ($field['index'] ?? -1); + if (($field['field'] ?? null) !== self::SPEAKER_FIELD || ! array_key_exists($index, $speakers)) { + throw new InspectorRefusal('That speaker is no longer on the event; read it again.'); + } + $id = trim($value); + if ($speakers[$index] === $id) { + return null; + } + if ($map->getNpcs()->indexOfId($id) === null) { + throw new InspectorRefusal(sprintf('No NPC on %s has the id "%s".', $map->mapId, $id)); + } + if (in_array($id, $speakers, true)) { + throw new InspectorRefusal(sprintf('%s already speaks this event.', $id)); + } + $speakers[$index] = $id; + + return self::writeSpeakers($map, $marker, $speakers, 'Speaker edit'); + } + + /** @return list One choice per NPC id, in map order. */ + private static function describeChoices(ProjectMap $map): array + { + $choices = []; + foreach ($map->getNpcs()->all() as $npc) { + $id = $npc->getId(); + if ($id === null || isset($choices[$id])) { + continue; + } + $choices[$id] = ['label' => sprintf('%s (%s)', $npc->getName(), $id), 'value' => $id, + 'description' => sprintf('Stands at %d,%d.', $npc->getX(), $npc->getY())]; + } + + return array_values($choices); + } + + /** + * @return list + * @throws InspectorRefusal When the event holds speakers the editor cannot read as a list of ids. + */ + private static function readSpeakers(ProjectMap $map, string $marker): array + { + $definition = $map->getEventDefinition($marker) ?? throw new InspectorRefusal(sprintf('There is no event %s to name speakers for.', $marker)); + $speakers = $definition['speakers'] ?? []; + if (! is_array($speakers) || ! array_is_list($speakers) || array_filter($speakers, is_string(...)) !== $speakers) { + throw new InspectorRefusal(sprintf('Event %s speakers are not a list of NPC ids; fix them in the source first.', $marker)); + } + + return $speakers; + } + + /** @param list $speakers An empty list drops the key, as the event was before one was named. */ + private static function writeSpeakers(ProjectMap $map, string $marker, array $speakers, string $label): Command + { + $held = $map->hasEventField($marker, ['speakers']); + $old = $map->getEventField($marker, ['speakers']); + $apply = $speakers === [] ? static fn() => $map->removeEventField($marker, ['speakers']) + : static fn() => $map->setEventField($marker, ['speakers'], $speakers); + $restore = $held ? static fn() => $map->setEventField($marker, ['speakers'], $old) + : static fn() => $map->removeEventField($marker, ['speakers']); + $apply(); + + return new GenericCommand($label, $apply, $restore); + } +} diff --git a/src/Inspector/MapStationField.php b/src/Inspector/MapStationField.php new file mode 100644 index 00000000..475abc4c --- /dev/null +++ b/src/Inspector/MapStationField.php @@ -0,0 +1,86 @@ + int, 'y' => int]`, both or neither. + * A map without one is placed from the direction its doors lead. The row is + * one `x, y` pair so the two are always written together, and an empty value + * takes the pin away. + */ +final class MapStationField +{ + public const string TARGET = 'map-station'; + + /** The map data key RegionMap reads. */ + public const string KEY = 'station'; + + private function __construct() {} + + /** @return array */ + public static function describeField(ProjectMap $map): array + { + $station = $map->getMapDataField([self::KEY]); + $pair = self::readPair($station); + $raw = $pair === null ? '' : sprintf('%d, %d', $pair[0], $pair[1]); + + return [ + 'label' => 'Region Map Station', + 'value' => match (true) { + $station === null => '(placed from its doors)', + $pair === null => '! not an x/y pair the region map reads · ' . get_debug_type($station), + default => $raw, + }, + 'control' => new InputControl(InputControlType::TEXT, $raw), + 'target' => self::TARGET, + 'field' => self::KEY, + ]; + } + + /** + * The station an edit leaves: null to take it away, or the pair with any + * other authored keys kept. + * + * @return array|null + * @throws InvalidArgumentException When the value is not two whole numbers. + */ + public static function applyValue(ProjectMap $map, string $value): ?array + { + $value = trim($value); + + if ($value === '') { + return null; + } + + $parts = array_map(trim(...), explode(',', $value)); + + if (count($parts) !== 2 || ! array_all($parts, static fn(string $part): bool => preg_match('/^-?\d+$/', $part) === 1)) { + throw new InvalidArgumentException(sprintf('A station is two whole numbers, x and y, separated by a comma; "%s" is not.', $value)); + } + + $current = $map->getMapDataField([self::KEY]); + + return [...(is_array($current) ? $current : []), 'x' => (int) $parts[0], 'y' => (int) $parts[1]]; + } + + /** + * The cell a station names, as RegionMap::stationOf reads it: both + * coordinates set, each read as a whole number, or null. + * + * @return array{0: int, 1: int}|null + */ + private static function readPair(mixed $station): ?array + { + if (! is_array($station) || ! isset($station['x'], $station['y'])) { + return null; + } + + return [intval($station['x']), intval($station['y'])]; + } +} diff --git a/src/Inspector/MapTerrainFields.php b/src/Inspector/MapTerrainFields.php new file mode 100644 index 00000000..71c51473 --- /dev/null +++ b/src/Inspector/MapTerrainFields.php @@ -0,0 +1,122 @@ +> */ + public static function describeMapFields(ProjectMap $map): array + { + try { + $block = MapAtlas::readBlock($map); + $layers = $map->getLayerSet()->layers; + } catch (Throwable $error) { + return [['label' => 'Terrain', 'value' => '', 'editable' => false], + ['label' => ' ! Read-only', 'value' => $error->getMessage(), 'editable' => false]]; + } + $terrain = MapAtlas::readTerrain($block); + $fields = [['label' => 'Terrain', 'value' => '', 'editable' => false]]; + $problem = self::findProblem($map, $block); + if ($problem !== null) { + $fields[] = ['label' => ' ! Problem', 'value' => $problem, 'editable' => false]; + } + $roles = [['label' => '(unclassified)', 'value' => '', 'description' => 'The atlas draws nothing for it.'], + ...array_map(static fn(string $role): array => ['label' => ucfirst($role), 'value' => $role, 'description' => ''], MapAtlasTerrain::ROLES)]; + $known = []; + foreach ($layers as $layer) { + $known[$layer->name] = true; + $binding = is_array($terrain[$layer->name] ?? null) ? $terrain[$layer->name] : []; + $symbols = is_array($binding['symbols'] ?? null) ? $binding['symbols'] : []; + $fields[] = ['label' => sprintf(' Layer %s', $layer->name), 'value' => $layer->decoration ? 'decoration' : 'gameplay', 'editable' => false]; + $fields[] = ['label' => ' Other Glyphs', 'value' => is_string($binding['default'] ?? null) ? $binding['default'] : '', + 'editable' => true, 'choices' => $roles, 'options' => array_column($roles, 'value'), + 'target' => self::TARGET, 'field' => 'default', 'path' => [$layer->name]]; + $counts = []; + for ($y = 0; $y < $layer->height; $y++) { + foreach ($layer->getRowGlyphs($y) as $glyph) { $counts[$glyph] = ($counts[$glyph] ?? 0) + 1; } + } + // A glyph classified here that the layer no longer holds stays listed, so it can be cleared. + foreach (array_keys($symbols) as $glyph) { $counts[(string) $glyph] ??= 0; } + foreach ($counts as $glyph => $count) { + $glyph = (string) $glyph; + $fields[] = ['label' => sprintf(' %s · %s', $glyph === ' ' ? '(blank)' : "'" . $glyph . "'", + $count === 0 ? 'not in this layer' : sprintf('%d %s', $count, $count === 1 ? 'cell' : 'cells')), + 'value' => is_string($symbols[$glyph] ?? null) ? $symbols[$glyph] : '', 'editable' => true, + 'choices' => $roles, 'options' => array_column($roles, 'value'), + 'target' => self::TARGET, 'field' => 'symbol', 'path' => [$layer->name, $glyph]]; + } + } + // A layer classified here that the map no longer has stays listed, so it can be cleared. + foreach (array_keys($terrain) as $name) { + if (!isset($known[(string) $name])) { + $fields[] = ['label' => sprintf(' Layer %s · not in this map', $name), 'value' => '', 'editable' => true, + 'choices' => [['label' => 'Remove its classifications', 'value' => '', 'description' => '']], 'options' => [''], + 'target' => self::TARGET, 'field' => 'layer', 'path' => [(string) $name]]; + } + } + return $fields; + } + + /** + * The atlas block an edit to one of these rows leaves, or null when it changes nothing. + * + * @param array $field + * @return array|null + */ + public static function applyValue(ProjectMap $map, array $field, mixed $value): ?array + { + $block = MapAtlas::readBlock($map); + $path = array_values(array_map('strval', (array) ($field['path'] ?? []))); + $role = (string) $value === '' ? null : (string) $value; + if ($role !== null && !in_array($role, MapAtlasTerrain::ROLES, true)) { + throw new InvalidArgumentException('Choose a listed terrain role.'); + } + $next = match ((string) ($field['field'] ?? '')) { + 'symbol' => MapAtlas::setTerrainSymbol($block, $path[0] ?? '', $path[1] ?? '', $role), + 'default' => MapAtlas::setTerrainDefault($block, $path[0] ?? '', $role), + 'layer' => MapAtlas::setTerrainDefault(array_reduce(array_keys(MapAtlas::readTerrain($block)[$path[0] ?? '']['symbols'] ?? []), + static fn(array $carry, int|string $glyph): array => MapAtlas::setTerrainSymbol($carry, $path[0] ?? '', (string) $glyph, null), $block), + $path[0] ?? '', null), + default => $block, + }; + return $next === $block ? null : $next; + } + + /** + * Why the Engine would refuse the atlas's terrain beside the map's source layers as they stand, or null. + * + * @param array $block + */ + public static function findProblem(ProjectMap $map, array $block): ?string + { + try { + $data = ($block === [] ? [] : [MapAtlas::KEY => $block]) + + array_intersect_key($map->getEditableData(), [MapTerrainKinds::DATA_KEY => true, 'tileset' => true]); + MapAtlasDefinition::readMap($data); + MapAtlasTerrain::readMap($data, $map->getLayerSet(), $map->loadTerrainKinds()); + return null; + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + } +} diff --git a/src/Maps/ConnectedPiecePreview.php b/src/Maps/ConnectedPiecePreview.php new file mode 100644 index 00000000..1daa3a67 --- /dev/null +++ b/src/Maps/ConnectedPiecePreview.php @@ -0,0 +1,83 @@ +> */ + public static function describe(Tileset $tileset, TilesetPiece $piece, string $assetRoot, array $sheetIssues = []): array + { + if ($piece->connects === null) { + return []; + } + $previews = []; + $usedIssues = []; + foreach ($piece->shapeTiles as $shapes) { + foreach ($shapes as $tile) { + $sheet = TileId::getSheet((int) $tile)?->value; + if ((int) $tile !== TileId::EMPTY && isset($sheetIssues[$sheet])) { + $usedIssues[$sheet] = $sheetIssues[$sheet]; + } + } + } + foreach (self::buildSamples($piece) as $sample) { + $world = null; + $issue = $usedIssues === [] ? null : implode(' ', $usedIssues); + try { + $world = TilePalette::buildPieceWorld($tileset, $sample['piece'], $assetRoot, 'connection:' . $piece->id . ':' . $sample['mask']); + } catch (\Throwable $error) { + $issue = $error->getMessage(); + } + $previews[] = ['mask' => $sample['mask'], 'label' => $sample['label'], 'shape' => $sample['shape'], + 'picture' => PiecePlacer::buildPicture($sample['piece']), 'operations' => $world?->operations, 'issue' => $issue]; + } + + return $previews; + } + + /** @return list */ + public static function buildSamples(TilesetPiece $piece): array + { + if ($piece->connects === null) { + return []; + } + $samples = []; + $sources = $piece->getSourceShapeGrid(); + for ($mask = 0; $mask < 16; ++$mask) { + // The empty border prevents Engine's beyond-map-edge rule joining these samples. + $drawn = [['x' => 2, 'y' => 2]]; + $labels = []; + foreach ([[0, -1, 'N'], [1, 0, 'E'], [0, 1, 'S'], [-1, 0, 'W']] as $bit => [$dx, $dy, $label]) { + if (($mask & (1 << $bit)) !== 0) { + $drawn[] = ['x' => 2 + $dx, 'y' => 2 + $dy]; + $labels[] = $label; + } + } + $cells = ConnectedPieceShaper::reshapeCells($piece, $drawn, [], static fn(int $x, int $y): bool => false); + $glyphs = array_fill(0, 5, array_fill(0, 5, ' ')); + $tiles = array_fill_keys(array_keys($piece->shapeTiles), array_fill(0, 5, array_fill(0, 5, 0))); + foreach ($cells as $cell) { + $glyphs[$cell['y']][$cell['x']] = $sources[$cell['shape']]; + foreach ($piece->shapeTiles as $layer => $shapes) { + $tiles[$layer][$cell['y']][$cell['x']] = (int) $shapes[$cell['shape']]; + } + } + $tiles = array_map(static fn(array $grid): array => array_map( + static fn(array $row): array => array_map(strval(...), $row), AutotileShape::resolveLayer($grid)), $tiles); + $samples[] = ['mask' => $mask, 'label' => $labels === [] ? 'Isolated' : implode(' + ', $labels), + 'shape' => $cells[0]['shape'], 'piece' => new TilesetPiece($piece->id, $piece->name, $piece->layer, $glyphs, $tiles)]; + } + + return $samples; + } +} diff --git a/src/Maps/EditableGrid.php b/src/Maps/EditableGrid.php new file mode 100644 index 00000000..80f95f28 --- /dev/null +++ b/src/Maps/EditableGrid.php @@ -0,0 +1,212 @@ +> */ + public array $cells; + private array $originalCells; + private array $lines; + private array $separators; + /** Whether rows were removed: the rows left can equal the authored ones while the grid does not. */ + private bool $rowsRemoved = false; + + public function __construct(private string $text, private ?string $source = null, private bool $legacyTags = false) + { + $parts = preg_split('/(\r\n|\n|\r)/', rtrim($text, "\r\n"), -1, PREG_SPLIT_DELIM_CAPTURE) ?: ['']; + $this->lines = $this->separators = []; + foreach ($parts as $index => $part) { + if ($index % 2 === 0) { + $this->lines[] = $part; + } else { + $this->separators[] = $part; + } + } + $this->cells = self::parseLines($this->lines, $legacyTags); + $this->originalCells = $this->cells; + } + + public static function parseLines(array $lines, bool $legacyTags = false): array + { + // Equal cells share PHP copy-on-write values, not mutable references. + // Keep the pool local to this parse so closed maps retain no cache. + $cellValues = []; + return array_map(static function (string $line) use ($legacyTags, &$cellValues): array { + preg_match_all('/<[^>]+>|[^<]+|]*>$/u', $segment) === 1 + && ($legacyTags || TerminalText::stripAnsi($segment . 'x') === 'x')) { + $tags[] = $segment; + } elseif (preg_match('/^<\/[^>]*>$/u', $segment) === 1 && ($legacyTags || $tags !== [])) { + array_pop($tags); + } else { + foreach (TerminalText::visibleSymbols($segment) as $symbol) { + preg_match('/\A((?:\x1b\[[0-9;]*m)*)(.*?)((?:\x1b\[[0-9;]*m)*)\z/us', $symbol, $styled); + $glyph = $styled[2] ?? $symbol; + $prefix = implode('', $tags) . ($styled[1] ?? ''); + $suffix = ($styled[3] ?? '') . str_repeat('', count($tags)); + $cells[] = $cellValues[$prefix][$suffix][$glyph] ??= [ + 'symbol' => $glyph, 'prefix' => $prefix, 'suffix' => $suffix, + ]; + } + } + } + return $cells; + }, $lines); + } + + public function getSymbols(): array + { + return array_map(static fn(array $row): array => array_column($row, 'symbol'), $this->cells); + } + + public function captureSnapshot(): array + { + return ['text' => $this->text, 'source' => $this->source, 'cells' => $this->cells, 'legacyTags' => $this->legacyTags]; + } + + public static function createFromSnapshot(array $snapshot): self + { + $grid = new self($snapshot['text'], $snapshot['source'], $snapshot['legacyTags']); + $grid->cells = $snapshot['cells']; + return $grid; + } + + public function getLines(): array + { + $lines = []; + foreach ($this->cells as $index => $cells) { + if (($this->originalCells[$index] ?? null) === $cells) { + $lines[] = $this->lines[$index]; + continue; + } + $line = ''; + $open = null; + foreach ($cells as $cell) { + $style = [$cell['prefix'], $cell['suffix']]; + if ($style !== $open) { + $line .= ($open[1] ?? '') . $style[0]; + $open = $style; + } + $line .= $cell['symbol']; + } + $lines[] = $line . ($open[1] ?? ''); + } + return $lines; + } + + public function getSource(): string + { + if ($this->cells === $this->originalCells && ! $this->rowsRemoved && $this->source !== null) { + return $this->source; + } + $text = ''; + foreach ($this->getLines() as $index => $line) { + $text .= ($index === 0 ? '' : ($this->separators[$index - 1] ?? $this->separators[0] ?? "\n")) . $line; + } + $text .= substr($this->text, strlen(rtrim($this->text, "\r\n"))); + if ($this->source === null) { + return MapGridSource::buildSource($text, 'ICHILOTO_MAP'); + } + + $offset = 0; + $start = $end = null; + $indent = ''; + $newline = "\n"; + foreach (token_get_all($this->source) as $token) { + $bytes = is_array($token) ? $token[1] : $token; + if (is_array($token) && $token[0] === T_START_HEREDOC) { + $start = $offset + strlen($bytes); + $newline = str_ends_with($bytes, "\r\n") ? "\r\n" : (str_ends_with($bytes, "\r") ? "\r" : "\n"); + } + if (is_array($token) && $token[0] === T_END_HEREDOC) { + $end = $offset; + preg_match('/^[ \t]*/', $bytes, $match); + $indent = $match[0]; + } + $offset += strlen($bytes); + } + $body = $indent . preg_replace('/(\r\n|\n|\r)/', '$1' . $indent, $text) . $newline; + $source = substr($this->source, 0, $start) . $body . substr($this->source, $end); + try { + if (MapGridSource::parseSource($source, '') !== $text) { + throw new \InvalidArgumentException('Edited grid would not read back identically.'); + } + } catch (\InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was written.', previous: $error); + } + return $source; + } + + public function resize(int $width, int $height): void + { + $blank = ['symbol' => ' ', 'prefix' => '', 'suffix' => '']; + $this->cells = array_slice($this->cells, 0, $height); + foreach ($this->cells as &$row) { + $row = array_pad(array_slice($row, 0, $width), $width, $blank); + } + unset($row); + $this->cells = array_pad($this->cells, $height, array_fill(0, $width, $blank)); + } + + /** + * Inserts blank rows before row `$at` (axis `y`), each as wide as the + * row it is inserted beside, or blank cells before column `$at` of every + * row that reaches it (axis `x`). Every other cell keeps its glyph and + * style, and rows that only move keep their authored bytes. + */ + public function insertLines(string $axis, int $at, int $count): void + { + $blank = ['symbol' => ' ', 'prefix' => '', 'suffix' => '']; + if ($axis === 'y') { + $beside = $this->cells[$at] ?? $this->cells[$at - 1] ?? []; + $rows = array_fill(0, $count, array_fill(0, count($beside), $blank)); + array_splice($this->cells, $at, 0, $rows); + // New rows have no authored line, so they are written from their cells. + array_splice($this->originalCells, $at, 0, array_fill(0, $count, null)); + array_splice($this->lines, $at, 0, array_fill(0, $count, '')); + $separator = $this->separators[max(0, $at - 1)] ?? $this->separators[0] ?? "\n"; + array_splice($this->separators, min($at, count($this->separators)), 0, array_fill(0, $count, $separator)); + return; + } + foreach ($this->cells as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, $count, $blank)); + } + } + unset($row); + } + + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`), or those columns + * from every row that reaches them (axis `x`). Every other cell keeps its + * glyph and style, and rows that only move keep their authored bytes. + */ + public function removeLines(string $axis, int $at, int $count): void + { + if ($axis === 'y') { + array_splice($this->cells, $at, $count); + array_splice($this->originalCells, $at, $count); + array_splice($this->lines, $at, $count); + // The separator before each removed row goes with it; the first rows take the one after them. + array_splice($this->separators, max(0, $at - 1), $count); + $this->rowsRemoved = true; + return; + } + foreach ($this->cells as &$row) { + if (count($row) > $at) { + array_splice($row, $at, $count); + } + } + unset($row); + } +} diff --git a/src/Maps/LayerEditor.php b/src/Maps/LayerEditor.php new file mode 100644 index 00000000..5e06c70f --- /dev/null +++ b/src/Maps/LayerEditor.php @@ -0,0 +1,283 @@ + $map->migratePhysicalOccupancy()), null); + } + + /** + * Adds an empty glyph layer, gameplay or decoration, at the next order. + * + * @return LayerEdit + * @throws MapSourceRefusal When the name is invalid or taken, or no order is free. + */ + public static function createLayer(ProjectMap $map, string $name, bool $decoration = false): array + { + $id = ''; + $command = self::record($map, 'Layer create', static function () use ($map, $name, $decoration, &$id): void { + $id = $map->createLayer($name, $decoration); + }); + + return self::describeEdit($command, $id); + } + + /** + * Renames a glyph layer. Collision lookup uses the layer name, so a + * rename that changes collisions is asked about first. + * + * @return LayerEdit + * @throws MapSourceRefusal When the rename is refused. + */ + public static function renameLayer(ProjectMap $map, string $id, string $name, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countRenameCollisionChanges($id, $name); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Rename %s to %s', self::findLayerName($map, $id), $name), $changed); + } + + return self::describeEdit(self::record($map, 'Layer rename', + static fn() => $map->renameLayer($id, $name, true)), $id); + } + + /** + * Moves a glyph layer to an order; a layer holding that order takes + * this layer's order. The topmost gameplay glyph sets a cell's collision, + * so a move that changes collisions is asked about first. + * + * @return LayerEdit + * @throws MapSourceRefusal When the move is refused. + */ + public static function moveLayer(ProjectMap $map, string $id, int $order, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countMoveCollisionChanges($id, $order); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Moving %s to order %02d', self::findLayerName($map, $id), $order), $changed); + } + + return self::describeEdit(self::record($map, 'Layer reorder', + static fn() => $map->moveLayer($id, $order, true)), $id); + } + + /** + * The order a glyph layer moves to one step above or below: the order of + * the next layer, gameplay or decoration, drawn over or under it. + * + * @throws MapSourceRefusal When the direction is unknown, there is no such layer, or no layer is there. + */ + public static function findAdjacentLayerOrder(ProjectMap $map, string $id, string $direction): int + { + $layers = array_values(array_filter($map->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $index = array_search($id, array_column($layers, 'id'), true); + if ($index === false) { + throw new MapSourceRefusal("{$map->mapId} has no layer {$id} to reorder."); + } + + return self::findAdjacentOrder(array_column($layers, 'order'), $index, $direction, (string) $layers[$index]['name']); + } + + /** + * Makes a glyph layer decoration, drawn without collision, or gameplay. + * A change that changes collisions is asked about first. + * + * @return LayerEdit + * @throws MapSourceRefusal When the change is refused, such as for the last gameplay layer. + */ + public static function setLayerDecoration(ProjectMap $map, string $id, bool $decoration, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countDecorationCollisionChanges($id, $decoration); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Making %s %s', self::findLayerName($map, $id), $decoration ? 'decoration' : 'gameplay'), $changed); + } + + return self::describeEdit(self::record($map, 'Layer decoration', + static fn() => $map->setLayerDecoration($id, $decoration, true)), $id); + } + + /** + * Removes a glyph layer and its cells. A removal that changes collisions + * is asked about first; the layer left to work on is the map's base layer. + * + * @return LayerEdit + * @throws MapSourceRefusal When the removal is refused, such as for the last gameplay layer. + */ + public static function removeLayer(ProjectMap $map, string $id, bool $confirmCollisionChange = false): array + { + $changed = $confirmCollisionChange ? 0 : $map->countRemovalCollisionChanges($id); + if ($changed > 0) { + return self::askCollisionChange(sprintf('Removing %s', self::findLayerName($map, $id)), $changed); + } + $command = self::record($map, 'Layer remove', static fn() => $map->removeLayer($id)); + + return self::describeEdit($command, $map->getBaseLayerId()); + } + + /** + * Adds an empty tile layer, placed among the tile layers as one a piece + * names is. + * + * @return LayerEdit + * @throws MapSourceRefusal When the name is invalid or taken, or the map has no room for it. + */ + public static function createTileLayer(ProjectMap $map, string $name): array + { + return self::describeEdit(self::record($map, 'Tile layer create', static fn() => $map->createTileLayer($name)), $name); + } + + /** + * Renames a tile layer and its settings. + * + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer, or the new name is invalid or taken. + */ + public static function renameTileLayer(ProjectMap $map, string $name, string $newName): array + { + return self::describeEdit(self::record($map, 'Tile layer rename', static fn() => $map->renameTileLayer($name, $newName)), $newName); + } + + /** + * Moves a tile layer to a drawing order; a tile layer holding that order + * takes this layer's order. + * + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer or the order is not 00-99. + */ + public static function moveTileLayer(ProjectMap $map, string $name, int $order): array + { + return self::describeEdit(self::record($map, 'Tile layer reorder', static fn() => $map->moveTileLayer($name, $order)), $name); + } + + /** + * The order a tile layer moves to one step above or below: the order of + * the next tile layer drawn over or under it. + * + * @throws MapSourceRefusal When the direction is unknown, there is no such layer, or no layer is there. + */ + public static function findAdjacentTileLayerOrder(ProjectMap $map, string $name, string $direction): int + { + $layers = $map->describeTileLayers()['layers']; + $index = array_search($name, array_column($layers, 'name'), true); + if ($index === false) { + throw new MapSourceRefusal("There is no tile layer {$name}. Nothing was changed."); + } + + return self::findAdjacentOrder(array_column($layers, 'order'), $index, $direction, $name); + } + + /** + * Removes a tile layer, its tiles and its settings. + * + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer. + */ + public static function removeTileLayer(ProjectMap $map, string $name): array + { + return self::describeEdit(self::record($map, 'Tile layer remove', static fn() => $map->removeTileLayer($name)), null); + } + + /** + * Sets a tile layer's offset across and down, in field cells, and the + * gameplay layer its tiles move with, or none. + * + * @param array $offset + * @return LayerEdit + * @throws MapSourceRefusal When there is no such layer, or the Engine would refuse the settings. + */ + public static function setTileLayerSettings(ProjectMap $map, string $name, array $offset, ?string $movesWith): array + { + return self::describeEdit(self::record($map, 'Tile layer settings', + static fn() => $map->setTileLayerSettings($name, $offset, $movesWith)), $name); + } + + /** + * Applies a change and returns the command that restores the layers and + * data on either side of it, or null when it changed nothing. + */ + private static function record(ProjectMap $map, string $label, Closure $change): ?Command + { + $before = $map->captureLayerSnapshot(); + $change(); + $after = $map->captureLayerSnapshot(); + if ($after === $before) { + return null; + } + + return new GenericCommand($label, static fn() => $map->restoreLayerSnapshot($after), + static fn() => $map->restoreLayerSnapshot($before)); + } + + /** @return LayerEdit */ + private static function describeEdit(?Command $command, ?string $layer): array + { + return ['command' => $command, 'layer' => $layer, 'question' => null]; + } + + /** @return LayerEdit */ + private static function askCollisionChange(string $action, int $changed): array + { + return ['command' => null, 'layer' => null, 'question' => sprintf( + '%s changes collision at %d cell(s). Shared collisions.php stays unchanged.', $action, $changed)]; + } + + private static function findLayerName(ProjectMap $map, string $id): string + { + return (string) (array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $id)['name'] ?? $id); + } + + /** + * @param list $orders Orders in drawing order. + * @throws MapSourceRefusal When the direction is unknown or no layer is there. + */ + private static function findAdjacentOrder(array $orders, int $index, string $direction, string $name): int + { + $step = match ($direction) { + self::ABOVE => 1, + self::BELOW => -1, + default => throw new MapSourceRefusal(sprintf("Move a layer '%s' or '%s', not '%s'.", self::ABOVE, self::BELOW, $direction)), + }; + + return $orders[$index + $step] ?? throw new MapSourceRefusal(sprintf('%s is already the %s layer.', $name, + $step > 0 ? 'top' : 'bottom')); + } +} diff --git a/src/Maps/LineInsertion.php b/src/Maps/LineInsertion.php new file mode 100644 index 00000000..0d5eca1a --- /dev/null +++ b/src/Maps/LineInsertion.php @@ -0,0 +1,70 @@ + 'Insert'; + } + + public string $noun { + get => ($this->axis === 'y' ? 'row' : 'column') . ($this->count === 1 ? '' : 's'); + } + + public string $description { + get => sprintf('%d %s %s %s %d of %s', $this->count, $this->noun, + $this->axis === 'y' ? 'above' : 'left of', $this->axis === 'y' ? 'row' : 'column', $this->at + 1, $this->mapId); + } + + public function getShiftedCoordinate(int $value): int + { + return $value >= $this->at ? $value + $this->count : $value; + } + + /** + * One starting at or beyond the line moves, one straddling it stretches, + * one before it stays. + * + * @return array{int, int} + */ + public function getShiftedSpan(int $start, int $size): array + { + if ($start >= $this->at) { + return [$start + $this->count, $size]; + } + + return $start + $size > $this->at ? [$start, $size + $this->count] : [$start, $size]; + } + + /** @return array{mapShifts: list} */ + public function getManifestEdits(): array + { + return ['mapShifts' => [['map' => $this->mapId, 'axis' => $this->axis, 'at' => $this->at, 'by' => $this->count]]]; + } +} diff --git a/src/Maps/LineRemoval.php b/src/Maps/LineRemoval.php new file mode 100644 index 00000000..30508674 --- /dev/null +++ b/src/Maps/LineRemoval.php @@ -0,0 +1,105 @@ +mapId, $this->axis, $this->at, $this->count, $landing); + } + + public string $verb { + get => 'Remove'; + } + + public string $noun { + get => ($this->axis === 'y' ? 'row' : 'column') . ($this->count === 1 ? '' : 's'); + } + + public string $description { + get => sprintf('%d %s from %s %d of %s', $this->count, $this->noun, + $this->axis === 'y' ? 'row' : 'column', $this->at + 1, $this->mapId); + } + + /** Whether a coordinate on the removal axis is on a removed line. */ + public function isRemoved(int $value): bool + { + return $value >= $this->at && $value < $this->at + $this->count; + } + + public function getShiftedCoordinate(int $value): ?int + { + if ($this->isRemoved($value)) { + return null; + } + + return $value >= $this->at ? $value - $this->count : $value; + } + + /** + * One beyond the removed lines moves back, one straddling them shrinks + * by the lines it loses, one before them stays. + * + * @return array{int, int}|null + */ + public function getShiftedSpan(int $start, int $size): ?array + { + $end = $start + $size; + $lost = max(0, min($end, $this->at + $this->count) - max($start, $this->at)); + if ($lost >= $size) { + return null; + } + $newStart = $start < $this->at ? $start : max($this->at, $start - $this->count); + + return [$newStart, $size - $lost]; + } + + /** @return array{mapRemovals: list} */ + public function getManifestEdits(): array + { + if ($this->landing === null) { + throw new InvalidArgumentException('Choose where saves on the removed lines land before planning the save step.'); + } + + return ['mapRemovals' => [['map' => $this->mapId, 'axis' => $this->axis, 'at' => $this->at, 'count' => $this->count, 'to' => $this->landing]]]; + } +} diff --git a/src/Maps/MapFolder.php b/src/Maps/MapFolder.php new file mode 100644 index 00000000..7f2aa3be --- /dev/null +++ b/src/Maps/MapFolder.php @@ -0,0 +1,91 @@ + Tile layer sources in graphics/, by path. */ + private array $tileSources = []; + /** Original on-disk members, including layers removed or renamed in memory. */ + private array $baselineSources = []; + + public function __construct( + private readonly string $directory, + public private(set) bool $legacy, + array $layers, + EditableGrid $events, + string $eventPath, + array $tileSources = [], + ) { + foreach ($layers as $layer) { + $this->layers[$layer['id']] = $layer; + } + $this->tileSources = $tileSources; + $this->layers[self::EVENT] = [ + 'id' => self::EVENT, 'name' => 'Events', 'order' => null, 'decoration' => false, + 'path' => $eventPath, 'grid' => $events, + ]; + $this->captureBaseline(); + } + + /** + * Formats a layer's name for display, so every layer reads alike: the + * Events layer and gameplay layers named by their files (fixtures, + * upper-floor) show as Events, Fixtures and Upper Floor. The name itself, + * which pieces and files use, is unchanged. + */ + public static function formatLabel(string $name): string + { + return ucwords(str_replace(['-', '_'], ' ', $name)); + } + + public static function createFromSource(string $directory, MapLayerSet $set, string $eventPath, string $eventText): self + { + $layers = []; + foreach ($set->layers as $layer) { + $path = $directory . ($set->legacy ? '/' : '/layers/') . basename($layer->path); + $layers[] = [ + 'id' => $set->legacy ? self::BASE : 'map:' . $layer->order, + 'name' => $layer->name, 'order' => $layer->order, 'decoration' => $layer->decoration, + 'path' => $path, + 'grid' => new EditableGrid($layer->text, (string) file_get_contents($path), $set->legacy), + ]; + } + $events = new EditableGrid($eventText, (string) file_get_contents($eventPath)); + $tileSources = []; + foreach (TileLayerSource::findPaths($directory) as $path) { + $tileSources[$path] = (string) file_get_contents($path); + } + return new self($directory, $set->legacy, $layers, $events, $eventPath, $tileSources); + } + + /** @return array Tile layer sources in graphics/, by path. */ + public function getTileSources(): array + { + return $this->tileSources; + } + + public function getLayers(): array + { + $layers = array_values($this->layers); + usort($layers, static fn(array $a, array $b): int => ($a['order'] ?? PHP_INT_MAX) <=> ($b['order'] ?? PHP_INT_MAX)); + return $layers; + } + + public function getBaseId(): string + { + foreach ($this->getLayers() as $layer) { + if ($layer['id'] !== self::EVENT && ! $layer['decoration']) { + return $layer['id']; + } + } + throw new MapSourceRefusal('A map needs at least one gameplay layer.'); + } + + public function getLayer(string $id): array + { + $id = $id === self::BASE && ! isset($this->layers[$id]) ? $this->getBaseId() : $id; + return $this->layers[$id] ?? throw new MapSourceRefusal("Unknown map layer {$id}."); + } + + public function getBaseGrid(): EditableGrid + { + return $this->getGrid($this->getBaseId()); + } + + public function getEventGrid(): EditableGrid + { + return $this->getGrid(self::EVENT); + } + + public function getGrid(string $id): EditableGrid + { + return $this->getLayer($id)['grid']; + } + + public function createLayer(string $name, bool $decoration, ?int $order = null): string + { + $this->assertNameAvailable($name); + $used = array_column($this->getLayers(), 'order'); + if ($order === null) { + $order = max(array_filter($used, is_int(...))) + 1; + } + if ($order < 0 || $order > 99 || in_array($order, $used, true)) { + throw new MapSourceRefusal('Layer order must be an unused two-digit number (00-99).'); + } + // Adding the first layer explicitly converts the legacy member in the + // same transaction; merely opening or saving a legacy map never does. + if ($this->legacy) { + $source = $this->getBaseGrid()->getSource(); + $this->layers[self::BASE]['grid'] = new EditableGrid(MapGridSource::parseSource($source, $this->layers[self::BASE]['path']), $source); + $this->layers[self::BASE]['path'] = $this->buildPath(0, 'terrain', false); + $this->legacy = false; + } + // Ids name a layer for a session and survive reordering, so the id + // its order would give at load may already be taken. + $id = 'map:' . $order; + for ($copy = 2; isset($this->layers[$id]); $copy++) { + $id = sprintf('map:%d:%d', $order, $copy); + } + $grid = new EditableGrid(implode("\n", array_map( + static fn(array $row): string => str_repeat(' ', count($row)), $this->getBaseGrid()->cells, + ))); + $this->layers[$id] = ['id' => $id, 'name' => $name, 'order' => $order, 'decoration' => $decoration, + 'path' => $this->buildPath($order, $name, $decoration), 'grid' => $grid]; + return $id; + } + + public function renameLayer(string $id, string $name): void + { + $layer = $this->getLayer($id); + if ($layer['name'] === $name) { + return; + } + $this->assertManagedLayer($layer); + $this->assertNameAvailable($name); + $this->layers[$layer['id']]['name'] = $name; + $this->layers[$layer['id']]['path'] = $this->buildPath($layer['order'], $name, $layer['decoration']); + } + + /** + * Moves a layer to another order, renaming its file's prefix. When + * another layer holds that order the two exchange orders. + * + * @throws MapSourceRefusal When the layer cannot move or the order is not 00-99. + */ + public function moveLayer(string $id, int $order): void + { + $layer = $this->getLayer($id); + $this->assertManagedLayer($layer); + if ($order < MapLayer::MIN_ORDER || $order > MapLayer::MAX_ORDER) { + throw new MapSourceRefusal('Layer order must be a two-digit number (00-99).'); + } + if ($layer['order'] === $order) { + return; + } + foreach ($this->layers as $other) { + if ($other['id'] !== self::EVENT && $other['order'] === $order) { + $this->setLayerEntry($other['id'], $other['name'], $layer['order'], $other['decoration']); + } + } + $this->setLayerEntry($layer['id'], $layer['name'], $order, $layer['decoration']); + } + + /** + * Makes a layer decoration, drawn without collision, or gameplay, + * renaming its file's kind. A map keeps at least one gameplay layer. + * + * @throws MapSourceRefusal When the layer cannot change or is the last gameplay layer. + */ + public function setLayerDecoration(string $id, bool $decoration): void + { + $layer = $this->getLayer($id); + $this->assertManagedLayer($layer); + if ($layer['decoration'] === $decoration) { + return; + } + if ($decoration && $this->isLastGameplayLayer($layer)) { + throw new MapSourceRefusal('The last gameplay layer cannot become decoration.'); + } + $this->setLayerEntry($layer['id'], $layer['name'], $layer['order'], $decoration); + } + + private function setLayerEntry(string $id, string $name, int $order, bool $decoration): void + { + $this->layers[$id] = [...$this->layers[$id], 'name' => $name, 'order' => $order, 'decoration' => $decoration, + 'path' => $this->buildPath($order, $name, $decoration)]; + } + + private function isLastGameplayLayer(array $layer): bool + { + return ! $layer['decoration'] && count(array_filter($this->layers, + static fn(array $entry): bool => $entry['id'] !== self::EVENT && ! $entry['decoration'], + )) === 1; + } + + public function removeLayer(string $id): void + { + $layer = $this->getLayer($id); + $this->assertManagedLayer($layer); + if ($this->isLastGameplayLayer($layer)) { + throw new MapSourceRefusal('The last gameplay layer cannot be removed.'); + } + unset($this->layers[$layer['id']]); + } + + private function assertManagedLayer(array $layer): void + { + if ($this->legacy || $layer['id'] === self::EVENT) { + throw new MapSourceRefusal('Only authored layers in layers/ can be renamed, reordered, made decoration or removed.'); + } + } + + private function assertNameAvailable(string $name): void + { + if (preg_match(MapLayerSource::FILENAME_PATTERN, '00.' . $name . '.map.php') !== 1) { + throw new MapSourceRefusal('Use a layer name beginning with a letter, followed by letters, digits, hyphens or underscores.'); + } + foreach ($this->layers as $layer) { + if ($layer['id'] !== self::EVENT && $layer['name'] === $name) { + throw new MapSourceRefusal("Layer {$name} already exists."); + } + } + } + + private function buildPath(int $order, string $name, bool $decoration): string + { + return sprintf('%s/layers/%02d.%s.%s.php', $this->directory, $order, $name, $decoration ? 'deco' : 'map'); + } + + public function captureSnapshot(): array + { + return ['legacy' => $this->legacy, 'layers' => array_map( + static fn(array $layer): array => [...$layer, 'grid' => $layer['grid']->captureSnapshot()], $this->layers, + ), 'tiles' => $this->tileSources]; + } + + public function restoreSnapshot(array $snapshot): void + { + $this->legacy = $snapshot['legacy']; + $this->layers = array_map(static fn(array $layer): array => [...$layer, 'grid' => EditableGrid::createFromSnapshot($layer['grid'])], $snapshot['layers']); + $this->tileSources = $snapshot['tiles']; + } + + /** + * Resizes every layer, tile layers included: a tile layer holds one tile + * per map cell, so it is cropped or padded like the terminal layers. + * Every tile layer is resized before anything changes, so one the Engine + * cannot read refuses the whole resize. + * + * @throws MapSourceRefusal When a tile layer cannot be read or does not match the map. + */ + public function resize(int $width, int $height): void + { + $tileSources = []; + if ($this->tileSources !== []) { + $set = $this->getLayerSet(); + foreach ($this->tileSources as $path => $source) { + $tileSources[$path] = TileLayerSource::resize($source, $this->getDisplayPath($path), $set, $width, $height, + $this->baselineSources[$path] ?? null); + } + } + foreach ($this->layers as $layer) { + $layer['grid']->resize($width, $height); + } + $this->tileSources = $tileSources; + } + + /** + * Inserts blank rows before row `$at` (axis `y`) or blank columns before + * column `$at` (axis `x`) into every layer: the terminal layers, the + * event layer and the tile layers, whose new cells are empty. Every tile + * layer is rewritten before anything changes, so one the Engine cannot + * read refuses the whole insertion. + * + * @throws MapSourceRefusal When a tile layer cannot be read or does not match the map. + */ + public function insertLines(string $axis, int $at, int $count): void + { + $tileSources = []; + if ($this->tileSources !== []) { + $set = $this->getLayerSet(); + foreach ($this->tileSources as $path => $source) { + $tileSources[$path] = TileLayerSource::insertLines($source, $this->getDisplayPath($path), $set, $axis, $at, $count, + $this->baselineSources[$path] ?? null); + } + } + foreach ($this->layers as $layer) { + $layer['grid']->insertLines($axis, $at, $count); + } + $this->tileSources = $tileSources; + } + + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`) or those columns + * (axis `x`) from every layer: the terminal layers, the event layer and + * the tile layers. Every tile layer is rewritten before anything changes, + * so one the Engine cannot read refuses the whole removal. + * + * @throws MapSourceRefusal When a tile layer cannot be read or does not match the map. + */ + public function removeLines(string $axis, int $at, int $count): void + { + $tileSources = []; + if ($this->tileSources !== []) { + $set = $this->getLayerSet(); + foreach ($this->tileSources as $path => $source) { + $tileSources[$path] = TileLayerSource::removeLines($source, $this->getDisplayPath($path), $set, $axis, $at, $count, + $this->baselineSources[$path] ?? null); + } + } + foreach ($this->layers as $layer) { + $layer['grid']->removeLines($axis, $at, $count); + } + $this->tileSources = $tileSources; + } + + /** + * Writes tile entries into the named tile layers with their top-left + * cell at (x, y), as one stamped piece: a `0` entry leaves its cell as it + * was. A layer the map does not have yet is created in `graphics/` with + * the next order after its tile layers, every cell empty. Every layer is + * written before anything changes, so one that cannot take the entries + * refuses them all. + * + * @param array>> $tiles Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read, does not match the map, cannot be created, or the entries fall outside it. + */ + public function writeTileEntries(array $tiles, int $x, int $y, ?Closure $placeBefore = null): void + { + if ($tiles === []) { + return; + } + $set = $this->getLayerSet(); + $sources = $this->tileSources; + foreach ($tiles as $name => $rows) { + $path = $this->findTileLayerPath((string) $name, $sources) ?? $this->addTileLayerPath((string) $name, $sources, $placeBefore); + $sources[$path] = TileLayerSource::writeEntries($sources[$path] ?? TileLayerSource::createEmpty($set), + $this->getDisplayPath($path), $set, $x, $y, $rows, $this->baselineSources[$path] ?? null); + } + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + + /** + * Sets tile entries cell by cell in the named tile layers, `0` included, + * for a drawn or erased connected piece. A layer the map does not have + * yet is created as {@see writeTileEntries()} creates one, unless every + * entry for it is `0`, which an empty layer already holds. Every layer is + * written before anything changes, so one that cannot take its entries + * refuses them all. + * + * @param array> $cells The entry for each cell, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read, does not match the map, cannot be created, or a cell falls outside it. + */ + public function writeTileCells(array $cells, ?Closure $placeBefore = null): void + { + $set = $this->getLayerSet(); + $sources = $this->tileSources; + foreach ($cells as $name => $entries) { + $path = $this->findTileLayerPath((string) $name, $sources); + if ($path === null) { + if (array_filter($entries, static fn(array $cell): bool => $cell['entry'] !== (string)TileId::EMPTY) === []) { + continue; + } + $path = $this->addTileLayerPath((string) $name, $sources, $placeBefore); + } + $sources[$path] = TileLayerSource::setCellEntries($sources[$path] ?? TileLayerSource::createEmpty($set), + $this->getDisplayPath($path), $set, $entries, $this->baselineSources[$path] ?? null); + } + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + + /** + * The names of the map's tile layers, in order. + * + * @return list + */ + public function getTileLayerNames(): array + { + $names = []; + foreach (array_keys($this->tileSources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $names[] = $matches['name']; + } + } + + return $names; + } + + /** + * Reads the entries of the named tile layers over a rectangle, by row, + * so they can move with the glyphs above them. A layer the map does not + * have is left out; a cell past the map's edge reads `0`. + * + * @param list $names Tile layer names. + * @return array>> Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read or does not match the map. + */ + public function readTileEntries(array $names, int $x, int $y, int $width, int $height): array + { + $set = $this->getLayerSet(); + $read = []; + foreach ($names as $name) { + $path = $this->findTileLayerPath($name, $this->tileSources); + if ($path === null) { + continue; + } + $entries = TileLayerSource::readEntries($this->tileSources[$path], $this->getDisplayPath($path), $set, 'moving its tiles'); + $rows = []; + for ($row = 0; $row < $height; $row++) { + $cells = []; + for ($column = 0; $column < $width; $column++) { + $cells[] = $entries[$y + $row][$x + $column] ?? (string) TileId::EMPTY; + } + $rows[] = $cells; + } + $read[$name] = $rows; + } + + return $read; + } + + /** + * The map's tile layers in drawing order: each one's name, order and path. + * + * @return list + */ + public function getTileLayers(): array + { + $layers = []; + foreach (array_keys($this->tileSources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $layers[] = ['name' => $matches['name'], 'order' => (int) $matches['order'], 'path' => $path]; + } + } + usort($layers, static fn(array $a, array $b): int => $a['order'] <=> $b['order']); + + return $layers; + } + + /** + * Adds an empty tile layer, every cell `0`, placed among the tile layers + * as a layer a piece names is ({@see writeTileEntries()}). + * + * @param (Closure(string, list): ?string)|null $placeBefore + * @throws MapSourceRefusal When the name is taken or not a tile layer name, or the map cannot take another tile layer. + */ + public function createTileLayer(string $name, ?Closure $placeBefore = null): void + { + $sources = $this->tileSources; + if ($this->findTileLayerPath($name, $sources) !== null) { + throw new MapSourceRefusal("Tile layer {$name} already exists. Nothing was changed."); + } + $path = $this->addTileLayerPath($name, $sources, $placeBefore); + $sources[$path] = TileLayerSource::createEmpty($this->getLayerSet()); + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + + /** + * Renames a tile layer, keeping its order and its tiles. + * + * @throws MapSourceRefusal When there is no such layer, or the new name is taken or not a tile layer name. + */ + public function renameTileLayer(string $name, string $newName): void + { + $layer = $this->requireTileLayer($name); + if ($newName === $name) { + return; + } + if ($this->findTileLayerPath($newName, $this->tileSources) !== null) { + throw new MapSourceRefusal("Tile layer {$newName} already exists. Nothing was changed."); + } + $this->setTileLayerPaths([$layer['path'] => $this->buildTileLayerPath($layer['order'], $newName)]); + } + + /** + * Moves a tile layer to another order, renaming its file's prefix. When + * another tile layer holds that order the two exchange orders. + * + * @throws MapSourceRefusal When there is no such layer or the order is not 00-99. + */ + public function moveTileLayer(string $name, int $order): void + { + $layer = $this->requireTileLayer($name); + if ($order < MapLayer::MIN_ORDER || $order > MapLayer::MAX_ORDER) { + throw new MapSourceRefusal('Tile layer order must be a two-digit number (00-99). Nothing was changed.'); + } + $paths = []; + foreach ($this->getTileLayers() as $other) { + if ($other['order'] === $order && $other['path'] !== $layer['path']) { + $paths[$other['path']] = $this->buildTileLayerPath($layer['order'], $other['name']); + } + } + $paths[$layer['path']] = $this->buildTileLayerPath($order, $name); + $this->setTileLayerPaths($paths); + } + + /** + * Removes a tile layer and its tiles. + * + * @throws MapSourceRefusal When there is no such layer. + */ + public function removeTileLayer(string $name): void + { + unset($this->tileSources[$this->requireTileLayer($name)['path']]); + } + + /** + * @return array{name: string, order: int, path: string} + * @throws MapSourceRefusal When the map has no tile layer with the name. + */ + private function requireTileLayer(string $name): array + { + $path = $this->findTileLayerPath($name, $this->tileSources); + + return array_find($this->getTileLayers(), static fn(array $layer): bool => $layer['path'] === $path) + ?? throw new MapSourceRefusal("There is no tile layer {$name}. Nothing was changed."); + } + + /** @throws MapSourceRefusal When the name is not a tile layer name. */ + private function buildTileLayerPath(int $order, string $name): string + { + $path = sprintf('%s/%s/%02d.%s.tiles.php', $this->directory, MapGraphics::DIRECTORY, $order, $name); + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path)) !== 1) { + throw new MapSourceRefusal("'{$name}' is not a tile layer name. Nothing was changed."); + } + + return $path; + } + + /** + * Moves tile layer sources to new paths, so a save writes them there and + * removes the files they leave. + * + * @param array $paths New paths by current path. + */ + private function setTileLayerPaths(array $paths): void + { + $sources = []; + foreach ($this->tileSources as $path => $source) { + $sources[$paths[$path] ?? $path] = $source; + } + ksort($sources, SORT_STRING); + $this->tileSources = $sources; + } + + /** @param array $tileSources Tile layer sources by path, as {@see getTileSources()} returns them. */ + public function restoreTileSources(array $tileSources): void + { + $this->tileSources = $tileSources; + } + + /** + * The path of the tile layer with this name, or null when there is none. + * + * @param array $sources Tile layer sources by path. + * @throws MapSourceRefusal When more than one tile layer has the name. + */ + private function findTileLayerPath(string $name, array $sources): ?string + { + $paths = array_values(array_filter(array_keys($sources), static fn(string $path): bool => + preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1 && $matches['name'] === $name)); + if (count($paths) > 1) { + throw new MapSourceRefusal(sprintf('Tile layers %s share the name %s; rename one so a piece can name it. Nothing was changed.', + implode(' and ', array_map($this->getDisplayPath(...), $paths)), $name)); + } + + return $paths[0] ?? null; + } + + /** + * Adds the path for a new tile layer: before the tile layer $placeBefore + * names, given the new layer's name and the map's tile layer names in + * order, or after them all when it names none. The new layer takes the + * free order just below that layer; when there is none, that layer and + * every later one move up one order to make room. + * + * @param array $sources Tile layer sources by path, renumbered in place when room is made. + * @param (Closure(string, list): ?string)|null $placeBefore + * @throws MapSourceRefusal When the map cannot take another tile layer. + */ + private function addTileLayerPath(string $name, array &$sources, ?Closure $placeBefore): string + { + $orders = []; + foreach (array_keys($sources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $orders[$path] = (int) $matches['order']; + } + } + asort($orders); + $names = array_values(array_map(static fn(string $path): string => + (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1 ? $matches['name'] : ''), array_keys($orders))); + $before = $placeBefore?->__invoke($name, $names); + $beforePath = $before === null ? null : $this->findTileLayerPath($before, $sources); + if ($beforePath === null) { + $order = $orders === [] ? 0 : max($orders) + 1; + } else { + $order = $orders[$beforePath]; + if ($order > 0 && !in_array($order - 1, $orders, true)) { + $order--; + } else { + $renamed = []; + foreach ($sources as $path => $source) { + $moves = isset($orders[$path]) && $orders[$path] >= $order; + $renamed[$moves ? preg_replace('/\/\d{2}\.(?=[^\/]+$)/', sprintf('/%02d.', $orders[$path] + 1), $path) : $path] = $source; + } + if (max($orders) + 1 > 99) { + throw new MapSourceRefusal(sprintf('Tile layer %s cannot be added: a map holds up to %d tile layers, ordered 00 to 99. Nothing was changed.', + $name, MapGraphics::MAX_LAYERS)); + } + $sources = $renamed; + } + } + if (count($sources) >= MapGraphics::MAX_LAYERS || $order > 99) { + throw new MapSourceRefusal(sprintf('Tile layer %s cannot be added: a map holds up to %d tile layers, ordered 00 to 99. Nothing was changed.', + $name, MapGraphics::MAX_LAYERS)); + } + return $this->buildTileLayerPath($order, $name); + } + + /** A tile layer path relative to its map, as refusals name it. */ + private function getDisplayPath(string $path): string + { + return basename($this->directory) . '/' . MapGraphics::DIRECTORY . '/' . basename($path); + } + + public function assertDimensions(): void + { + $this->getLayerSet()->assertMatchingGrid($this->getEventGrid()->getSymbols(), $this->layers[self::EVENT]['path']); + } + + public function getLayerSet(): MapLayerSet + { + $layers = []; + foreach ($this->getLayers() as $layer) { + if ($layer['id'] !== self::EVENT) { + $layers[] = new MapLayer($layer['name'], $layer['order'], $layer['decoration'], $layer['path'], + MapGridSource::parseSource($layer['grid']->getSource(), $layer['path'])); + } + } + return new MapLayerSet($layers, $this->legacy); + } + + public function assertSourcesUnchanged(): void + { + if (! is_dir($this->directory)) { + return; + } + foreach ($this->baselineSources as $path => $source) { + if (! is_file($path)) { + throw new MapSourceRefusal("{$path} disappeared after opening; reload before saving."); + } + if (dirname($path) !== $this->directory . '/' . MapGraphics::DIRECTORY) { + // Tile layers are carried as bytes; validation reports one the Engine cannot read. + MapGridSource::readFile($path); + } + if (file_get_contents($path) !== $source) { + throw new MapSourceRefusal("{$path} changed after opening; reload before saving. Nothing was written."); + } + } + if ($this->baselineSources !== []) { + $set = MapLayerSource::loadFromDirectory($this->directory); + $known = array_keys($this->baselineSources); + foreach ($set->layers as $layer) { + if (! in_array($layer->path, $known, true)) { + throw new MapSourceRefusal("{$layer->path} was added after opening; reload before saving."); + } + } + } + foreach (TileLayerSource::findPaths($this->directory) as $path) { + if (! array_key_exists($path, $this->baselineSources)) { + throw new MapSourceRefusal("{$path} was added after opening; reload before saving."); + } + } + } + + /** Retired/renamed members remain read dependencies until a successful save. */ + public function expectSources(FileSetTransaction $transaction): void + { + $recoverFolder = ! is_dir($this->directory); + foreach ($this->baselineSources as $path => $source) { + $transaction->expectSource($path, $source, allowMissing: $recoverFolder); + } + foreach (array_diff_key($this->getSources(), $this->baselineSources) as $path => $source) { + $transaction->expectSource($path, null); + } + } + + public function getSources(?string $directory = null, ?string $baseName = null): array + { + $sources = []; + foreach ($this->getLayers() as $layer) { + $path = $layer['path']; + if ($directory !== null) { + $path = $layer['id'] === self::EVENT ? "{$directory}/{$baseName}.event.php" + : ($this->legacy ? "{$directory}/{$baseName}.map.php" : $directory . '/layers/' . basename($path)); + } + $sources[$path] = $layer['grid']->getSource(); + } + foreach ($this->tileSources as $path => $source) { + $sources[$directory === null ? $path : $directory . '/' . MapGraphics::DIRECTORY . '/' . basename($path)] = $source; + } + return $sources; + } + + public function stageChanges(FileSetTransaction $transaction, ?string $directory = null, ?string $baseName = null, bool $removeOriginals = false): void + { + $sources = $this->getSources($directory, $baseName); + foreach ($sources as $path => $source) { + if ($directory !== null || ($this->baselineSources[$path] ?? null) !== $source || ! is_file($path)) { + $transaction->write($path, $source); + } + } + if ($directory === null || $removeOriginals) { + foreach ($this->baselineSources as $path => $source) { + if (! isset($sources[$path])) { + $transaction->remove($path); + } + } + } + } + + public function captureBaseline(): void + { + $this->baselineSources = []; + foreach ($this->getSources() as $path => $source) { + if (is_file($path)) { + $this->baselineSources[$path] = $source; + } + } + } + + public function getStoredPaths(): array + { + return array_keys($this->baselineSources); + } + +} diff --git a/src/Maps/MapLineEdit.php b/src/Maps/MapLineEdit.php new file mode 100644 index 00000000..3c9e4381 --- /dev/null +++ b/src/Maps/MapLineEdit.php @@ -0,0 +1,52 @@ +>> + */ + public function getManifestEdits(): array; +} diff --git a/src/Maps/MapLineInventory.php b/src/Maps/MapLineInventory.php new file mode 100644 index 00000000..6cb3b627 --- /dev/null +++ b/src/Maps/MapLineInventory.php @@ -0,0 +1,676 @@ +, value: int}>> Moves by file, keyed by path. */ + private array $shifts = []; + + /** @var array, reason: string}>> Hand edits by file, keyed by path. */ + private array $handEdits = []; + + /** @var array, reason: string}>> What stands on a removed line, by file, keyed by path. */ + private array $blocked = []; + + /** @var array|null>> The contexts each reusable event script is started from, each once. */ + private array $commonEventStarts = []; + + /** @var array> The maps whose events start each cutscene. */ + private array $cinematicTriggerMaps = []; + + /** @var list|null> Every context a walk reached through a transfer. */ + private array $transferContexts = []; + + /** + * @param MapLineEdit $edit The insertion or removal. + * @param array $commonEvents Reusable event scripts by id. + * @param MapSubjectCells|null $subjects Where the edited map's subjects stood before the edit. + */ + public function __construct( + private readonly MapLineEdit $edit, + private readonly array $commonEvents, + private readonly ?MapSubjectCells $subjects = null, + ) { + } + + /** + * Moves by file, in the order found. + * + * @return array, value: int}>> + */ + public function getShifts(): array + { + return array_map(array_values(...), $this->shifts); + } + + /** + * Coordinates that must be moved by hand, by file. + * + * @return array, reason: string}>> + */ + public function getHandEdits(): array + { + return array_map(array_values(...), $this->handEdits); + } + + /** + * What stands on a removed line, by file; empty for an insertion. + * + * @return array, reason: string}>> + */ + public function getBlocked(): array + { + return array_map(array_values(...), $this->blocked); + } + + /** + * Adds one map's data file: its own coordinates when it is the edited + * map, and everything in it that leads into the edited map. + * + * @param array $data The evaluated data file. + */ + public function addMapData(string $file, string $mapId, array $data): void + { + $isTarget = $mapId === $this->edit->mapId; + $here = [$mapId]; + + if ($isTarget) { + foreach ((array) ($data[MapAreaAuthoring::KEY] ?? []) as $index => $entry) { + if (is_array($entry) && is_array($entry['area'] ?? null)) { + $this->shiftNamedArea($file, [MapAreaAuthoring::KEY, $index, 'area'], $entry['area'], strval($entry['id'] ?? $index)); + } + } + foreach (($data['worldObjects'] ?? []) as $index => $object) { + $this->shiftPoint($file, ['worldObjects', $index, 'anchor'], $object['anchor']); + foreach (($object['covers']['cells'] ?? []) as $cellIndex => $cell) { + $axis = $this->edit->axis === 'x' ? 0 : 1; + $coordinate = $cell[$axis]; + if (is_int($coordinate) && $coordinate >= $this->edit->at) { + $path = ['worldObjects', $index, 'covers', 'cells', $cellIndex, $axis]; + $shifted = $this->edit->getShiftedCoordinate($coordinate); + // An object is drawn whole; cutting the cells it covers would change what it hides. + if ($shifted === null) { + $this->reportBlocked($file, $path, sprintf('world object %s covers a cell on a removed line', strval($object['id'] ?? $index))); + } else { + $this->shifts[$file][self::formatKey($path)] = ['path' => $path, 'value' => $shifted]; + } + } + } + } + } + + foreach ((array) ($data['npcs'] ?? []) as $index => $npc) { + if (! is_array($npc)) { + continue; + } + if ($isTarget) { + $this->shiftPoint($file, ['npcs', $index], $npc); + if (is_array($npc['wanderArea'] ?? null)) { + $this->shiftArea($file, ['npcs', $index, 'wanderArea'], $npc['wanderArea']); + } + } + $this->walkCommands($npc['script'] ?? [], ['npcs', $index, 'script'], $here, $file); + foreach ((array) ($npc['dialogue'] ?? []) as $variantIndex => $variant) { + if (is_array($variant) && is_array($variant['script'] ?? null)) { + $this->walkCommands($variant['script'], ['npcs', $index, 'dialogue', $variantIndex, 'script'], $here, $file); + } + // A page runs a shared event script or a Cinematic as a map event does. + $scriptId = is_array($variant) ? trim(strval($variant['scriptId'] ?? '')) : ''; + if ($scriptId !== '') { + $this->addCommonEventStart($scriptId, $here); + } + $cinematicId = is_array($variant) ? trim(strval($variant['cinematicId'] ?? '')) : ''; + if ($cinematicId !== '') { + $this->cinematicTriggerMaps[$cinematicId][] = $mapId; + } + } + } + + foreach ((array) ($data['events'] ?? []) as $marker => $event) { + if (! is_array($event)) { + continue; + } + if ($isTarget && is_array($event['area'] ?? null)) { + $this->shiftArea($file, ['events', $marker, 'area'], $event['area']); + } + $eventData = is_array($event['data'] ?? null) ? $event['data'] : []; + $destination = trim(strval($eventData['destinationMap'] ?? '')); + $isBed = $destination === '' && $isTarget + && ltrim(strval($event['class'] ?? ''), '\\') === SleepEventTrigger::class; + if (is_array($eventData['spawnPoint'] ?? null) && ($destination === $this->edit->mapId || $isBed)) { + $this->shiftPoint($file, ['events', $marker, 'data', 'spawnPoint'], $eventData['spawnPoint']); + } + if (is_array($eventData['script'] ?? null) && $eventData['script'] !== []) { + $this->walkCommands($eventData['script'], ['events', $marker, 'data', 'script'], $here, $file); + } elseif (trim(strval($eventData['scriptId'] ?? '')) !== '') { + $this->addCommonEventStart(trim(strval($eventData['scriptId'])), $here); + } + $cinematicId = trim(strval($eventData['cinematicId'] ?? '')); + if ($cinematicId !== '') { + $this->cinematicTriggerMaps[$cinematicId][] = $mapId; + } + } + + foreach ((array) ($data['triggers'] ?? []) as $index => $trigger) { + if (! is_array($trigger)) { + continue; + } + if ($isTarget && is_array($trigger['trigger_area'] ?? null)) { + $this->shiftArea($file, ['triggers', $index, 'trigger_area'], $trigger['trigger_area']); + } + if (trim(strval($trigger['destinationMap'] ?? '')) === $this->edit->mapId && is_array($trigger['spawn_point'] ?? null)) { + $this->shiftPoint($file, ['triggers', $index, 'spawn_point'], $trigger['spawn_point']); + } + } + } + + /** + * Adds the system data file's starting positions on the edited map. + * + * @param array $system The evaluated system data. + */ + public function addSystemData(string $file, array $system): void + { + foreach ((array) ($system['startingPositions'] ?? []) as $key => $position) { + if (is_array($position) && trim(strval($position['destinationMap'] ?? '')) === $this->edit->mapId + && is_array($position['spawnPoint'] ?? null)) { + $this->shiftPoint($file, ['startingPositions', $key, 'spawnPoint'], $position['spawnPoint']); + } + } + } + + /** + * Adds one cutscene: its cast and script start on its start map (or on + * the maps whose events start it), and its finalizer may run from any + * map the script reaches, since a skip can end the script anywhere. + * Add every map first, so the maps that start it are known. + * + * @param array $data The evaluated data file. + */ + public function addCutscene(string $dataFile, string $scriptFile, string $id, array $data, mixed $commands): void + { + $startMap = trim(strval($data['startMap'] ?? '')); + $start = $startMap !== '' + ? [$startMap] + : self::mergeContexts(array_map(static fn(string $map): array => [$map], $this->cinematicTriggerMaps[$id] ?? [])); + foreach ((array) ($data['cast'] ?? []) as $index => $entry) { + if (is_array($entry)) { + $this->shiftPositionIn($dataFile, ['cast', $index], $entry, $start); + } + } + $this->transferContexts = []; + $end = $this->walkCommands($commands, [], $start, $scriptFile); + $this->walkCommands($data['finalizer'] ?? [], ['finalizer'], self::mergeContexts([$start, $end, ...$this->transferContexts]), $dataFile); + } + + /** + * Adds every reusable event script, each starting on every map that + * runs it. Add every map and cutscene first, so those starts are known. + * A reusable event script runs others on the maps it is on, so every + * script's starts are followed through the scripts that run it before + * anything moves. Starts only grow and the maps are finite, so following + * them ends, also when scripts run each other in a cycle. + */ + public function addCommonEvents(): void + { + do { + $known = $this->commonEventStarts; + foreach ($this->commonEvents as $id => $event) { + $this->walkCommands($event['commands'], [], $this->getCommonEventStart($id), null, [$id]); + } + } while ($this->commonEventStarts !== $known); + + foreach ($this->commonEvents as $id => $event) { + $this->walkCommands($event['commands'], [], $this->getCommonEventStart($id), $event['file'], [$id]); + } + } + + /** + * Records a context a reusable event script is started from. + * + * @param list|null $context + */ + private function addCommonEventStart(string $id, ?array $context): void + { + $context = $context === null ? null : self::mergeContexts([$context]); + $this->commonEventStarts[$id][json_encode($context, JSON_THROW_ON_ERROR)] = $context; + } + + /** + * The maps a reusable event script may start on; none when nothing known runs it. + * + * @return list|null + */ + private function getCommonEventStart(string $id): ?array + { + return isset($this->commonEventStarts[$id]) ? self::mergeContexts(array_values($this->commonEventStarts[$id])) : []; + } + + /** + * Walks a command list from a context, moving coordinates in the edited + * map's space when `$file` is given, and returns the context it ends in. + * A reusable event script it runs is followed inline, without moving + * anything, for the map it leaves the caller on and the scripts it runs. + * + * A parallel block's lanes run together on one current map, as + * {@see CommandMapContext} describes, so each lane can also be on any + * map a sibling lane transfers to. + * + * @param list $path The list's path in its file. + * @param list|null $context + * @param list $stack Reusable event scripts being followed. + * @param list $concurrent Maps a concurrent parallel lane can transfer to at any point. + * @return list|null + */ + private function walkCommands(mixed $commands, array $path, ?array $context, ?string $file, array $stack = [], array $concurrent = []): ?array + { + if (! is_array($commands)) { + return $context; + } + + foreach ($commands as $index => $command) { + if (! is_array($command)) { + continue; + } + $at = [...$path, $index]; + $type = strval($command['type'] ?? ''); + if ($file !== null) { + $this->shiftCommand($file, $type, $command, $at, $context); + } + + if ($type === 'sequence') { + $context = $this->walkCommands($command['commands'] ?? [], [...$at, 'commands'], $context, $file, $stack, $concurrent); + } + + if ($type === 'parallel') { + $lanes = CommandMapContext::getLanes($command); + $findCommonEvent = fn(string $id): ?array => is_array($this->commonEvents[$id]['commands'] ?? null) + ? $this->commonEvents[$id]['commands'] + : null; + $destinations = array_map( + static fn(array $lane): array => CommandMapContext::listTransferDestinations($lane['commands'], $findCommonEvent, $stack), + $lanes, + ); + // The block ends where a transferring lane can leave it, or where it started. + $ends = []; + foreach ($lanes as $laneIndex => $lane) { + $siblings = array_values(array_unique(array_merge([], ...array_values(array_diff_key($destinations, [$laneIndex => true]))))); + $start = self::mergeContexts([$context, ...array_map(self::getTransferContext(...), $siblings)]); + $end = $this->walkCommands($lane['commands'], [...$at, ...$lane['path']], $start, $file, $stack, array_values(array_unique([...$concurrent, ...$siblings]))); + if ($destinations[$laneIndex] !== []) { + $ends[] = $end; + } + } + if ($ends !== []) { + $context = self::mergeContexts($ends); + } + } + + if ($type === 'common_event') { + $id = trim(strval($command['id'] ?? '')); + $this->addCommonEventStart($id, $context); + if (isset($this->commonEvents[$id]) && ! in_array($id, $stack, true)) { + $context = $this->walkCommands($this->commonEvents[$id]['commands'], [], $context, null, [...$stack, $id], $concurrent); + } + } + + $arms = CommandMapContext::getArms($command); + if ($arms !== []) { + $ends = []; + foreach ($arms as $arm) { + $ends[] = $this->walkCommands($arm['commands'], [...$at, ...$arm['path']], $context, $file, $stack, $concurrent); + } + if (CommandMapContext::canSkipArms($command)) { + $ends[] = $context; + } + $context = self::mergeContexts($ends); + } + + if ($type === 'transfer') { + $destination = trim(strval($command['map'] ?? '')); + if ($file !== null && $destination === $this->edit->mapId) { + $this->shiftPoint($file, $at, $command); + } + $context = self::mergeContexts(array_map(self::getTransferContext(...), [$destination, ...$concurrent])); + $this->transferContexts[] = $context; + } + } + + return $context; + } + + /** + * Moves the map-space coordinates one command names. Relative movement + * (route steps, retracing) and screen positions are not map cells. + * + * @param array $command + * @param list $at The command's path. + * @param list|null $context + */ + private function shiftCommand(string $file, string $type, array $command, array $at, ?array $context): void + { + switch ($type) { + case 'move_player': + $this->shiftPointIn($file, $at, $command, $context); + break; + case 'move_route': + foreach ((array) ($command['waypoints'] ?? []) as $index => $waypoint) { + if (is_array($waypoint)) { + $this->shiftPositionIn($file, [...$at, 'waypoints', $index], $waypoint, $context); + } + } + // Steps are relative, so they cannot be rewritten, but a walk across the edited lines now ends elsewhere. + if (array_key_exists('steps', $command) && ($context === null || in_array($this->edit->mapId, $context, true))) { + $this->reportHandEdit($file, [...$at, 'steps'], sprintf('its relative steps may cross the %s lines; check where the route ends', + $this->edit instanceof LineRemoval ? 'removed' : 'inserted')); + } + break; + case 'camera': + $this->shiftTargetIn($file, $at, $command, $context); + foreach ((array) ($command['points'] ?? []) as $index => $point) { + if (is_array($point)) { + $this->shiftTargetIn($file, [...$at, 'points', $index], $point, $context); + } + } + break; + case 'field_animation': + $this->shiftTargetIn($file, $at, $command, $context); + break; + case 'stage_actor': + is_array($command['actor'] ?? null) + ? $this->shiftPositionIn($file, [...$at, 'actor'], $command['actor'], $context) + : $this->shiftPositionIn($file, $at, $command, $context); + break; + } + } + + /** + * Moves a subject reference that names a map position, as the Engine + * resolves one: the source's `target`, or the source itself. + * + * @param list $path + * @param array $source + * @param list|null $context + */ + private function shiftTargetIn(string $file, array $path, array $source, ?array $context): void + { + [$reference, $referencePath] = is_array($source['target'] ?? null) + ? [$source['target'], [...$path, 'target']] + : [$source, $path]; + $kind = strtolower(trim(strval($reference['kind'] ?? $reference['subject'] ?? ''))); + if ($kind === 'position') { + $this->shiftPointIn($file, $referencePath, $reference, $context); + } + } + + /** + * Moves a staged position: a map cell like any other point, or one relative to a map subject, which moves with + * its subject and is never rewritten. A relative offset counts cells, so when the edited lines fall between the + * subject and the point, or may, the point is reported for a hand edit. + * + * @param list $path + * @param array $point + * @param list|null $context + */ + private function shiftPositionIn(string $file, array $path, array $point, ?array $context): void + { + if (! RelativePositionFields::isRelative($point)) { + $this->shiftPointIn($file, $path, $point, $context); + return; + } + if ($context !== null && ! in_array($this->edit->mapId, $context, true)) { + return; + } + $axis = $this->edit->axis; + $offset = is_array($point['offset'] ?? null) && is_int($point['offset'][$axis] ?? null) ? $point['offset'][$axis] : 0; + if ($offset === 0) { + return; + } + $lines = $this->edit instanceof LineRemoval ? 'removed' : 'inserted'; + $position = RelativePositionFields::describePosition($point); + $cell = $context === [$this->edit->mapId] && is_array($point['at'] ?? null) + ? ($this->subjects?->resolveCell($point['at'])['cell'] ?? null) : null; + if ($cell === null) { + $this->reportHandEdit($file, [...$path, 'offset', $axis], sprintf('it stands %s, and the %s lines may fall between them; check its offset', $position, $lines)); + return; + } + $from = $cell[$axis === 'x' ? 0 : 1]; + $shiftedFrom = $this->edit->getShiftedCoordinate($from); + $shiftedTo = $this->edit->getShiftedCoordinate($from + $offset); + if ($shiftedFrom === null || $shiftedTo === null || $shiftedTo - $shiftedFrom !== $offset) { + $this->reportHandEdit($file, [...$path, 'offset', $axis], sprintf('it stands %s, and the %s lines fall between them; check its offset', $position, $lines)); + } + } + + /** + * Moves a point whose map is the script's current map: surely when that + * is the edited map, never when it is surely another, and otherwise + * reports it for a hand edit when it would move. + * + * @param list $path + * @param array $point + * @param list|null $context + */ + private function shiftPointIn(string $file, array $path, array $point, ?array $context): void + { + if ($context === [$this->edit->mapId]) { + $this->shiftPoint($file, $path, $point); + return; + } + if ($context !== null && ! in_array($this->edit->mapId, $context, true)) { + return; + } + $key = $this->edit->axis; + if (array_key_exists($key, $point) && (! is_int($point[$key]) || $point[$key] >= $this->edit->at)) { + $this->reportHandEdit($file, [...$path, $key], $context === null + ? 'it runs on a map that is not known here' + : sprintf('it may run on %s', implode(' or ', $context))); + } + } + + /** + * Moves a point surely in the edited map's space. Only the edited axis + * moves; a point naming only the other axis stays. A point on a removed + * line blocks the removal. + * + * @param list $path + * @param array $point + */ + private function shiftPoint(string $file, array $path, array $point): void + { + $key = $this->edit->axis; + if (! array_key_exists($key, $point)) { + return; + } + $value = $point[$key]; + if (is_int($value)) { + $shifted = $this->edit->getShiftedCoordinate($value); + if ($shifted === null) { + $this->reportBlocked($file, [...$path, $key], 'it stands on a removed line'); + } elseif ($shifted !== $value) { + $this->shifts[$file][self::formatKey([...$path, $key])] = ['path' => [...$path, $key], 'value' => $shifted]; + } + return; + } + $this->reportHandEdit($file, [...$path, $key], 'it is not a whole number'); + } + + /** + * Moves an area surely in the edited map's space: one starting at or + * beyond the line moves, one straddling inserted lines stretches, one + * straddling removed lines shrinks, and one wholly on removed lines + * blocks the removal. + * + * @param list $path + * @param array $area + */ + private function shiftArea(string $file, array $path, array $area): void + { + $startKey = $this->edit->axis; + $sizeKey = $startKey === 'x' ? 'width' : 'height'; + $start = $area[$startKey] ?? 0; + $size = $area[$sizeKey] ?? 1; + if (! is_int($start) || ! is_int($size)) { + $this->reportHandEdit($file, $path, 'its position or size is not a whole number'); + return; + } + $span = $this->edit->getShiftedSpan($start, $size); + if ($span === null) { + $this->reportBlocked($file, $path, 'the whole area is on removed lines'); + return; + } + [$newStart, $newSize] = $span; + if ($newStart !== $start) { + $this->shifts[$file][self::formatKey([...$path, $startKey])] = ['path' => [...$path, $startKey], 'value' => $newStart]; + } + if ($newSize !== $size) { + $this->shifts[$file][self::formatKey([...$path, $sizeKey])] = ['path' => [...$path, $sizeKey], 'value' => $newSize]; + } + } + + /** + * Moves a named area with the lines: a rectangle as an event's area + * moves, shrinking with removed lines; exact cells each move with their + * line. A cell on a removed line stops the removal, since dropping it + * would change the territory the area names; reshape the area first. + * Exact cells on both sides of inserted lines leave the new cells between + * them outside the area, which a rectangle would take in, so that is + * reported for the author to reshape, never guessed. + * + * @param list $path + * @param array $area + */ + private function shiftNamedArea(string $file, array $path, array $area, string $id): void + { + if (! array_key_exists('cells', $area)) { + $this->shiftArea($file, $path, $area); + return; + } + $axis = $this->edit->axis === 'x' ? 0 : 1; + if ($this->edit instanceof LineInsertion && self::spansLine((array) $area['cells'], $axis, $this->edit->at)) { + $this->reportHandEdit($file, [...$path, 'cells'], sprintf('area %s has cells on both sides of the inserted lines, which leave a gap in it; reshape it over them', $id)); + } + foreach ((array) $area['cells'] as $cellIndex => $cell) { + $coordinate = is_array($cell) ? ($cell[$axis] ?? null) : null; + if (! is_int($coordinate)) { + $this->reportHandEdit($file, [...$path, 'cells', $cellIndex], 'its position is not a whole number'); + continue; + } + if ($coordinate < $this->edit->at) { + continue; + } + $cellPath = [...$path, 'cells', $cellIndex, $axis]; + $shifted = $this->edit->getShiftedCoordinate($coordinate); + if ($shifted === null) { + $this->reportBlocked($file, $cellPath, sprintf('area %s has a cell on a removed line', $id)); + } else { + $this->shifts[$file][self::formatKey($cellPath)] = ['path' => $cellPath, 'value' => $shifted]; + } + } + } + + /** + * Whether exact cells stand on both sides of a line at the same place across it: on the line before it and on + * the line itself. + * + * @param array $cells + */ + private static function spansLine(array $cells, int $axis, int $at): bool + { + $before = []; + $on = []; + foreach ($cells as $cell) { + if (! is_array($cell) || ! is_int($cell[0] ?? null) || ! is_int($cell[1] ?? null)) { + continue; + } + $across = $cell[1 - $axis]; + if ($cell[$axis] === $at - 1) { + $before[$across] = true; + } elseif ($cell[$axis] === $at) { + $on[$across] = true; + } + } + + return array_intersect_key($before, $on) !== []; + } + + /** @param list $path */ + private function reportHandEdit(string $file, array $path, string $reason): void + { + $this->handEdits[$file][self::formatKey($path)] = ['path' => $path, 'reason' => $reason]; + } + + /** @param list $path */ + private function reportBlocked(string $file, array $path, string $reason): void + { + $this->blocked[$file][self::formatKey($path)] = ['path' => $path, 'reason' => $reason]; + } + + /** @param list $path */ + private static function formatKey(array $path): string + { + return implode("\0", $path); + } + + /** + * The context a transfer arrives in: its map, or an unknown map when it + * names none. + * + * @return list|null + */ + private static function getTransferContext(string $destination): ?array + { + return $destination !== '' ? [$destination] : null; + } + + /** + * The maps any of several contexts may be on; unknown when any is. + * + * @param list|null> $contexts + * @return list|null + */ + private static function mergeContexts(array $contexts): ?array + { + $maps = []; + foreach ($contexts as $context) { + if ($context === null) { + return null; + } + array_push($maps, ...$context); + } + $maps = array_values(array_unique($maps)); + sort($maps, SORT_STRING); + + return $maps; + } +} diff --git a/src/Maps/MapLinePlan.php b/src/Maps/MapLinePlan.php new file mode 100644 index 00000000..e9b8621a --- /dev/null +++ b/src/Maps/MapLinePlan.php @@ -0,0 +1,42 @@ + $handEdits Coordinates to move by hand, by project-relative file. + * @param list $notes What the author should know, such as whether saves follow. + */ + public function __construct( + public readonly MapLineEdit $edit, + private readonly SourceSetPlan $sources, + public readonly array $handEdits, + public readonly array $notes, + public readonly int $width, + public readonly int $height, + ) { + } + + public function getSourceSet(): SourceSetPlan { return $this->sources; } + + /** @return list Every file the edit writes. */ + public function getChangedPaths(): array { return $this->sources->getChangedPaths(); } + + /** @return list Every changed path. */ + public function apply(): array { return $this->sources->apply(); } + + public function revert(): void { $this->sources->revert(); } +} diff --git a/src/Maps/MapLinePlanner.php b/src/Maps/MapLinePlanner.php new file mode 100644 index 00000000..b7a70374 --- /dev/null +++ b/src/Maps/MapLinePlanner.php @@ -0,0 +1,443 @@ + self::buildPlan($root, $insertion)); + } + + /** + * Plans the removal; callers show the plan, including where saves on the + * removed lines land, and obtain confirmation before applying it. + * + * @param array{int, int}|null $landing Where saves on the removed lines land, after the removal; null proposes the reachable cell nearest them. + * @throws RuntimeException When the map or a data file cannot be read, something authored stands on a removed line, or the landing is unusable; nothing is written. + */ + public static function planRemoval(string $projectRoot, string $mapId, string $axis, int $at, int $count, ?array $landing = null): MapLinePlan + { + $removal = new LineRemoval($mapId, $axis, $at, $count, $landing); + + return ProjectDirectoryContext::run($projectRoot, static fn(string $root): MapLinePlan => self::buildPlan($root, $removal)); + } + + private static function buildPlan(string $root, MapLineEdit $edit): MapLinePlan + { + $directory = $root . '/assets/Maps/' . $edit->mapId; + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); + [$width, $height] = [$map->getWidth(), $map->getHeight()]; + // Relative points are judged against where their subjects stand before the edit moves them. + $subjects = MapSubjectCells::readMap($map); + $edit instanceof LineRemoval + ? $map->removeLines($edit->axis, $edit->at, $edit->count) + : $map->insertLines($edit->axis, $edit->at, $edit->count); + + $watched = $originals = $proposals = $before = $after = []; + $gridPaths = self::findGridPaths($directory); + foreach ($map->getGridSources() as $path => $source) { + $original = (string) file_get_contents($path); + $watched[$path] = $original; + if ($source !== $original) { + $originals[$path] = $original; + $proposals[$path] = $source; + $before[$path] = MapGridSource::parseSource($original, $path); + $after[$path] = MapGridSource::parseSource($source, $path); + } + } + + $dataPaths = self::findDataPaths($root); + $payloads = self::evaluate($dataPaths, $root); + $relative = static fn(string $path): string => substr($path, strlen($root) + 1); + foreach ($dataPaths as $path) { + $watched[$path] = (string) file_get_contents($path); + } + + // Physical geometry is edited by ProjectMap, not the coordinate inventory. + // Keep its source-preserved proposal in this same reversible source set. + $map->assertSourcesUnchanged(); + $mapData = $payloads[$map->dataPath]; + if ($map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + $mapData[MapPhysicalOccupancy::DATA_KEY] = $map->getMapDataField([MapPhysicalOccupancy::DATA_KEY]); + } + if ($map->getMapDataField([]) !== $map->data) { + $originals[$map->dataPath] = $watched[$map->dataPath]; + $proposals[$map->dataPath] = $map->captureLayerSnapshot()['source']; + $before[$map->dataPath] = PhpDataFile::getComparableValue($payloads[$map->dataPath]); + $after[$map->dataPath] = PhpDataFile::getComparableValue($mapData); + } + + $commonEvents = []; + foreach ($dataPaths as $path) { + if (dirname($path) === $root . '/assets/Events') { + $commonEvents[basename($path, '.php')] = ['file' => $path, 'commands' => $payloads[$path]]; + } + } + $inventory = new MapLineInventory($edit, $commonEvents, $subjects); + foreach ($dataPaths as $path) { + if (str_starts_with($path, $root . '/assets/Maps/') && is_array($payloads[$path])) { + $inventory->addMapData($path, substr(dirname($path), strlen($root . '/assets/Maps/')), $payloads[$path]); + } + } + if (is_array($payloads[$root . '/' . self::SYSTEM] ?? null)) { + $inventory->addSystemData($root . '/' . self::SYSTEM, $payloads[$root . '/' . self::SYSTEM]); + } + foreach ($dataPaths as $path) { + if (str_ends_with($path, '.data.php') && str_starts_with($path, $root . '/assets/Cutscenes/Cinematics/')) { + $scriptPath = substr($path, 0, -strlen('.data.php')) . '.script.php'; + if (is_array($payloads[$path]) && array_key_exists($scriptPath, $payloads)) { + $inventory->addCutscene($path, $scriptPath, basename(dirname($path)), $payloads[$path], $payloads[$scriptPath]); + } + } + } + $inventory->addCommonEvents(); + + $blocked = []; + foreach ($inventory->getBlocked() as $path => $entries) { + foreach ($entries as $entry) { + $blocked[] = sprintf('%s %s (%s)', $relative($path), PhpArraySourceDocument::describePath($entry['path']), $entry['reason']); + } + } + if ($blocked !== []) { + throw new RuntimeException(sprintf('%s %s is refused; nothing was changed. Move these off the removed lines first: %s.', + $edit->verb, $edit->description, implode('; ', $blocked))); + } + + $handEdits = []; + foreach ($inventory->getHandEdits() as $path => $edits) { + foreach ($edits as $handEdit) { + $handEdits[] = ['file' => $relative($path), 'where' => PhpArraySourceDocument::describePath($handEdit['path']), 'reason' => $handEdit['reason']]; + } + } + foreach ($inventory->getShifts() as $path => $shifts) { + $next = self::rewriteShifts($proposals[$path] ?? $watched[$path], + $path === $map->dataPath ? $mapData : $payloads[$path], $shifts, $relative($path), $handEdits); + if ($next !== null) { + $originals[$path] = $watched[$path]; + $proposals[$path] = $next['source']; + $before[$path] = PhpDataFile::getComparableValue($payloads[$path]); + $after[$path] = PhpDataFile::getComparableValue($next['value']); + } + } + + $notes = []; + $manifestPath = $root . '/' . self::MANIFEST; + $report = array_key_exists($manifestPath, $payloads) ? ProjectReachability::analyze($root . '/assets')->reports[$edit->mapId] ?? null : null; + if (! array_key_exists($manifestPath, $payloads)) { + $notes[] = sprintf('Existing saves are not migrated: the project has no %s.', self::MANIFEST); + } elseif (! ($report?->isAnyReachable(new Rect(0, 0, max(1, $width), max(1, $height))) ?? false)) { + // As for save relocations: a map the player reaches nowhere today, such as one not yet joined to the + // world, holds no save to move. + $notes[] = 'No save moves: the player reaches this map nowhere today.'; + } else { + if ($edit instanceof LineRemoval) { + $edit = $edit->withLanding(self::findLanding($report, $edit, $width, $height)); + $notes[] = sprintf('Saves standing on the removed lines land at x %d, y %d.', ...$edit->landing); + } + $next = self::planSaveMigration($watched[$manifestPath], $payloads[$manifestPath], $edit, $handEdits); + if ($next !== null) { + $originals[$manifestPath] = $watched[$manifestPath]; + $proposals[$manifestPath] = $next['source']; + $before[$manifestPath] = PhpDataFile::getComparableValue($payloads[$manifestPath]); + $after[$manifestPath] = PhpDataFile::getComparableValue($next['value']); + $notes[] = sprintf('Saves move with the map: save content version %d adds this map %s.', $next['value']['contentVersion'], + $edit instanceof LineRemoval ? 'removal' : 'shift'); + } + } + + $sources = new SourceSetPlan($root, $watched, $originals, $proposals, $before, $after, + static fn(): array => [...self::findGridPaths($directory), ...self::findDataPaths($root)], + static fn(string $path, string $staged): mixed => in_array($path, $gridPaths, true) + ? MapGridSource::parseSource((string) file_get_contents($staged), $path) + : PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + 'map line ' . ($edit instanceof LineRemoval ? 'removal' : 'insertion'), + 'Map and project data files', + ); + + return new MapLinePlan($edit, $sources, $handEdits, $notes, $map->getWidth(), $map->getHeight()); + } + + /** + * Where saves standing on the removed lines land, in the map's + * coordinates after the removal: the author's cell when given, which + * must be one the player can reach today off the removed lines, or else + * the reachable cell nearest the reachable part of the removed lines. + * + * @return array{int, int} + * @throws RuntimeException When no reachable cell is left, or the given landing is not one. + */ + private static function findLanding(?MapReachabilityReport $report, LineRemoval $removal, int $width, int $height): array + { + $vertical = $removal->axis === 'y'; + // Today's cell for a cell after the removal: lines from the removed ones on sit further along by the count. + $toToday = static fn(int $x, int $y): array => $vertical + ? [$x, $y >= $removal->at ? $y + $removal->count : $y] + : [$x >= $removal->at ? $x + $removal->count : $x, $y]; + if ($removal->landing !== null) { + [$x, $y] = $toToday(...$removal->landing); + if ($x >= $width || $y >= $height || ! ($report?->isReachable($x, $y) ?? false)) { + throw new RuntimeException(sprintf('Saves cannot land at x %d, y %d: the player cannot reach that cell. Choose a reachable cell off the removed lines.', + ...$removal->landing)); + } + + return $removal->landing; + } + $kept = $removed = []; + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + if (! ($report?->isReachable($x, $y) ?? false)) { + continue; + } + if ($removal->isRemoved($vertical ? $y : $x)) { + $removed[] = [$x, $y]; + } else { + $kept[] = [$x, $y]; + } + } + } + if ($kept === []) { + throw new RuntimeException(sprintf('%s leaves no cell the player can reach, so saves on it have nowhere to land; nothing was changed.', $removal->mapId)); + } + // Nearest the middle of where a saved player could stand on the removed lines, or of the lines themselves. + $middle = $removed !== [] + ? [array_sum(array_column($removed, 0)) / count($removed), array_sum(array_column($removed, 1)) / count($removed)] + : ($vertical ? [($width - 1) / 2, $removal->at + ($removal->count - 1) / 2] : [$removal->at + ($removal->count - 1) / 2, ($height - 1) / 2]); + usort($kept, static fn(array $a, array $b): int => [abs($a[0] - $middle[0]) + abs($a[1] - $middle[1]), $a[1], $a[0]] + <=> [abs($b[0] - $middle[0]) + abs($b[1] - $middle[1]), $b[1], $b[0]]); + [$x, $y] = $kept[0]; + + return $vertical ? [$x, $y >= $removal->at ? $y - $removal->count : $y] : [$x >= $removal->at ? $x - $removal->count : $x, $y]; + } + + /** + * Rewrites a data file's coordinates in place. A coordinate written as + * anything but a literal, or in a file that is not one returned array + * literal, is reported for a hand edit and left as authored. + * + * @param list, value: int}> $shifts + * @param list $handEdits + * @return array{source: string, value: array}|null Null when nothing in the file can be rewritten. + */ + private static function rewriteShifts(string $source, mixed $payload, array $shifts, string $file, array &$handEdits): ?array + { + $report = static function (array $path, string $reason) use (&$handEdits, $file): void { + $handEdits[] = ['file' => $file, 'where' => PhpArraySourceDocument::describePath($path), 'reason' => $reason]; + }; + try { + $document = PhpArraySourceDocument::parse($source); + } catch (SourceUnreadable $unreadable) { + foreach ($shifts as $shift) { + $report($shift['path'], 'the file is not one returned array literal (' . rtrim($unreadable->getMessage(), '.') . ')'); + } + return null; + } + + $next = $payload; + $kept = []; + foreach ($shifts as $shift) { + $refusal = self::findLiteralRefusal($document, $shift['path']); + if ($refusal !== null) { + $report($shift['path'], $refusal); + continue; + } + $value = &$next; + foreach ($shift['path'] as $step) { + $value = &$value[$step]; + } + $value = $shift['value']; + unset($value); + $kept[] = $shift; + } + if ($kept === []) { + return null; + } + + try { + return ['source' => ArraySourceWriter::rewrite($document, $payload, $next)->source, 'value' => $next]; + } catch (SourcePreservationRefusal $refusal) { + foreach ($kept as $shift) { + $report($shift['path'], rtrim($refusal->getMessage(), '.')); + } + return null; + } + } + + /** + * Why a coordinate is not a literal the file writes in its own place, + * or null when it is: every array above it must be written out entry by + * entry, so its place in the file is its place in the data. + * + * @param list $path + */ + private static function findLiteralRefusal(PhpArraySourceDocument $document, array $path): ?string + { + $node = $document->root(); + foreach ($path as $step) { + if ($node->kind !== SourceNode::ARRAY) { + return 'it is inside a value written as an expression or variable'; + } + if ($node->hasOpaqueKey) { + return 'it is inside an array built with a spread or a computed key'; + } + $entry = $node->entryFor($step); + if ($entry === null) { + return 'the file does not write it as an entry of its own'; + } + $node = $entry->value; + } + + return $node->kind === SourceNode::SCALAR ? null : 'it is written as an expression or variable, not a literal'; + } + + /** + * Raises the manifest's content version by one with a declarative map + * edit step, or reports why it must be done by hand. + * + * @param list $handEdits + * @return array{source: string, value: array}|null + */ + private static function planSaveMigration(string $source, mixed $manifest, MapLineEdit $edit, array &$handEdits): ?array + { + $next = SaveManifestStep::append($source, $manifest, $edit->getManifestEdits()); + if (is_array($next)) { + return $next; + } + $handEdits[] = ['file' => self::MANIFEST, 'where' => 'contentVersion and migrations', + 'reason' => sprintf('%s; raise contentVersion by one and add the step %s by hand', $next, + var_export(SaveManifestStep::describe($manifest, $edit->getManifestEdits()), true))]; + + return null; + } + + /** + * The map's grid files: its terminal layers, event layer and tile layers. + * + * @return list + */ + private static function findGridPaths(string $directory): array + { + $baseName = basename($directory); + $layers = is_dir($directory . '/' . MapLayerSource::DIRECTORY) + ? (glob($directory . '/' . MapLayerSource::DIRECTORY . '/*.{map,deco}.php', GLOB_BRACE) ?: []) + : [$directory . '/' . $baseName . '.map.php']; + + return [...$layers, $directory . '/' . $baseName . '.event.php', ...TileLayerSource::findPaths($directory)]; + } + + /** + * Every data file that can hold a coordinate in a map's space: map data + * files, the system data, reusable event scripts, cutscene pairs, and + * the save compatibility manifest. + * + * @return list + */ + private static function findDataPaths(string $root): array + { + $paths = []; + if (is_dir($root . '/assets/Maps')) { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root . '/assets/Maps', \FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $path = $file->getPathname(); + if ($file->isFile() && basename($path) === basename(dirname($path)) . '.data.php') { + $paths[] = $path; + } + } + } + foreach ([self::SYSTEM, self::MANIFEST] as $relative) { + if (is_file($root . '/' . $relative)) { + $paths[] = $root . '/' . $relative; + } + } + array_push($paths, ...(glob($root . '/assets/Events/*.php') ?: [])); + foreach (glob($root . '/assets/Cutscenes/Cinematics/*', GLOB_ONLYDIR) ?: [] as $cutscene) { + foreach (['data', 'script'] as $member) { + $path = $cutscene . '/' . basename($cutscene) . '.' . $member . '.php'; + if (is_file($path)) { + $paths[] = $path; + } + } + } + sort($paths, SORT_STRING); + + return $paths; + } + + /** + * Evaluates every data file outside the Editor process: together when + * they load cleanly as one unit, one by one otherwise, so a failure + * names its file. + * + * @param list $paths + * @return array Payloads by path. + */ + private static function evaluate(array $paths, string $root): array + { + try { + return array_combine($paths, PhpDataFile::evaluateIsolatedFiles($paths, $root)); + } catch (Throwable) { + $payloads = []; + foreach ($paths as $path) { + try { + $payloads[$path] = PhpDataFile::evaluateIsolated($path, $root); + } catch (Throwable $failure) { + throw new RuntimeException(sprintf('%s could not be read, so the rows or columns cannot be changed safely: %s', + substr($path, strlen($root) + 1), $failure->getMessage()), previous: $failure); + } + } + + return $payloads; + } + } +} diff --git a/src/Maps/MapPlacement.php b/src/Maps/MapPlacement.php new file mode 100644 index 00000000..29a81ecc --- /dev/null +++ b/src/Maps/MapPlacement.php @@ -0,0 +1,365 @@ + $field) { + $fieldPath = $field['path'] ?? []; + if (array_slice($fieldPath, 0, count($path)) !== array_map(strval(...), $path)) { + continue; + } + $tail = array_slice($fieldPath, count($path)); + $entry = [0]; + if (in_array($tail[0] ?? '', ['steps', 'waypoints', 'points'], true) && ctype_digit(strval($tail[1] ?? ''))) { + $entry[] = (int) $tail[1]; + $tail = array_slice($tail, 2); + } + $original[] = $i; + $commandFields[] = [...$field, 'entry' => $entry, 'sourceKey' => implode('.', $tail), 'dataPath' => $fieldPath, 'field' => json_encode($fieldPath, JSON_THROW_ON_ERROR)]; + } + foreach (self::describeFields($value, $commandFields) as $i => $field) { + if (isset($field['mapPlacement'])) { + $fields[$original[$i]]['mapPlacement'] = $field['mapPlacement']; + } + } + } + foreach ($value as $key => $child) { + if (is_array($child)) { + $walk($child, [...$path, $key]); + } + } + }; + $walk($definition, []); + foreach ($fields as &$field) { + if (($field['destination'] ?? false) === true) { + $data = $definition['data'] ?? []; + $position = $data['spawnPoint'] ?? []; + $valid = self::isMapPoint($position) && is_string($data['destinationMap'] ?? null); + $field['mapPlacement'] = ['kind' => 'event-transfer', 'map' => is_string($data['destinationMap'] ?? null) ? $data['destinationMap'] : null, + 'points' => [['point' => $valid ? [$position['x'], $position['y']] : [0, 0], + 'fields' => [], 'label' => 'Arrival']], + 'sourcePaths' => [['data', 'destinationMap'], ['data', 'spawnPoint', 'x'], ['data', 'spawnPoint', 'y']], + 'issue' => $valid ? null : 'The arrival needs a map reference and literal integer x/y coordinates.']; + } + } + return $fields; + } + + /** @param array $entry @param list> $fields */ + public static function describeFields(array $entry, array $fields): array + { + $own = []; + $nested = []; + foreach ($fields as $i => $field) { + $path = $field['entry'] ?? []; + if (!isset($field['sourceKey'], $field['field'])) { + continue; + } + if (count($path) === 1) { + $own[$field['sourceKey']] = $i; + } elseif (count($path) === 2) { + $nested[$path[1]][$field['sourceKey']] = $i; + } + } + $keys = static fn(array $indexes): array => array_map(static fn(int $i): string => $fields[$i]['field'], $indexes); + $type = $entry['type'] ?? ''; + if ($type === 'move_route' && isset($own['subject'])) { + $steps = []; + $x = $y = 0; + $issue = null; + try { + MovementRouteRunner::validatePathOptions($entry); + } catch (\Throwable $error) { + $issue = $error->getMessage(); + } + if (array_key_exists('waypoints', $entry)) { + $coordinates = ['x' => null, 'y' => null]; + foreach ((array) $entry['waypoints'] as $i => $waypoint) { + if (!is_array($waypoint)) { break; } + if (RelativePositionFields::isRelative($waypoint)) { + // The route ends wherever its subject stands; resolveRelativePoints() reads that cell. + $coordinates = ['x' => null, 'y' => null]; + $steps[] = ['point' => [null, null], 'fields' => [], 'relative' => self::readRelative($waypoint), + 'label' => sprintf('Waypoint %d (%s)', $i + 1, RelativePositionFields::describePosition($waypoint))]; + continue; + } + $axes = array_intersect_key($nested[$i] ?? [], $waypoint, array_flip(['x', 'y'])); + $coordinates = array_replace($coordinates, array_intersect_key($waypoint, $coordinates)); + $steps[] = ['point' => [$coordinates['x'], $coordinates['y']], 'fields' => $keys($axes), + 'axes' => array_intersect_key($waypoint, $coordinates), + 'label' => 'Waypoint ' . ($i + 1) . ' (authored target only)']; + } + } elseif (array_key_exists('retrace', $entry)) { + $issue ??= 'Retrace geometry belongs to the completed runtime route record; choose its stable reference in Recorded Route, not a fabricated map path.'; + } + foreach ((array) ($entry['steps'] ?? []) as $i => $step) { + try { + if (!is_array($step) || !is_string($step['direction'] ?? null)) { + throw new \InvalidArgumentException('Invalid direction.'); + } + $direction = MovementRouteRunner::directionVector($step['direction']); + $count = $step['count'] ?? 1; + if (!is_numeric($count) || !is_finite((float) $count) || (float) $count !== (float) (int) $count || $count < 0 || !is_bool($step['faceOnly'] ?? false)) { + throw new \InvalidArgumentException('Invalid count or face-only value.'); + } + $from = [$x, $y]; + if (!($step['faceOnly'] ?? false)) { + $x += (int) $direction->x * (int) $count; + $y += (int) $direction->y * (int) $count; + if (!is_int($x) || !is_int($y)) { + throw new \InvalidArgumentException('The route offsets exceed the supported integer range.'); + } + } + $steps[] = ['from' => $from, 'point' => [$x, $y], 'faceOnly' => $step['faceOnly'] ?? false, + 'fields' => $keys($nested[$i] ?? []), 'label' => 'Step ' . ($i + 1)]; + } catch (\Throwable $error) { + $issue = sprintf('Step %d: %s', $i + 1, $error->getMessage()); + break; + } + } + $fields[$own['subject']]['mapPlacement'] = ['kind' => isset($entry['waypoints']) ? 'waypoints' : 'route', 'points' => $steps, 'issue' => $issue, + 'subject' => $entry['subject'] ?? 'player', 'subjectId' => $entry['npcId'] ?? $entry['actorId'] ?? null, + 'sourcePaths' => array_values(array_column(array_filter($fields, static fn(array $field): bool => + in_array($field['sourceKey'] ?? '', ['type', 'subject', 'npcId', 'actorId', 'direction', 'count', 'faceOnly', 'x', 'y', 'retrace', 'remember', + ...self::RELATIVE_KEYS], true)), 'dataPath'))]; + } + $point = static function (array $indexes, array $position, string $kind, ?string $map = null) use (&$fields, $keys, $own): void { + if (!isset($indexes['x'], $indexes['y'])) { + return; + } + $valid = self::isMapPoint($position); + $fields[$indexes['x']]['mapPlacement'] = ['kind' => $kind, 'map' => $map, + 'points' => [['point' => $valid ? [$position['x'], $position['y']] : [0, 0], + 'fields' => $keys($indexes), 'label' => ucfirst($kind)]], + 'sourcePaths' => array_values(array_map(static fn(int $i): array => $fields[$i]['dataPath'] ?? [], + [...$indexes, ...array_intersect_key($own, array_flip(['type', 'target.kind', 'operation']))])), + 'issue' => $valid ? null : 'Map placement needs explicit non-negative integer x/y coordinates; relative or invalid axes are not inferred.']; + }; + $isStagedActor = $type === 'stage_actor' || ($type === '' && ($entry['kind'] ?? null) === 'staged_actor'); + $wrapped = $type === 'stage_actor' && is_array($entry['actor'] ?? null); + $actor = $wrapped ? $entry['actor'] : $entry; + $prefix = $wrapped ? 'actor.' : ''; + // An independent staged actor relative to a subject is shown where that subject puts it. A bound visual + // inherits its real subject's position and declares none. + if ($isStagedActor && !isset($actor['subject']) && RelativePositionFields::isRelative($actor) + && isset($own[$prefix . RelativePositionFields::KIND_FIELD])) { + $relative = array_intersect_key($own, array_flip(array_map(static fn(string $key): string => $prefix . $key, self::RELATIVE_KEYS))); + $fields[$own[$prefix . RelativePositionFields::KIND_FIELD]]['mapPlacement'] = ['kind' => 'stage', 'map' => null, + 'points' => [['point' => [null, null], 'fields' => [], 'relative' => self::readRelative($actor), + 'label' => sprintf('Staged actor (%s)', RelativePositionFields::describePosition($actor))]], + 'sourcePaths' => array_values(array_map(static fn(int $i): array => $fields[$i]['dataPath'] ?? [], $relative)), + 'issue' => null]; + } + if (in_array($type, ['transfer', 'move_player'], true)) { + $point(array_intersect_key($own, array_flip(['x', 'y', 'map'])), $entry, $type === 'transfer' ? 'transfer' : 'position', is_string($entry['map'] ?? null) ? $entry['map'] : null); + } + if (in_array($type, ['camera', 'field_animation'], true) && ($entry['target']['kind'] ?? '') === 'position') { + $indexes = []; + foreach (['x', 'y'] as $axis) { + if (isset($own['target.' . $axis])) { + $indexes[$axis] = $own['target.' . $axis]; + } + } + $point($indexes, $entry['target'], $type === 'camera' ? 'camera' : 'effect'); + } + if ($type === 'camera' && ($entry['operation'] ?? '') === 'route') { + $points = $indexes = $sourcePaths = []; + foreach (array_intersect_key($own, array_flip(['type', 'operation'])) as $index) { + $sourcePaths[] = $fields[$index]['dataPath'] ?? []; + } + $issue = null; + $from = null; + foreach ((array) ($entry['points'] ?? []) as $i => $target) { + if (!is_array($target) || ($target['kind'] ?? '') !== 'position') { + // Actor/screen-relative targets break the known map-space line. + $from = null; + continue; + } + $coordinates = array_intersect_key($nested[$i] ?? [], array_flip(['x', 'y'])); + if (!isset($coordinates['x'], $coordinates['y'])) { + continue; + } + $valid = self::isMapPoint($target); + $issue ??= $valid ? null : 'Camera route placement needs explicit non-negative integer x/y coordinates.'; + $indexes[$coordinates['x']] = count($points); + $position = $valid ? [$target['x'], $target['y']] : [0, 0]; + $points[] = ['point' => $position, 'from' => $from, 'fields' => $keys($coordinates), 'label' => 'Point ' . ($i + 1)]; + $from = $valid ? $position : null; + foreach ($coordinates as $index) { + $sourcePaths[] = $fields[$index]['dataPath'] ?? []; + } + if (isset($nested[$i]['kind'])) { + $sourcePaths[] = $fields[$nested[$i]['kind']]['dataPath'] ?? []; + } + } + foreach ($indexes as $index => $selected) { + $fields[$index]['mapPlacement'] = ['kind' => 'camera', 'points' => $points, 'selectedPoint' => $selected, + 'sourcePaths' => $sourcePaths, 'issue' => $issue]; + } + } + return $fields; + } + + /** @return array{at: array, offset: array{x: int, y: int}} */ + private static function readRelative(array $entry): array + { + $offset = is_array($entry['offset'] ?? null) ? $entry['offset'] : []; + + return ['at' => is_array($entry['at'] ?? null) ? $entry['at'] : [], + 'offset' => ['x' => is_int($offset['x'] ?? null) ? $offset['x'] : 0, 'y' => is_int($offset['y'] ?? null) ? $offset['y'] : 0]]; + } + + /** + * Shows each subject-relative point at its subject's cell plus the offset, on the map the owner's script runs + * on, or says why it has none there. A waypoint after it that keeps an axis keeps that resolved axis. + * + * @param MapSubjectCells|null $subjects The map the owner's script runs on; null when it names none. + * @param string $unavailable Why no map is known, when none is. + */ + public static function resolveRelativePoints(array $placement, ?MapSubjectCells $subjects, string $unavailable): array + { + if (!array_any($placement['points'] ?? [], static fn(array $point): bool => isset($point['relative']))) { + return $placement; + } + if ($subjects !== null) { + $placement['map'] = $subjects->mapId; + } + $coordinates = [null, null]; + foreach ($placement['points'] as &$point) { + if (isset($point['relative'])) { + $resolved = $subjects?->resolveCell($point['relative']['at']) ?? ['cell' => null, 'issue' => $unavailable]; + $cell = $resolved['cell']; + $coordinates = $cell === null ? [null, null] + : [$cell[0] + $point['relative']['offset']['x'], $cell[1] + $point['relative']['offset']['y']]; + $point['point'] = $coordinates; + $point['issue'] = $resolved['issue']; + } elseif ($placement['kind'] === 'waypoints' && isset($point['axes'])) { + $coordinates = [$point['axes']['x'] ?? $coordinates[0], $point['axes']['y'] ?? $coordinates[1]]; + $point['point'] = $coordinates; + } + } + unset($point); + + return $placement; + } + + private static function isMapPoint(mixed $position): bool + { + return is_array($position) && is_int($position['x'] ?? null) && is_int($position['y'] ?? null) + && $position['x'] >= 0 && $position['y'] >= 0; + } + + /** Object member order is transport trivia, but types and list order are not. */ + public static function areValuesEqual(mixed $left, mixed $right): bool + { + if (!is_array($left) || !is_array($right)) { + return $left === $right; + } + if (array_is_list($left) !== array_is_list($right) || count($left) !== count($right)) { + return false; + } + foreach ($left as $key => $value) { + if (!array_key_exists($key, $right) || !self::areValuesEqual($value, $right[$key])) { + return false; + } + } + return true; + } + + /** Refuse even unchanged expressions; a click must not depend on evaluated PHP. */ + public static function getSourceIssue(PhpArraySourceDocument $document, array $paths): ?string + { + foreach ($paths as $path) { + $node = $document->root(); + foreach ($path as $segment) { + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey) { + return 'Map placement requires literal source fields, not expressions, variables or spread arrays.'; + } + $key = is_string($segment) && (string) (int) $segment === $segment ? (int) $segment : $segment; + $node = $node->entryFor($key)?->value; + if ($node === null) { + // A shared optional field may be inserted into a literal array. + break; + } + } + if ($node !== null && $node->kind !== SourceNode::SCALAR) { + return 'Map placement requires literal source fields, not expressions or variables.'; + } + } + return null; + } + + /** Convert one map click to shared row values, keeping all other authored fields. */ + public static function getChanges(array $placement, int $point, int $x, int $y, ?array $origin, string $map): array + { + if (($placement['issue'] ?? null) !== null) { + throw new \InvalidArgumentException($placement['issue']); + } + $selected = $placement['points'][$point] ?? throw new \InvalidArgumentException('Choose an authored point first.'); + if (isset($selected['relative'])) { + throw new \InvalidArgumentException('This point follows its subject; change its subject or offset instead of placing it.'); + } + $fields = $selected['fields']; + if ($placement['kind'] === 'waypoints') { + // A click changes only authored axes. Omitted axes retain the prior runtime coordinate. + $values = array_intersect_key(['x' => (string) $x, 'y' => (string) $y], $fields); + if ($values === []) { + throw new \InvalidArgumentException('Author at least one waypoint axis before placing it.'); + } + } elseif ($placement['kind'] !== 'route') { + $values = ['x' => (string) $x, 'y' => (string) $y]; + if (isset($fields['map'])) { + $values['map'] = $map; + } + } else { + if ($origin === null || count($origin) !== 2 || !is_int($origin[0]) || !is_int($origin[1])) { + throw new \InvalidArgumentException('Choose a preview origin; it is not saved as the actor position.'); + } + $dx = $x - $origin[0] - $selected['from'][0]; + $dy = $y - $origin[1] - $selected['from'][1]; + if (($dx !== 0 && $dy !== 0) || ($dx === 0 && $dy === 0)) { + throw new \InvalidArgumentException('Choose a cell in one cardinal direction from the previous point.'); + } + $direction = null; + foreach (MovementRouteRunner::DIRECTIONS as $name) { + $vector = MovementRouteRunner::directionVector($name); + if ($vector->x === (float) ($dx <=> 0) && $vector->y === (float) ($dy <=> 0)) { + $direction = $name; + break; + } + } + $values = ['direction' => $direction]; + if (!$selected['faceOnly']) { + $values['count'] = (string) (abs($dx) + abs($dy)); + } + } + $changes = []; + foreach ($values as $name => $value) { + if (!isset($fields[$name]) || $value === null) { + throw new \InvalidArgumentException('The shared schema does not expose that coordinate.'); + } + $changes[$fields[$name]] = $value; + } + return $changes; + } +} diff --git a/src/Maps/MapReferences.php b/src/Maps/MapReferences.php new file mode 100644 index 00000000..0365d069 --- /dev/null +++ b/src/Maps/MapReferences.php @@ -0,0 +1,70 @@ + + */ + public function describe(string $mapId): array + { + $found = []; + $positions = $this->workspace->getSystemField('startingPositions'); + if (is_array($positions)) { + foreach ($positions as $role => $position) { + if (is_array($position) && ($position['destinationMap'] ?? null) === $mapId) { + $found[] = sprintf('the %s starting position (System)', $role); + } + } + } + foreach ($this->workspace->maps as $map) { + foreach ($map->getEventDefinitions() as $marker => $definition) { + if (is_array($definition) && (($definition['data'] ?? [])['destinationMap'] ?? null) === $mapId) { + $found[] = sprintf('event %s on %s', $marker, $map->mapId); + } + } + } + // A local map that names this one as its atlas parent loses its place above. + array_push($found, ...MapAtlas::describeParentReferences($this->workspace->maps, $mapId)); + foreach (CutsceneType::cases() as $type) { + foreach ($this->workspace->cutscenes?->assets($type) ?? [] as $asset) { + if (self::hasTransferTo($asset->commands(), $mapId)) { + $found[] = sprintf('%s %s', $type->noun(), $asset->id); + } + } + } + + return $found; + } + + /** Whether a command tree, branches included, transfers to the map. */ + private static function hasTransferTo(array $commands, string $mapId): bool + { + if (($commands['type'] ?? null) === 'transfer' && ($commands['map'] ?? null) === $mapId) { + return true; + } + + return array_any($commands, static fn(mixed $value): bool => is_array($value) && self::hasTransferTo($value, $mapId)); + } +} diff --git a/src/Maps/MapSubjectCells.php b/src/Maps/MapSubjectCells.php new file mode 100644 index 00000000..54ef6551 --- /dev/null +++ b/src/Maps/MapSubjectCells.php @@ -0,0 +1,125 @@ +> Attachment cells by object id, then attachment id. */ + private array $attachments = [], + ) { + } + + /** Reads the map as it stands now, so a later edit to it does not change what was read. */ + public static function readMap(ProjectMap $map): self + { + try { + return new self($map->mapId, $map->getNpcs(), MapLandmarks::readAnchors($map), null, self::readAttachments($map)); + } catch (InvalidArgumentException $error) { + return new self($map->mapId, $map->getNpcs(), null, $error->getMessage()); + } + } + + /** @return array> */ + private static function readAttachments(ProjectMap $map): array + { + try { + $entries = WorldObjectAuthoring::readEntries($map); + } catch (InvalidArgumentException) { + return []; + } + $cells = []; + foreach ($entries as $entry) { + $found = is_string($entry['id'] ?? null) ? WorldObjectAuthoring::findAttachments($map, $entry['id']) : null; + foreach ($found['attachments'] ?? [] as $attachment) { + $cells[$entry['id']][$attachment->id] = [$found['anchor']['x'] + $attachment->cellX, $found['anchor']['y'] + $attachment->cellY]; + } + } + return $cells; + } + + /** + * The cell a subject reference stands on, or why it has none here. + * + * @param array $reference An `at` reference: kind, optional id and, on a world object, optional attachment. + * @return array{cell: array{int, int}|null, issue: string|null} + */ + public function resolveCell(array $reference): array + { + $kind = strval($reference['kind'] ?? ''); + $id = is_string($reference['id'] ?? null) ? $reference['id'] : ''; + if (in_array($kind, ['npc', 'world_object', 'marker'], true) && $id === '') { + return self::describeIssue('Choose the subject this position is relative to.'); + } + if ($kind === 'world_object' && is_string($reference['attachment'] ?? null)) { + $cell = $this->attachments[$id][$reference['attachment']] ?? null; + return $cell === null + ? self::describeIssue(sprintf('World object %s on map %s has no attachment %s.', $id, $this->mapId, $reference['attachment'])) + : ['cell' => $cell, 'issue' => null]; + } + + return match ($kind) { + 'npc' => $this->resolveNpcCell($id), + 'world_object' => $this->resolveAnchorCell($this->anchors?->objects, $id, 'world object'), + 'marker' => $this->resolveAnchorCell($this->anchors?->events, $id, 'event marker'), + 'player' => self::describeIssue('The player stands where the scene finds them; this point is known only while it runs.'), + 'party_actor' => self::describeIssue('A party actor stands where the scene finds it; this point is known only while it runs.'), + 'staged_actor' => self::describeIssue('A staged actor stands where the scene puts it; this point is known only while it runs.'), + default => self::describeIssue('Choose what this position is relative to.'), + }; + } + + /** @return array{cell: array{int, int}|null, issue: string|null} */ + private function resolveNpcCell(string $id): array + { + $index = $this->npcs->indexOfId($id); + $npc = $index === null ? null : $this->npcs->get($index); + + return $npc === null + ? self::describeIssue(sprintf('Map %s has no NPC %s.', $this->mapId, $id)) + : ['cell' => [$npc->getX(), $npc->getY()], 'issue' => null]; + } + + /** + * @param array|null $anchors + * @return array{cell: array{int, int}|null, issue: string|null} + */ + private function resolveAnchorCell(?array $anchors, string $id, string $noun): array + { + if ($anchors === null) { + return self::describeIssue(sprintf('Map %s cannot be read: %s', $this->mapId, strval($this->anchorIssue))); + } + $anchor = $anchors[$id] ?? null; + if ($anchor === null) { + return self::describeIssue(sprintf('Map %s has no %s %s.', $this->mapId, $noun, $id)); + } + $cell = $anchor->area->firstCell; + + return ['cell' => [(int) $cell->x, (int) $cell->y], 'issue' => null]; + } + + /** @return array{cell: null, issue: string} */ + private static function describeIssue(string $issue): array + { + return ['cell' => null, 'issue' => $issue]; + } +} diff --git a/src/Maps/PhysicalFootprintCodec.php b/src/Maps/PhysicalFootprintCodec.php new file mode 100644 index 00000000..4cfad208 --- /dev/null +++ b/src/Maps/PhysicalFootprintCodec.php @@ -0,0 +1,88 @@ +> */ + public static function decode(string $encoded): array + { + try { + // Objects with numeric keys are not JSON lists. + $rows = json_decode($encoded, false, 64, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new InvalidArgumentException('Physical footprint must be JSON rows of final collision integers or null: ' . $error->getMessage(), previous: $error); + } + + return self::decodeRows($rows); + } + + /** @return list> Geometry is validated by TilesetPiece, never guessed here. */ + public static function decodeRows(mixed $rows): array + { + self::assertRows($rows); + $decoded = []; + foreach ($rows as $y => $row) { + $decoded[$y] = []; + foreach ($row as $x => $cell) { + $type = is_int($cell) ? CollisionType::tryFrom($cell) : null; + if ($cell !== null && (! is_int($cell) || $type === null || $type === CollisionType::PASS_THROUGH)) { + throw new InvalidArgumentException("Physical footprint cell {$x}, {$y} must be a final CollisionType integer or null; PASS_THROUGH is not a physical cell."); + } + $decoded[$y][$x] = $type; + } + } + + return $decoded; + } + + /** @return list> Only valid authored cells are projected, without repairing or padding them. */ + public static function exportRows(mixed $rows): array + { + self::assertRows($rows); + $exported = []; + foreach ($rows as $y => $row) { + $exported[$y] = []; + foreach ($row as $x => $type) { + if ($type !== null && (! $type instanceof CollisionType || $type === CollisionType::PASS_THROUGH)) { + throw new InvalidArgumentException("Physical footprint cell {$x}, {$y} must be a final CollisionType case or null."); + } + $exported[$y][$x] = $type?->value; + } + } + + return $exported; + } + + /** Display invalid declarations as authored so the record row can repair them, not as an invented valid mask. */ + public static function encode(mixed $rows): string + { + if ($rows === null) { return ''; } + try { + return json_encode($rows, JSON_THROW_ON_ERROR); + } catch (JsonException) { + // Unsupported enum declarations remain visible for explicit row repair. + return PhpValueExporter::export($rows); + } + } + + private static function assertRows(mixed $rows): void + { + if (! is_array($rows) || ! array_is_list($rows)) { + throw new InvalidArgumentException('Physical footprint must be a zero-based list of rows.'); + } + foreach ($rows as $y => $row) { + if (! is_array($row) || ! array_is_list($row)) { + throw new InvalidArgumentException("Physical footprint row {$y} must be a zero-based list of cells."); + } + } + } +} diff --git a/src/Maps/PieceDoorwayCodec.php b/src/Maps/PieceDoorwayCodec.php new file mode 100644 index 00000000..91cec71e --- /dev/null +++ b/src/Maps/PieceDoorwayCodec.php @@ -0,0 +1,54 @@ + Empty for an empty line. + * @throws InvalidArgumentException When the line does not name distinct door cells. + */ + public static function decode(string $encoded): array + { + $cells = []; + foreach (explode(';', $encoded) as $part) { + $part = trim($part); + if ($part === '') { continue; } + if (preg_match('/\A(-?\d+)\s*,\s*(-?\d+)(?:\s+([a-z]+))?\z/', $part, $match) !== 1) { + throw new InvalidArgumentException("Painted doors are cells such as `2,4` or `2,4 front`, separated by semicolons; `{$part}` is not one."); + } + $cells[] = ['x' => (int) $match[1], 'y' => (int) $match[2]] + (isset($match[3]) ? ['entrance' => $match[3]] : []); + } + + return $cells === [] ? [] : Doorway::exportCells(Doorway::readCells($cells, 'Painted doors')); + } + + /** Displays an authored doorway as typed, or as authored when it cannot be read, so its row can repair it. */ + public static function encode(mixed $cells): string + { + if ($cells === null || $cells === []) { return ''; } + try { + return implode('; ', array_map(static fn(array $cell): string => "{$cell['x']},{$cell['y']}" + . ($cell['entrance'] === DoorwayEntrance::ON ? '' : ' ' . $cell['entrance']->value), Doorway::readCells($cells, 'Painted doors'))); + } catch (InvalidArgumentException) { + return PhpValueExporter::export($cells); + } + } +} diff --git a/src/Maps/ProjectMigrationProbe.php b/src/Maps/ProjectMigrationProbe.php new file mode 100644 index 00000000..ffac2ff4 --- /dev/null +++ b/src/Maps/ProjectMigrationProbe.php @@ -0,0 +1,92 @@ + $reason], JSON_THROW_ON_ERROR)); + exit(0); + }; + $autoload = $root . '/vendor/autoload.php'; + if (! is_file($autoload)) { + $fail('the project has no vendor/autoload.php, so the game could not load a migration class; run composer install in it'); + } + chdir($root); + ob_start(); + try { + require $autoload; + $data = (static fn(string $path): mixed => require $path)($manifestPath); + if (! is_array($data)) { + $fail('the manifest does not return an array'); + } + \Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromArray($projectId, $data, 'assets/Data/save-compatibility.php'); + } catch (Throwable $failure) { + ob_end_clean(); + $fail(rtrim($failure->getMessage(), '.')); + } + ob_end_clean(); + fwrite(STDOUT, json_encode(['manifest' => base64_encode(serialize($data))], JSON_THROW_ON_ERROR)); + PHP; + + private function __construct() + { + } + + /** + * @param string $manifestPath The manifest file to read, a staged copy included. + * @return array The manifest as the game evaluates it. + * @throws RuntimeException With the reason the game would refuse it. + */ + public static function readManifest(string $projectRoot, string $projectId, string $manifestPath): array + { + $runner = tempnam(sys_get_temp_dir(), 'ichiloto-migration-probe-'); + if ($runner === false || file_put_contents($runner, self::RUNNER) === false) { + throw new RuntimeException('The save migration check could not be prepared.'); + } + + try { + $process = proc_open([PHP_BINARY, $runner, $projectRoot, $projectId, $manifestPath], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $projectRoot); + if (! is_resource($process)) { + throw new RuntimeException('The save migration check could not start.'); + } + $output = (string) stream_get_contents($pipes[1]); + $errors = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + } finally { + @unlink($runner); + } + + $answer = json_decode($output, true); + if (! is_array($answer)) { + throw new RuntimeException(sprintf('The save migration check failed: %s', trim($errors) !== '' ? trim($errors) : 'it gave no answer')); + } + if (is_string($answer['error'] ?? null)) { + throw new RuntimeException($answer['error']); + } + $manifest = unserialize((string) base64_decode((string) ($answer['manifest'] ?? ''), true), ['allowed_classes' => false]); + + return is_array($manifest) ? $manifest : throw new RuntimeException('The save migration check gave no manifest.'); + } +} diff --git a/src/Maps/SaveLandingCells.php b/src/Maps/SaveLandingCells.php new file mode 100644 index 00000000..18b3e3f9 --- /dev/null +++ b/src/Maps/SaveLandingCells.php @@ -0,0 +1,136 @@ +> $grid Resolved collision by row then column. + * @param array $npcs NPC start cells by `x,y`. + * @param array $events Event marker cells by `x,y`. + * @param list $areas Every event's cells as the Engine resolves them. + */ + private function __construct( + public readonly string $mapId, + private readonly array $grid, + private readonly array $npcs, + private readonly array $events, + private readonly array $areas, + private readonly MapReachabilityReport $report, + ) { + } + + /** + * Reads the map as it is held, unsaved edits included. + * + * @param MapReachabilityReport $report Where the player can reach on this map. + */ + public static function readMap(ProjectMap $map, MapReachabilityReport $report): self + { + $events = []; + for ($y = 0, $height = $map->getHeight(); $y < $height; $y++) { + for ($x = 0, $width = $map->getWidth(); $x < $width; $x++) { + if ($map->hasLayerCell(MapLayers::EVENT, $x, $y) && $map->getEventMarkerAt($x, $y) !== null) { + $events["{$x},{$y}"] = true; + } + } + } + $resolved = MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), $map->mapId)->events; + $areas = array_values(array_map(static fn(array $event): CellArea => CellArea::fromArray($event['area'], 'Event area'), + array_filter($resolved, static fn(array $event): bool => is_array($event['area'] ?? null)))); + + return new self($map->mapId, $map->getResolvedCollisionMap(), self::readNpcCells((array) ($map->getEditableData()['npcs'] ?? [])), + $events, $areas, $report); + } + + /** + * Where NPCs stand when the map loads. Every NPC starts on its authored + * cell and a wanderer never steps onto the player, so these are the only + * cells an NPC can hold under a loaded save. + * + * @param array $entries The map's `npcs` entries. + * @return array By `x,y`. + */ + public static function readNpcCells(array $entries): array + { + $npcs = []; + foreach ($entries as $entry) { + $npc = NpcPlacement::fromArray($entry); + if ($npc !== null) { + $npcs["{$npc->x},{$npc->y}"] = true; + } + } + + return $npcs; + } + + /** Whether the player walks through the cell: on the map, not blocking movement, no NPC on it. */ + public function isPassable(int $x, int $y): bool + { + return isset($this->grid[$y][$x]) && ! isset($this->npcs["{$x},{$y}"]) + && ! CollisionType::from($this->grid[$y][$x])->blocksMovement(); + } + + /** + * Whether a save may land on the cell. + * + * @param array{int, int}|null $from The cell on this map the save moves off, whose own events it may stay + * inside; null for a save arriving from another map. + */ + public function canLand(int $x, int $y, ?array $from = null): bool + { + return $this->findLandingRefusal($x, $y, $from) === null; + } + + /** + * Says why a save may not land on the cell, or null when it may. + * + * @param array{int, int}|null $from As for {@see canLand()}. + */ + public function findLandingRefusal(int $x, int $y, ?array $from = null): ?string + { + $type = $this->grid[$y][$x] ?? null; + if ($type === null) { + return 'it is off the map'; + } + $collision = CollisionType::from($type); + if ($collision->blocksMovement()) { + return 'it is solid'; + } + if (isset($this->npcs["{$x},{$y}"])) { + return 'an NPC stands there'; + } + if ($collision !== CollisionType::NONE) { + return sprintf('it is not plain floor (%s)', strtolower(str_replace('_', ' ', $collision->name))); + } + if (isset($this->events["{$x},{$y}"]) || array_any($this->areas, static fn(CellArea $area): bool => $area->containsCell($x, $y) + && ($from === null || ! $area->containsCell(...$from)))) { + return 'an event is there'; + } + if (! $this->report->isReachable($x, $y)) { + return 'the player cannot reach it'; + } + + return null; + } +} diff --git a/src/Maps/SaveManifestAlias.php b/src/Maps/SaveManifestAlias.php new file mode 100644 index 00000000..b66d4bcc --- /dev/null +++ b/src/Maps/SaveManifestAlias.php @@ -0,0 +1,71 @@ +getMessage()), previous: $failure); + } + $aliases = is_array($manifest) ? ($manifest['aliases'][$category->value] ?? []) : null; + if (! is_array($aliases) || ! array_is_list($aliases)) { + throw new RuntimeException(sprintf('%s aliases.%s is not a list.', SaveManifestStep::PATH, $category->value)); + } + foreach ($aliases as $alias) { + if (($alias['from'] ?? null) !== $from) { continue; } + if (($alias['to'] ?? null) === $to) { return null; } + throw new RuntimeException(sprintf('%s already sends %s to %s.', SaveManifestStep::PATH, $from, strval($alias['to'] ?? ''))); + } + $next = $manifest; + $next['aliases'][$category->value] = [...$aliases, ['from' => $from, 'to' => $to]]; + try { + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $manifest, $next)->source; + } catch (SourceUnreadable|SourcePreservationRefusal $refusal) { + throw new RuntimeException(sprintf('%s cannot take the alias: %s', SaveManifestStep::PATH, rtrim($refusal->getMessage(), '.')), previous: $refusal); + } + $readBack = SaveManifestStep::createReadBack($projectRoot, $projectId, $next, 'the alias'); + + return new SourceSetPlan($projectRoot, [$manifestPath => $source], [$manifestPath => $source], [$manifestPath => $rewritten], + [$manifestPath => $manifest], [$manifestPath => $next], + static fn(): array => [$manifestPath], $readBack, 'save identity alias', 'Save compatibility manifest'); + } +} diff --git a/src/Maps/SaveManifestStep.php b/src/Maps/SaveManifestStep.php new file mode 100644 index 00000000..cc1fd5b9 --- /dev/null +++ b/src/Maps/SaveManifestStep.php @@ -0,0 +1,129 @@ +>> $edits Declared position edits by manifest key. + * @return array + */ + public static function describe(mixed $manifest, array $edits): array + { + $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; + + return ['from' => $version, 'to' => is_int($version) ? $version + 1 : null, ...$edits]; + } + + /** + * Appends the step. + * + * @param string $source The manifest's source. + * @param mixed $manifest The manifest as evaluated. + * @param array>> $edits Declared position edits by manifest key. + * @return array{source: string, value: array}|string The rewritten manifest, or why it cannot be. + */ + public static function append(string $source, mixed $manifest, array $edits): array|string + { + return self::appendStep($source, $manifest, self::describe($manifest, $edits)); + } + + /** + * Appends a step that runs a project migration class. The Engine reads a + * step as a class or as declared position edits, never both, so the step + * names the class alone. + * + * @param string $source The manifest's source. + * @param mixed $manifest The manifest as evaluated. + * @param string $class The migration class, fully qualified. + * @return array{source: string, value: array}|string The rewritten manifest, or why it cannot be. + */ + public static function appendClass(string $source, mixed $manifest, string $class): array|string + { + $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; + + return self::appendStep($source, $manifest, ['from' => $version, 'to' => is_int($version) ? $version + 1 : null, 'class' => $class]); + } + + /** + * Reads a staged manifest back as the game reads it, for a source set + * plan: through the project's own autoloader ({@see ProjectMigrationProbe}) + * when a step names a project migration class, which the Editor's process + * cannot load, else by the Engine's SaveCompatibilityManifest here. + * + * @param mixed $manifest The manifest as it will be written. + * @param string $change What the rewrite adds, for the refusal: "the alias". + * @return \Closure(string, string): mixed + */ + public static function createReadBack(string $projectRoot, string $projectId, mixed $manifest, string $change): \Closure + { + $namesClasses = array_any(is_array($manifest) ? (array) ($manifest['migrations'] ?? []) : [], + static fn(mixed $step): bool => is_array($step) && isset($step['class'])); + + return static function (string $path, string $staged) use ($projectRoot, $projectId, $namesClasses, $change): mixed { + try { + if ($namesClasses) { + return ProjectMigrationProbe::readManifest($projectRoot, $projectId, $staged); + } + $value = PhpDataFile::evaluateIsolated($staged, $projectRoot); + SaveCompatibilityManifest::fromArray($projectId, is_array($value) ? $value : [], self::PATH); + + return $value; + } catch (Throwable $refusal) { + throw new RuntimeException(sprintf('The game would refuse %s: %s; nothing was written.', $change, rtrim($refusal->getMessage(), '.')), previous: $refusal); + } + }; + } + + /** + * @param array $step + * @return array{source: string, value: array}|string + */ + private static function appendStep(string $source, mixed $manifest, array $step): array|string + { + $version = is_array($manifest) ? ($manifest['contentVersion'] ?? null) : null; + $migrations = is_array($manifest) ? ($manifest['migrations'] ?? []) : null; + if (! is_int($version) || $version < 0 || ! is_array($migrations) || ! array_is_list($migrations)) { + return 'its contentVersion or migrations list is not valid, so saves cannot be migrated automatically'; + } + $next = $manifest; + $next['contentVersion'] = $version + 1; + $next['migrations'] = [...$migrations, $step]; + try { + return ['source' => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $manifest, $next)->source, 'value' => $next]; + } catch (SourceUnreadable|SourcePreservationRefusal $refusal) { + return rtrim($refusal->getMessage(), '.'); + } + } +} diff --git a/src/Maps/SaveMapRetirement.php b/src/Maps/SaveMapRetirement.php new file mode 100644 index 00000000..33dc0648 --- /dev/null +++ b/src/Maps/SaveMapRetirement.php @@ -0,0 +1,138 @@ + $entry `map`, `into`, `to` and optional `landings`. + * @throws RuntimeException With the Engine's reason when it is malformed. + */ + public static function readEntry(array $entry): MapRetirement + { + try { + return MapRetirement::createFromArray($entry, 'The retirement'); + } catch (InvalidSaveCompatibilityManifestException $refusal) { + throw new RuntimeException($refusal->getMessage(), previous: $refusal); + } + } + + /** + * @param array $entry The entry as written, read first by {@see readEntry()}. + * @return array{plan: SourceSetPlan, from: int, to: int} + * @throws RuntimeException When the manifest cannot be read or take the step, or the game would refuse it; + * nothing is written. + */ + public static function plan(string $projectRoot, string $projectId, array $entry): array + { + $manifestPath = $projectRoot . '/' . SaveManifestStep::PATH; + if (! is_file($manifestPath)) { + throw new RuntimeException(sprintf('%s is missing, so saves on %s cannot move.', SaveManifestStep::PATH, strval($entry['map'] ?? ''))); + } + $source = (string) file_get_contents($manifestPath); + try { + $manifest = PhpDataFile::evaluateIsolated($manifestPath, $projectRoot); + } catch (Throwable $failure) { + throw new RuntimeException(sprintf('%s could not be read: %s', SaveManifestStep::PATH, $failure->getMessage()), previous: $failure); + } + $next = SaveManifestStep::append($source, $manifest, ['mapRetirements' => [$entry]]); + if (is_string($next)) { + throw new RuntimeException(sprintf('%s cannot take the map retirement: %s.', SaveManifestStep::PATH, $next)); + } + $plan = new SourceSetPlan($projectRoot, [$manifestPath => $source], [$manifestPath => $source], [$manifestPath => $next['source']], + [$manifestPath => $manifest], [$manifestPath => $next['value']], + static fn(): array => [$manifestPath], SaveManifestStep::createReadBack($projectRoot, $projectId, $next['value'], 'the map retirement'), + 'save map retirement', 'Save compatibility manifest'); + $version = (int) $next['value']['contentVersion']; + + return ['plan' => $plan, 'from' => $version - 1, 'to' => $version]; + } + + /** + * Where each landing of the retirement in step `$from` stands once every + * later declared step has run, as a save landing there moves: a later + * shift, removal or relocation on the map it lands on (`into`, or the + * successor the landing names), or a later retirement of that map. A step that names a project class is passed over, as the Editor + * cannot run project code. + * + * @param array $manifest The manifest as evaluated. + * @param int $from The retirement step's `from` version. + * @return list Each landing cell as written on + * its own map, and + * where it stands now: the map by its current id, through declared map aliases. None when the step does not + * retire `$map`, as after an undo. + * @throws RuntimeException When a step is malformed. + */ + public static function followLandings(array $manifest, int $from, string $map): array + { + $aliases = []; + foreach ((array) ($manifest['aliases']['maps'] ?? []) as $alias) { + if (is_array($alias) && is_string($alias['from'] ?? null) && is_string($alias['to'] ?? null)) { + $aliases[trim($alias['from'])] = trim($alias['to']); + } + } + $steps = []; + foreach ((array) ($manifest['migrations'] ?? []) as $index => $step) { + if (is_array($step) && is_int($step['from'] ?? null) && $step['from'] >= $from) { + try { + $steps[$step['from']] = SaveCompatibilityManifest::readDeclaredPositionEdits($step, sprintf('%s migrations[%s]', SaveManifestStep::PATH, strval($index))); + } catch (InvalidSaveCompatibilityManifestException $refusal) { + throw new RuntimeException($refusal->getMessage(), previous: $refusal); + } + } + } + ksort($steps); + $retirement = array_find($steps[$from] ?? [], static fn(object $edit): bool => $edit instanceof MapRetirement && $edit->map === $map); + if ($retirement === null) { + return []; + } + unset($steps[$from]); + + $landings = []; + foreach ($retirement->getLandingPlaces() as ['map' => $landingMap, 'cell' => $landing]) { + $place = ['mapId' => $landingMap, 'position' => new Vector2(...$landing)]; + foreach ($steps as $edits) { + $place = $edits === null ? $place : new DeclaredPositionContentMigration($edits, $aliases)->movePlace($place['mapId'], $place['position']); + } + $current = $place['mapId']; + for ($seen = []; isset($aliases[$current]) && ! isset($seen[$current]); $current = $aliases[$current]) { + $seen[$current] = true; + } + $landings[] = ['map' => $current, 'cell' => [(int) $place['position']->x, (int) $place['position']->y], 'landing' => $landing]; + } + + return $landings; + } +} diff --git a/src/Maps/SaveMigrationRegistration.php b/src/Maps/SaveMigrationRegistration.php new file mode 100644 index 00000000..c23dd3fc --- /dev/null +++ b/src/Maps/SaveMigrationRegistration.php @@ -0,0 +1,70 @@ +getMessage()), previous: $failure); + } + $next = SaveManifestStep::appendClass($source, $manifest, $class); + if (is_string($next)) { + throw new RuntimeException(sprintf('%s cannot take the migration step: %s.', SaveManifestStep::PATH, $next)); + } + $readBack = static function (string $path, string $staged) use ($projectRoot, $projectId): mixed { + try { + return ProjectMigrationProbe::readManifest($projectRoot, $projectId, $staged); + } catch (RuntimeException $refusal) { + throw new RuntimeException(sprintf('The game would refuse the migration step: %s; nothing was written.', $refusal->getMessage()), previous: $refusal); + } + }; + $plan = new SourceSetPlan($projectRoot, [$manifestPath => $source], [$manifestPath => $source], [$manifestPath => $next['source']], + [$manifestPath => $manifest], [$manifestPath => $next['value']], + static fn(): array => [$manifestPath], $readBack, 'save migration registration', 'Save compatibility manifest'); + $version = (int) $next['value']['contentVersion']; + + return ['plan' => $plan, 'from' => $version - 1, 'to' => $version, 'class' => $class]; + } +} diff --git a/src/Maps/SaveRelocationPlan.php b/src/Maps/SaveRelocationPlan.php new file mode 100644 index 00000000..6f57f10e --- /dev/null +++ b/src/Maps/SaveRelocationPlan.php @@ -0,0 +1,94 @@ +, to: array{int, int}}> $relocations + */ + public function __construct( + public readonly string $mapId, + public readonly array $relocations, + public readonly int $contentVersion, + private readonly SourceSetPlan $sources, + ) { + } + + /** What the save also does, in words: for the question and the status line. */ + public string $description { + get => sprintf('Saves standing where %s is now solid or taken by an NPC move to open floor: save content version %d relocates %s.', + $this->mapId, $this->contentVersion, implode('; ', $this->describeMoves())); + } + + /** @return list Each move: x 2, y 5 to x 2, y 6. */ + public function describeMoves(): array + { + $moves = []; + foreach ($this->relocations as $relocation) { + $from = implode(', ', array_map(static fn(array $cell): string => sprintf('x %d, y %d', ...$cell), $relocation['cells'])); + $moves[] = sprintf('%s to x %d, y %d', $from, ...$relocation['to']); + } + + return $moves; + } + + /** Names this exact plan, so the write is the one the author was shown. */ + public function getFingerprint(): string + { + return $this->sources->getFingerprint(); + } + + /** @return list The manifest, which the plan writes. */ + public function getChangedPaths(): array + { + return $this->sources->getChangedPaths(); + } + + /** + * Writes the manifest step and returns the undo step that removes it: the + * map is saved by the caller, and undoing restores the manifest as it was. + */ + public function apply(): Command + { + $sources = $this->sources; + $sources->apply(); + + return new GenericCommand('Add save relocations', static fn() => $sources->apply(), static fn() => $sources->revert()); + } + + /** + * Works out writing the manifest step without writing it, for a change + * set to install with the map whose save needs it. The step that removes + * it again is recorded once it is installed. + * + * @param \Closure(Command): void $record Records the undo step. + */ + public function prepareApply(\Closure $record): PreparedSave + { + $sources = $this->sources; + + return $sources->prepareApply(sprintf('save relocations for %s', $this->mapId))->withAdoption(static fn() => $record( + new GenericCommand('Add save relocations', static fn() => $sources->apply(), static fn() => $sources->revert()), + )); + } + + public function revert(): void + { + $this->sources->revert(); + } +} diff --git a/src/Maps/SaveRelocationPlanner.php b/src/Maps/SaveRelocationPlanner.php new file mode 100644 index 00000000..6686a6c5 --- /dev/null +++ b/src/Maps/SaveRelocationPlanner.php @@ -0,0 +1,288 @@ + $maps + * @throws RuntimeException When the manifest cannot be read or rewritten; nothing is written. + */ + public static function planAll(array $maps, string $projectRoot): ?SaveRelocationPlan + { + return ProjectDirectoryContext::run($projectRoot, static fn(string $root): ?SaveRelocationPlan => self::buildPlan($maps, $root)); + } + + /** @param list $maps */ + private static function buildPlan(array $maps, string $root): ?SaveRelocationPlan + { + $manifestPath = $root . '/' . SaveManifestStep::PATH; + $maps = array_values(array_filter($maps, static fn(ProjectMap $map): bool => $map->isDirty())); + if (! is_file($manifestPath) || $maps === []) { + return null; + } + $reachability = ProjectReachability::analyze($root . '/assets'); + // Where saves land is judged on the maps as the change leaves them: a pocket it walls off is reachable today. + $unsaved = []; + foreach ($maps as $map) { + $unsaved[$map->mapId] = ['data' => $map->getEditableData(), 'layers' => $map->getLayerSet(), 'eventLayer' => $map->getEventLayerCells()]; + } + $changed = ProjectReachability::analyze($root . '/assets', $unsaved); + $relocations = []; + $moving = []; + foreach ($maps as $map) { + $report = $reachability->reports[$map->mapId] ?? null; + $found = $report === null ? [] : self::planMap($map, $root, $report, $changed->reports[$map->mapId] ?? null); + if ($found !== []) { + $relocations = [...$relocations, ...$found]; + $moving[] = $map->mapId; + } + } + if ($relocations === []) { + return null; + } + $label = implode(', ', $moving); + + $source = (string) file_get_contents($manifestPath); + try { + $manifest = PhpDataFile::evaluateIsolated($manifestPath, $root); + } catch (Throwable $failure) { + throw new RuntimeException(sprintf('%s could not be read, so the saves standing where %s is now taken cannot be moved: %s', + SaveManifestStep::PATH, $label, $failure->getMessage()), previous: $failure); + } + $next = SaveManifestStep::append($source, $manifest, ['relocations' => $relocations]); + if (is_string($next)) { + throw new RuntimeException(sprintf('%s cannot take the save relocations %s needs: %s. Add the step %s by hand.', + SaveManifestStep::PATH, $label, $next, var_export(SaveManifestStep::describe($manifest, ['relocations' => $relocations]), true))); + } + + $sources = new SourceSetPlan($root, [$manifestPath => $source], [$manifestPath => $source], [$manifestPath => $next['source']], + [$manifestPath => PhpDataFile::getComparableValue($manifest)], [$manifestPath => PhpDataFile::getComparableValue($next['value'])], + static fn(): array => [$manifestPath], + static fn(string $path, string $staged): mixed => PhpDataFile::getComparableValue(PhpDataFile::evaluateIsolated($staged, $root)), + 'save relocations', 'Save compatibility manifest'); + + return new SaveRelocationPlan($label, $relocations, $next['value']['contentVersion'], $sources); + } + + /** + * One map's relocations: saves may stand where the player reaches today, + * and land where the player reaches once the map is saved. + * + * @return list, to: array{int, int}}> + */ + private static function planMap(ProjectMap $map, string $root, MapReachabilityReport $report, ?MapReachabilityReport $changed): array + { + $grid = $map->getResolvedCollisionMap(); + // A map the player reaches nowhere today, such as one made in this change and not yet joined to the world, + // holds no save a relocation could place. + if (! $report->isAnyReachable(new Rect(0, 0, max(1, $map->getWidth()), max(1, $map->getHeight())))) { + return []; + } + // Landings are judged on the map as the change leaves them: a pocket it walls off is no landing. + $cells = $changed === null ? null : SaveLandingCells::readMap($map, $changed); + $npcs = SaveLandingCells::readNpcCells((array) ($map->getEditableData()['npcs'] ?? [])); + $passable = static fn(int $x, int $y): bool => isset($grid[$y][$x]) && ! isset($npcs["{$x},{$y}"]) + && ! CollisionType::from($grid[$y][$x])->blocksMovement(); + // An NPC already standing there on disk is no change: reachability lets saves stand where a wanderer starts. + $arrived = array_diff_key($npcs, SaveLandingCells::readNpcCells(self::readSavedNpcs($root, $map->mapId))); + // Any cell walkable on disk may hold a save, even one no route reaches today: an older layout may have + // reached it, and a save made there must never load inside what now takes it. + $walkable = self::readSavedWalkableCells($root, $map->mapId); + // An event now covering a cell no event covered on disk, such as a door moved there, would start under a save. + $saved = self::readSavedEventCells($root, $map->mapId); + $covered = []; + foreach (self::readEventAreas($map) as $area) { + foreach ($area->cells as [$x, $y]) { + if (! isset($saved["{$x},{$y}"])) { + $covered["{$x},{$y}"] = true; + } + } + } + $blocked = []; + foreach ($grid as $y => $row) { + foreach (array_keys($row) as $x) { + $taken = CollisionType::from($grid[$y][$x])->blocksMovement() || isset($arrived["{$x},{$y}"]) || isset($covered["{$x},{$y}"]); + // Floor the player reaches today and the change walls off: a save there would load with no way out. + $cutOff = $changed !== null && $report->isReachable($x, $y) && ! $changed->isReachable($x, $y); + if (($report->isReachable($x, $y) || isset($walkable["{$x},{$y}"])) && ($taken || $cutOff)) { + $blocked["{$x},{$y}"] = [$x, $y]; + } + } + } + if ($blocked === []) { + return []; + } + + // A save lands where SaveLandingCells allows, inside only the events its own cell was already in. + $canLandFrom = static fn(int $fromX, int $fromY): \Closure => static fn(int $x, int $y): bool + => $cells?->canLand($x, $y, [$fromX, $fromY]) ?? false; + $groups = []; + foreach ($blocked as [$x, $y]) { + $landing = self::findLanding($x, $y, $passable, $canLandFrom($x, $y), $grid); + if ($landing === null && ! $report->isReachable($x, $y)) { + // A map the player reaches nowhere today holds no save a relocation could place. + continue; + } + $landing ?? throw new RuntimeException(sprintf('Saves standing on x %d, y %d of %s would have no open floor to move to. Open a path to it, or undo the change, then save.', + $x, $y, $map->mapId)); + $groups[implode(',', $landing)]['to'] = $landing; + $groups[implode(',', $landing)]['cells'][] = [$x, $y]; + } + + return array_map(static fn(array $group): array => ['map' => $map->mapId, 'cells' => $group['cells'], 'to' => $group['to']], array_values($groups)); + } + + /** + * The nearest floor a save can land on: first by walking from the cell's + * passable neighbours, so the landing is on the same side of whatever was + * built, else the closest such cell anywhere. + * + * @param callable(int, int): bool $passable + * @param callable(int, int): bool $canLand + * @return array{int, int}|null + */ + private static function findLanding(int $x, int $y, callable $passable, callable $canLand, array $grid): ?array + { + $seen = ["{$x},{$y}" => true]; + $queue = [[$x, $y]]; + while ($queue !== []) { + [$cx, $cy] = array_shift($queue); + foreach ([[0, 1], [1, 0], [-1, 0], [0, -1]] as [$dx, $dy]) { + [$nx, $ny] = [$cx + $dx, $cy + $dy]; + if (isset($seen["{$nx},{$ny}"]) || ! $passable($nx, $ny)) { + continue; + } + if ($canLand($nx, $ny)) { + return [$nx, $ny]; + } + $seen["{$nx},{$ny}"] = true; + $queue[] = [$nx, $ny]; + } + } + $best = null; + foreach ($grid as $cy => $row) { + foreach (array_keys($row) as $cx) { + if ($canLand($cx, $cy) && ($best === null || abs($cx - $x) + abs($cy - $y) < $best[2])) { + $best = [$cx, $cy, abs($cx - $x) + abs($cy - $y)]; + } + } + } + + return $best === null ? null : [$best[0], $best[1]]; + } + + /** @return list Every event's cells as the Engine resolves them, bound events included. */ + private static function readEventAreas(ProjectMap $map): array + { + $events = MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), $map->mapId)->events; + + return array_values(array_map(static fn(array $event): CellArea => CellArea::fromArray($event['area'], 'Event area'), + array_filter($events, static fn(array $event): bool => is_array($event['area'] ?? null)))); + } + + /** + * The cells of the map on disk that a player could stand on, by `x,y`: + * walkable by its saved collision, reachable or not. None when the + * project has no readable collision dictionary. + * + * @return array + */ + private static function readSavedWalkableCells(string $root, string $mapId): array + { + try { + $dictionary = MapSourceReader::loadCollisionDictionary($root . '/assets/Maps/collisions.php'); + } catch (Throwable) { + $dictionary = []; + } + $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($root . '/assets/Maps', $mapId)); + $walkable = []; + $kinds = MapTerrainKinds::loadForMap($root . '/assets', $source['data']); + foreach (MapCollisionResolver::resolveMap($source['layers'], $source['data'], $dictionary, $kinds)->collisionGrid as $y => $row) { + foreach ($row as $x => $value) { + if (! CollisionType::from($value)->blocksMovement()) { + $walkable["{$x},{$y}"] = true; + } + } + } + + return $walkable; + } + + /** @return array The cells the map's events cover on disk, bound events included. */ + private static function readSavedEventCells(string $root, string $mapId): array + { + $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($root . '/assets/Maps', $mapId)); + $cells = []; + foreach ((array) ($source['data']['events'] ?? []) as $event) { + if (is_array($event) && is_array($event['area'] ?? null)) { + foreach (CellArea::fromArray($event['area'], 'Event area')->cells as [$x, $y]) { + $cells["{$x},{$y}"] = true; + } + } + } + + return $cells; + } + + /** @return array The map's `npcs` entries as saved on disk. */ + private static function readSavedNpcs(string $root, string $mapId): array + { + $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($root . '/assets/Maps', $mapId)); + + return (array) ($source['data']['npcs'] ?? []); + } +} diff --git a/src/Maps/TerrainKindConversion.php b/src/Maps/TerrainKindConversion.php new file mode 100644 index 00000000..bbc88444 --- /dev/null +++ b/src/Maps/TerrainKindConversion.php @@ -0,0 +1,316 @@ + true`) and paints every mapped cell with its kind, as + * one source-preserving undo step. Nothing is reinterpreted silently: on a + * map not yet painted in kinds, every cell whose glyph already names a kind + * must be mapped too, even to that same kind. + * + * Each mapped cell is painted with its kind ({@see TerrainKindPainter}), so its + * terrain becomes the kind's alone: tiles the old glyph left on the tile + * layers its gameplay layer owns are cleared, except layers the kind keeps. + * Glyphs nobody maps stay as they are, with the collision the dictionary gives them. + * A kind owns its glyph's atlas role on its layer, so the map's own atlas + * classifications of the glyphs it converts, and of glyphs that name kinds + * there, are removed with it (`atlasBindings`); the others stay. + * + * Events, transfers and NPCs are not touched. Saves standing where the map + * becomes solid are moved through the existing save relocation when the map + * is saved, and validation checks reachability then, as for any map edit. + */ +final class TerrainKindConversion +{ + private function __construct() + { + } + + /** + * What converting the map would do, changing nothing: each mapped glyph's + * cells and kind, the glyphs left as they are, those among them that name + * a kind and so must be mapped before converting (`reread`), and the + * cells whose collision changes. With no mapping it lists what is there. + * + * @param array $mapping Kind ids by old plain glyph. + * @return array{map: string, declares: bool, mappings: list, + * unmapped: list, reread: list, collision: array{changed: int, blocked: int, opened: int, cells: list}, + * atlasBindings: list} + * @throws MapSourceRefusal When the mapping cannot be applied. + */ + public static function preview(ProjectMap $map, array $mapping): array + { + $plan = self::plan($map, $mapping); + + return [ + 'map' => $map->mapId, + 'declares' => ! $plan['declared'], + 'mappings' => $plan['mappings'], + 'unmapped' => $plan['unmapped'], + 'reread' => $plan['reread'], + 'collision' => self::compareCollision($map, $plan), + 'atlasBindings' => self::findReplacedAtlasBindings($map, $plan, $mapping), + ]; + } + + /** + * The map's atlas classifications the kinds take over: on each kind layer, + * those of the glyphs the conversion maps and of glyphs that name a kind. + * + * @param array{terrain: MapTerrainKinds} $plan + * @param array $mapping + * @return list + */ + private static function findReplacedAtlasBindings(ProjectMap $map, array $plan, array $mapping): array + { + try { + $terrain = MapAtlas::readTerrain(MapAtlas::readBlock($map)); + } catch (InvalidArgumentException) { + return []; + } + $replaced = []; + foreach ($plan['terrain']->getLayers() as $layer) { + $symbols = $terrain[$layer]['symbols'] ?? []; + foreach (is_array($symbols) ? $symbols : [] as $glyph => $role) { + $glyph = (string) $glyph; + if (array_key_exists($glyph, $mapping) || $plan['terrain']->getKindsOn($layer, $glyph) !== []) { + $replaced[] = ['layer' => $layer, 'glyph' => $glyph, 'role' => (string) $role]; + } + } + } + + return $replaced; + } + + /** + * Converts the map as one undo step. + * + * @param array $mapping Kind ids by old plain glyph. + * @return array{command: ?Command, cells: int} The applied, unrecorded edit, and the cells painted. + * @throws MapSourceRefusal When the mapping is empty, leaves a glyph that names a kind unmapped, or cannot be applied; nothing changes. + */ + public static function apply(ProjectMap $map, array $mapping, string $label = 'Convert to terrain kinds'): array + { + $plan = self::plan($map, $mapping); + if ($mapping === []) { + throw new MapSourceRefusal('Map at least one old glyph to a terrain kind. Nothing was changed.'); + } + if ($plan['reread'] !== []) { + throw new MapSourceRefusal(sprintf('%s already holds %s, which name terrain kinds; converting would reread them as kinds unseen. ' + . 'Map them too, to the kind they are or to another. Nothing was changed.', $map->mapId, + implode(', ', array_map(static fn(array $glyph): string => sprintf("'%s' on %s (%d cells)", $glyph['glyph'], $glyph['layer'], + $glyph['cells']), $plan['reread'])))); + } + $commands = []; + if (! $plan['declared']) { + $map->setMapDataField([MapTerrainKinds::DATA_KEY], true); + $commands[] = new GenericCommand($label, + static fn() => $map->setMapDataField([MapTerrainKinds::DATA_KEY], true), + static fn() => $map->setMapDataField([MapTerrainKinds::DATA_KEY], null), + ); + } + $replaced = self::findReplacedAtlasBindings($map, $plan, $mapping); + if ($replaced !== []) { + $before = MapAtlas::readBlock($map); + $after = $before; + foreach ($replaced as ['layer' => $layer, 'glyph' => $glyph]) { + $after = MapAtlas::setTerrainSymbol($after, $layer, $glyph, null); + } + $map->setMapDataField([MapAtlas::KEY], $after === [] ? null : $after); + $commands[] = new GenericCommand($label, + static fn() => $map->setMapDataField([MapAtlas::KEY], $after === [] ? null : $after), + static fn() => $map->setMapDataField([MapAtlas::KEY], $before === [] ? null : $before), + ); + } + try { + $painted = 0; + foreach ($plan['cells'] as $kindId => $cells) { + $command = TerrainKindPainter::paintCells($map, $plan['kinds'][$kindId], $cells, $label); + $painted += count($cells); + if ($command !== null) { + $commands[] = $command; + } + } + } catch (MapSourceRefusal $refusal) { + foreach (array_reverse($commands) as $command) { + $command->undo(); + } + throw $refusal; + } + + return ['command' => $commands === [] ? null : new CommandGroup($label, $commands), 'cells' => $painted]; + } + + /** + * @param array $mapping + * @return array{declared: bool, kinds: array, terrain: MapTerrainKinds, cells: array>, + * mappings: list>, unmapped: list>, reread: list} + */ + private static function plan(ProjectMap $map, array $mapping): array + { + if ($map->getGridSourceIssue() !== null) { + throw new MapSourceRefusal(sprintf('%s is read-only: %s', $map->mapId, $map->getGridSourceIssue())); + } + $kinds = TerrainKindPainter::readTilesetKinds($map); + $terrain = new MapTerrainKinds($map->loadTileset()); + try { + $declared = MapTerrainKinds::isDeclared($map->getEditableData()); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + $layerIds = []; + foreach ($map->getLayers() as $layer) { + if ($layer['id'] !== MapLayers::EVENT && ! $layer['decoration']) { + $layerIds[$layer['name']] = $layer['id']; + } + } + $targets = []; + foreach ($mapping as $glyph => $kindId) { + $symbols = TerminalText::visibleSymbols((string) $glyph); + if (count($symbols) !== 1 || TerminalText::stripAnsi($symbols[0]) !== (string) $glyph) { + throw new MapSourceRefusal(sprintf("'%s' is not one plain glyph; map each old glyph on its own. Nothing was changed.", $glyph)); + } + $kind = $kinds[$kindId] ?? throw new MapSourceRefusal(sprintf('%s is not one of the tileset\'s terrain kinds (%s). Nothing was changed.', + $kindId, implode(', ', array_keys($kinds)))); + if (! isset($layerIds[$kind->layer])) { + throw new MapSourceRefusal(sprintf('%s goes on the %s layer, which this map does not have. Nothing was changed.', $kind->name, $kind->layer)); + } + $targets[(string) $glyph] = $kind; + } + $cells = $counts = $unmapped = $unmappedKinds = []; + foreach ($terrain->getLayers() as $layerName) { + if (! isset($layerIds[$layerName])) { + continue; + } + $layerId = $layerIds[$layerName]; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if (! $map->hasLayerCell($layerId, $x, $y)) { + continue; + } + $glyph = $map->getLayerSymbol($layerId, $x, $y); + $kind = $targets[$glyph] ?? null; + if ($kind !== null && $kind->layer === $layerName) { + $cells[$kind->id][] = ['x' => $x, 'y' => $y]; + $counts[$glyph] = ($counts[$glyph] ?? 0) + 1; + } else { + $unmapped[$layerName][$glyph] = ($unmapped[$layerName][$glyph] ?? 0) + 1; + if (! $declared && $terrain->getKindsOn($layerName, $glyph) !== []) { + $unmappedKinds[$glyph] = $layerName; + } + } + } + } + } + $reread = []; + foreach ($unmappedKinds as $glyph => $layerName) { + $reread[] = ['glyph' => (string) $glyph, 'layer' => $layerName, 'cells' => $unmapped[$layerName][$glyph]]; + } + $mappings = []; + foreach ($targets as $glyph => $kind) { + $mappings[] = ['glyph' => (string) $glyph, 'kind' => $kind->id, 'name' => $kind->name, 'layer' => $kind->layer, + 'cells' => $counts[$glyph] ?? 0, 'awaitingArt' => $kind->awaitingArt]; + } + $left = []; + foreach ($unmapped as $layerName => $glyphs) { + foreach ($glyphs as $glyph => $count) { + $left[] = ['glyph' => (string) $glyph, 'layer' => (string) $layerName, 'cells' => $count]; + } + } + + return ['declared' => $declared, 'kinds' => $kinds, 'terrain' => $terrain, 'cells' => $cells, 'mappings' => $mappings, 'unmapped' => $left, + 'reread' => $reread]; + } + + /** + * The cells whose collision the conversion changes: on a map without + * declared occupancy, its glyphs read with the kinds after conversion; on + * one with it, the kinds' collision where their layer is on top. + * + * @param array{declared: bool, kinds: array, terrain: MapTerrainKinds, cells: array>} $plan + * @return array{changed: int, blocked: int, opened: int, cells: list} + */ + private static function compareCollision(ProjectMap $map, array $plan): array + { + try { + $before = $map->getResolvedCollisionMap(); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal('The map\'s collision cannot be read: ' . $error->getMessage(), previous: $error); + } + $painted = []; + foreach ($plan['cells'] as $kindId => $cells) { + foreach ($cells as ['x' => $x, 'y' => $y]) { + $painted[$y][$x] = $plan['kinds'][$kindId]; + } + } + $set = $map->getLayerSet(); + if ($map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + $after = $before; + foreach ($painted as $y => $row) { + foreach ($row as $x => $kind) { + if (TerrainKindPainter::isTopLayer($set, $kind->layer, $x, $y)) { + $after[$y][$x] = $kind->collision->value; + } + } + } + } else { + $layers = array_map(static function (MapLayer $layer) use ($painted): MapLayer { + $rows = []; + for ($y = 0; $y < $layer->height; $y++) { + $cells = []; + foreach ($layer->getRowCells($y) as $x => $cell) { + $kind = $painted[$y][$x] ?? null; + $cells[] = $kind !== null && $kind->layer === $layer->name ? $kind->piece->grid[0][0] : $cell; + } + $rows[] = implode('', $cells); + } + return new MapLayer($layer->name, $layer->order, $layer->decoration, $layer->path, implode("\n", $rows)); + }, $set->layers); + try { + $after = $map->resolveTrialCollision(new MapLayerSet($layers, $set->legacy), + [...$map->getEditableData(), MapTerrainKinds::DATA_KEY => true], $plan['terrain']); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal('The map\'s collision after conversion cannot be resolved: ' . $error->getMessage() + . ' Nothing was changed.', previous: $error); + } + } + $changed = []; + $blocked = $opened = 0; + foreach ($before as $y => $row) { + foreach ($row as $x => $value) { + $next = $after[$y][$x] ?? $value; + if ($next === $value) { + continue; + } + $changed[] = [$x, $y]; + $wasBlocking = CollisionType::from($value)->blocksMovement(); + $isBlocking = CollisionType::from($next)->blocksMovement(); + $blocked += ! $wasBlocking && $isBlocking ? 1 : 0; + $opened += $wasBlocking && ! $isBlocking ? 1 : 0; + } + } + + return ['changed' => count($changed), 'blocked' => $blocked, 'opened' => $opened, 'cells' => $changed]; + } +} diff --git a/src/Maps/TileLayerSettings.php b/src/Maps/TileLayerSettings.php new file mode 100644 index 00000000..93bc1fe3 --- /dev/null +++ b/src/Maps/TileLayerSettings.php @@ -0,0 +1,160 @@ + ['floor' => ['offset' => + * [0, -0.5], 'movesWith' => 'buildings']]`, {@see MapGraphics::SETTINGS_KEY}) + * naming the layers the map has while its tile and gameplay layers are + * renamed and removed, and sets one layer's settings. + * + * Each change returns the settings unchanged when it touches nothing, so an + * authored value is never rewritten for nothing. A layer whose settings + * become empty loses its entry, as the Engine refuses an empty one, and + * settings without entries become null, which removes the key. Settings that + * are not a map of layer names are returned as they are: validation reports + * them ({@see MapGraphics::readLayerOffsets()}), and only the Engine's readers + * decide what is valid. + */ +final class TileLayerSettings +{ + private const string OFFSET_KEY = 'offset'; + + private function __construct() + { + } + + /** + * Moves a tile layer's settings to its new name, last: the map's data + * source keeps the keys it keeps in order and appends new ones, and the + * Engine reads the settings by name, never by position. + */ + public static function renameLayer(mixed $settings, string $name, string $newName): mixed + { + if (! is_array($settings) || ! array_key_exists($name, $settings) || $name === $newName) { + return $settings; + } + $layer = $settings[$name]; + unset($settings[$name]); + $settings[$newName] = $layer; + + return $settings; + } + + /** Drops a removed tile layer's settings. */ + public static function removeLayer(mixed $settings, string $name): mixed + { + if (! is_array($settings) || ! array_key_exists($name, $settings)) { + return $settings; + } + unset($settings[$name]); + + return self::normalize($settings); + } + + /** Makes the tile layers that move with a renamed gameplay layer name it by its new name. */ + public static function renameOwner(mixed $settings, string $owner, string $newOwner): mixed + { + return self::updateMovesWith($settings, $owner, $newOwner); + } + + /** Makes the tile layers that moved with a gameplay layer that is gone, or now decoration, move with none. */ + public static function removeOwner(mixed $settings, string $owner): mixed + { + return self::updateMovesWith($settings, $owner, null); + } + + /** + * Sets one tile layer's offset across and down, in field cells, and the + * gameplay layer it moves with, or none. No offset (0, 0) and no layer + * leave their keys out. An offset equal to the authored one keeps the + * authored numbers. + * + * @param array $offset Across and down. + */ + public static function setLayer(mixed $settings, string $name, array $offset, ?string $movesWith): mixed + { + if ($settings !== null && ! is_array($settings)) { + return $settings; + } + $layer = $settings[$name] ?? []; + if (! is_array($layer)) { + $layer = []; + } + $zero = array_is_list($offset) && count($offset) === 2 + && array_all($offset, static fn(mixed $value): bool => (is_int($value) || is_float($value)) && (float) $value === 0.0); + if ($zero) { + unset($layer[self::OFFSET_KEY]); + } elseif (! self::isSameOffset($layer[self::OFFSET_KEY] ?? null, $offset)) { + $layer[self::OFFSET_KEY] = array_map(static fn(mixed $value): mixed => + (is_int($value) || is_float($value)) && (float) $value === 0.0 ? 0 : $value, $offset); + } + if ($movesWith === null) { + unset($layer[MapGraphics::MOVES_WITH_KEY]); + } else { + $layer[MapGraphics::MOVES_WITH_KEY] = $movesWith; + } + $next = $settings ?? []; + if ($layer === []) { + unset($next[$name]); + } else { + $next[$name] = $layer; + } + if ($next === ($settings ?? [])) { + return $settings; + } + + return self::normalize($next); + } + + private static function updateMovesWith(mixed $settings, string $owner, ?string $newOwner): mixed + { + if (! is_array($settings)) { + return $settings; + } + $next = $settings; + foreach ($settings as $name => $layer) { + if (! is_array($layer) || ($layer[MapGraphics::MOVES_WITH_KEY] ?? null) !== $owner) { + continue; + } + if ($newOwner === null) { + unset($layer[MapGraphics::MOVES_WITH_KEY]); + } else { + $layer[MapGraphics::MOVES_WITH_KEY] = $newOwner; + } + if ($layer === []) { + unset($next[$name]); + } else { + $next[$name] = $layer; + } + } + + return $next === $settings ? $settings : self::normalize($next); + } + + /** Whether two offsets are the same numbers, so the authored one can stay as written. */ + private static function isSameOffset(mixed $authored, array $offset): bool + { + if (! is_array($authored) || ! array_is_list($authored) || count($authored) !== count($offset)) { + return false; + } + foreach ($authored as $index => $value) { + $other = $offset[$index] ?? null; + if (! (is_int($value) || is_float($value)) || ! (is_int($other) || is_float($other)) || (float) $value !== (float) $other) { + return false; + } + } + + return true; + } + + /** @param array $settings */ + private static function normalize(array $settings): ?array + { + return $settings === [] ? null : $settings; + } +} diff --git a/src/Maps/TileLayerSource.php b/src/Maps/TileLayerSource.php new file mode 100644 index 00000000..aa99ecc6 --- /dev/null +++ b/src/Maps/TileLayerSource.php @@ -0,0 +1,316 @@ + + */ + public static function findPaths(string $mapDirectory): array + { + $directory = $mapDirectory . '/' . MapGraphics::DIRECTORY; + $paths = is_dir($directory) ? (glob($directory . '/*.tiles.php') ?: []) : []; + sort($paths); + + return $paths; + } + + /** + * Reads a tile layer as the Engine does. + * + * @throws InvalidArgumentException When the source is not a literal nowdoc of tile identities. + */ + public static function readLayer(string $source, string $displayPath): MapTileLayer + { + $name = preg_match(MapGraphics::FILENAME_PATTERN, basename($displayPath), $matches) === 1 ? $matches['name'] : basename($displayPath); + $order = (int) ($matches['order'] ?? 0); + + return new MapTileLayer($name, $order, $displayPath, MapGridSource::parseSource($source, $displayPath)); + } + + /** + * Resizes a tile layer with its map: cells and rows beyond the new size + * are cropped and new cells are empty (`0`); kept cells keep their entry. + * A layer that reads back as its baseline keeps the baseline's bytes; + * otherwise it is rewritten as a canonical literal nowdoc, keeping its + * leading comment and marker. + * + * @param MapLayerSet $layers The map's terminal layers before the resize. + * @throws MapSourceRefusal When the layer cannot be read or does not match the map; nothing is changed. + */ + public static function resize(string $source, string $displayPath, MapLayerSet $layers, int $width, int $height, ?string $baseline = null): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, 'resizing the map'); + + $empty = (string)TileId::EMPTY; + $entries = $layer->getEntries(); + $rows = array_slice($entries, 0, $height); + foreach ($rows as &$row) { + $row = array_pad(array_slice($row, 0, $width), $width, $empty); + } + unset($row); + $rows = array_pad($rows, $height, array_fill(0, $width, $empty)); + + return self::rewrite($rows, $entries, $source, $displayPath, $baseline); + } + + /** + * Inserts empty (`0`) rows before row `$at` (axis `y`) or empty cells + * before column `$at` of every row that reaches it (axis `x`), as + * {@see EditableGrid::insertLines()} inserts them into the terminal + * layers. The result is rewritten as {@see resize()} rewrites a layer. + * + * @param MapLayerSet $layers The map's terminal layers before the insertion. + * @throws MapSourceRefusal When the layer cannot be read or does not match the map; nothing is changed. + */ + public static function insertLines(string $source, string $displayPath, MapLayerSet $layers, string $axis, int $at, int $count, ?string $baseline = null): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, 'inserting rows or columns'); + + $empty = (string)TileId::EMPTY; + $entries = $layer->getEntries(); + $rows = $entries; + if ($axis === 'y') { + $beside = $rows[$at] ?? $rows[$at - 1] ?? []; + array_splice($rows, $at, 0, array_fill(0, $count, array_fill(0, count($beside), $empty))); + } else { + foreach ($rows as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, $count, $empty)); + } + } + unset($row); + } + + return self::rewrite($rows, $entries, $source, $displayPath, $baseline); + } + + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`), or those cells + * from every row that reaches them (axis `x`), as + * {@see EditableGrid::removeLines()} removes them from the terminal + * layers. The result is rewritten as {@see resize()} rewrites a layer. + * + * @param MapLayerSet $layers The map's terminal layers before the removal. + * @throws MapSourceRefusal When the layer cannot be read or does not match the map; nothing is changed. + */ + public static function removeLines(string $source, string $displayPath, MapLayerSet $layers, string $axis, int $at, int $count, ?string $baseline = null): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, 'removing rows or columns'); + + $entries = $layer->getEntries(); + $rows = $entries; + if ($axis === 'y') { + array_splice($rows, $at, $count); + } else { + foreach ($rows as &$row) { + if (count($row) > $at) { + array_splice($row, $at, $count); + } + } + unset($row); + } + + return self::rewrite($rows, $entries, $source, $displayPath, $baseline); + } + + /** + * Writes a piece's tile entries into a tile layer with its top-left cell + * at (x, y). A `0` entry leaves its cell as it was; every other entry + * replaces the cell's entry with that whole tile. The result is + * rewritten as {@see resize()} rewrites a layer. + * + * @param list> $rows Tile entries by row, as a piece holds them. + * @param MapLayerSet $layers The map's terminal layers. + * @throws MapSourceRefusal When the layer cannot be read, does not match the map, or the entries fall outside it; nothing is changed. + */ + public static function writeEntries(string $source, string $displayPath, MapLayerSet $layers, int $x, int $y, array $rows, ?string $baseline = null): string + { + $cells = []; + foreach ($rows as $row => $entries) { + foreach ($entries as $column => $entry) { + $cells[] = ['x' => $x + $column, 'y' => $y + $row, 'entry' => $entry]; + } + } + + return self::writeCellEntries($source, $displayPath, $layers, $cells, 'stamping a piece', $baseline, keepsEmptyCells: true); + } + + /** + * Sets each listed cell's entry, `0` included, so a connected piece can + * give every cell it draws or reshapes its shape's tile and clear the + * cells it erases. The result is rewritten as {@see resize()} rewrites a + * layer. + * + * @param list $cells The entry for each cell. + * @param MapLayerSet $layers The map's terminal layers. + * @throws MapSourceRefusal When the layer cannot be read, does not match the map, or a cell falls outside it; nothing is changed. + */ + public static function setCellEntries(string $source, string $displayPath, MapLayerSet $layers, array $cells, ?string $baseline = null): string + { + return self::writeCellEntries($source, $displayPath, $layers, $cells, 'drawing a piece', $baseline, keepsEmptyCells: false); + } + + /** + * Writes entries into a layer's cells, refusing them all when one cell + * is outside the layer or one entry is not a tile identity the Engine + * reads. + * + * @param list $cells The entry for each cell. + * @param bool $keepsEmptyCells Whether a `0` entry leaves its cell as it was instead of emptying it. + */ + private static function writeCellEntries(string $source, string $displayPath, MapLayerSet $layers, array $cells, string $action, ?string $baseline, bool $keepsEmptyCells): string + { + $layer = self::readMatchingLayer($source, $displayPath, $layers, $action); + + $entries = $layer->getEntries(); + $written = $entries; + foreach ($cells as $cell) { + if (! isset($entries[$cell['y']][$cell['x']])) { + throw new MapSourceRefusal(sprintf('%s has no cell at (%d, %d); nothing was changed.', $displayPath, $cell['x'], $cell['y'])); + } + if (preg_match('/\A\d+\z/', $cell['entry']) !== 1 + || ((int) $cell['entry'] !== TileId::EMPTY && ! TileId::isValid((int) $cell['entry']))) { + throw new MapSourceRefusal(sprintf("'%s' is not an RPG Maker tile identity, so %s cannot take it at (%d, %d); nothing was changed.", + $cell['entry'], $displayPath, $cell['x'], $cell['y'])); + } + if (! $keepsEmptyCells || $cell['entry'] !== (string)TileId::EMPTY) { + $written[$cell['y']][$cell['x']] = $cell['entry']; + } + } + + return self::rewrite($written, $entries, $source, $displayPath, $baseline); + } + + /** + * Reads a tile layer's entries by row, as a layer that must match the map. + * + * @param MapLayerSet $layers The map's terminal layers. + * @return list> + * @throws MapSourceRefusal When it cannot be read or does not match the map. + */ + public static function readEntries(string $source, string $displayPath, MapLayerSet $layers, string $action): array + { + return self::readMatchingLayer($source, $displayPath, $layers, $action)->getEntries(); + } + + /** + * Reads a tile layer that must match the map before it changes. + * + * @throws MapSourceRefusal When it cannot be read or does not match the map. + */ + private static function readMatchingLayer(string $source, string $displayPath, MapLayerSet $layers, string $action): MapTileLayer + { + try { + $layer = self::readLayer($source, $displayPath); + $layer->assertMatches($layers); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(sprintf( + '%s Repair it before %s; nothing was changed.', + rtrim($error->getMessage(), '.') . '.', + $action, + ), previous: $error); + } + + return $layer; + } + + /** + * A new tile layer for the map: every cell empty (`0`), each row as wide + * as the map's row. + * + * @param MapLayerSet $layers The map's terminal layers. + */ + public static function createEmpty(MapLayerSet $layers): string + { + $body = implode("\n", array_map( + static fn(array $row): string => implode(' ', array_fill(0, count($row), (string)TileId::EMPTY)), + $layers->getComposedGrid(), + )); + + return MapGridSource::buildSource($body, self::PREFERRED_MARKER); + } + + /** + * The source for a layer's new entries. Unchanged entries keep the + * source's bytes and entries that read back as the baseline keep the + * baseline's; otherwise the layer is rewritten as a canonical literal + * nowdoc, keeping its leading comment and marker. + * + * @param list> $rows The entries the layer should hold. + * @param list> $entries The entries the source holds. + */ + private static function rewrite(array $rows, array $entries, string $source, string $displayPath, ?string $baseline): string + { + if ($rows === $entries) { + return $source; + } + + if ($baseline !== null) { + try { + if (self::readLayer($baseline, $displayPath)->getEntries() === $rows) { + return $baseline; + } + } catch (InvalidArgumentException) { + // A baseline the Engine cannot read is never restored by a resize. + } + } + + $body = implode("\n", array_map(static fn(array $row): string => implode(' ', $row), $rows)); + + return MapGridSource::buildSource($body, self::getMarker($source), self::getLeadingComment($source)); + } + + /** The nowdoc marker the author chose, or the preferred one. */ + private static function getMarker(string $source): string + { + return preg_match("/<<<[ \\t]*'([A-Za-z_][A-Za-z0-9_]*)'/", $source, $matches) === 1 + ? $matches[1] + : self::PREFERRED_MARKER; + } + + /** Comments between the open tag and `return`, so a rewrite keeps them. */ + private static function getLeadingComment(string $source): string + { + $comment = ''; + foreach (token_get_all($source) as $index => $token) { + if ($index === 0) { + continue; + } + if (! is_array($token) || ! in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + break; + } + $comment .= $token[1]; + } + $comment = trim($comment); + + return $comment === '' ? '' : $comment . "\n"; + } +} diff --git a/src/Maps/TilePalette.php b/src/Maps/TilePalette.php new file mode 100644 index 00000000..c4ac0cfc --- /dev/null +++ b/src/Maps/TilePalette.php @@ -0,0 +1,124 @@ +>}> Tabs in RPG Maker order, each a grid of tile identities. + */ + public static function getTabs(Tileset $tileset): array + { + $tabs = []; + foreach (TilesetSheet::cases() as $sheet) { + if (! isset($tileset->sheets[$sheet->value])) { + continue; + } + $tab = str_starts_with($sheet->value, 'A') ? 'A' : $sheet->value; + foreach (array_chunk(self::getSheetIds($sheet), self::COLUMNS) as $row) { + $tabs[$tab][] = $row; + } + } + $ordered = []; + foreach ($tabs as $name => $ids) { + $ordered[] = ['name' => (string) $name, 'ids' => $ids]; + } + + return $ordered; + } + + /** + * A tab drawn as the game draws tiles: a world of the tab's grid, one + * tile per cell, with blank glyphs. + * + * @param list> $ids + */ + public static function buildWorld(Tileset $tileset, array $ids, string $assetRoot, string $id): PresentationWorld + { + $columns = max([self::COLUMNS, ...array_map(count(...), $ids)]); + $rows = array_map(static fn(array $row): array => array_pad($row, $columns, TileId::EMPTY), $ids); + $layers = new MapLayerSet([new MapLayer(self::LAYER, 1, false, self::LAYER, + implode("\n", array_fill(0, count($rows), str_repeat(' ', $columns))))]); + $tiles = new MapTileLayer(self::LAYER, 1, self::LAYER, + implode("\n", array_map(static fn(array $row): string => implode(' ', $row), $rows))); + + return PresentationWorld::getFromLayers($layers, $id, new MapGraphics($tileset, [$tiles]), $assetRoot); + } + + /** + * A piece drawn as the game draws its tiles, for a picker: its tile + * layers in authored order over a blank grid of the piece's size, or a + * connected piece's shapes laid out as a small room, as its glyph picture + * is ({@see \Ichiloto\Editor\Canvas\PiecePlacer::buildPicture()}). + * Null when the piece draws no tiles. + */ + public static function buildPieceWorld(Tileset $tileset, TilesetPiece $piece, string $assetRoot, string $id): ?PresentationWorld + { + $grids = []; + if ($piece->connects === null) { + foreach ($piece->tiles as $name => $rows) { + $grids[$name] = array_map(static fn(array $row): array => array_map(intval(...), $row), $rows); + } + } else { + foreach ($piece->shapeTiles as $name => $shapes) { + [$corner, $across, $down] = array_map(static fn(string $shape): int => (int) ($shapes[$shape] ?? TileId::EMPTY), ['corner', 'horizontal', 'vertical']); + $grids[$name] = [[$corner, $across, $corner], [$down, TileId::EMPTY, $down], [$corner, $across, $corner]]; + } + } + if ($grids === []) { + return null; + } + $height = max(array_map(count(...), $grids)); + $width = max(array_map(static fn(array $rows): int => max(array_map(count(...), $rows) ?: [0]), $grids)); + $glyphs = new MapLayerSet([new MapLayer(self::LAYER, 1, false, self::LAYER, implode("\n", array_fill(0, $height, str_repeat(' ', $width))))]); + $layers = []; + $order = 1; + foreach ($grids as $name => $rows) { + $padded = array_map(static fn(array $row): string => implode(' ', array_pad($row, $width, TileId::EMPTY)), + array_pad($rows, $height, [])); + $layers[] = new MapTileLayer((string) $name, $order++, (string) $name, implode("\n", $padded)); + } + + return PresentationWorld::getFromLayers($glyphs, $id, new MapGraphics($tileset, $layers), $assetRoot); + } + + /** @return list */ + private static function getSheetIds(TilesetSheet $sheet): array + { + [$first, $count, $step] = match ($sheet) { + TilesetSheet::A1 => [TileId::A1, 16, TileId::SHAPES], + TilesetSheet::A2 => [TileId::A2, 32, TileId::SHAPES], + TilesetSheet::A3 => [TileId::A3, 32, TileId::SHAPES], + TilesetSheet::A4 => [TileId::A4, 48, TileId::SHAPES], + TilesetSheet::A5 => [TileId::A5, 128, 1], + TilesetSheet::B => [TileId::B, 256, 1], + TilesetSheet::C => [TileId::C, 256, 1], + TilesetSheet::D => [TileId::D, 256, 1], + TilesetSheet::E => [TileId::E, 256, 1], + }; + + return array_map(static fn(int $index): int => $first + $index * $step, range(0, $count - 1)); + } +} diff --git a/src/Maps/TilesetAuthoring.php b/src/Maps/TilesetAuthoring.php new file mode 100644 index 00000000..f313a3ee --- /dev/null +++ b/src/Maps/TilesetAuthoring.php @@ -0,0 +1,206 @@ +getUsableSheets($assetRoot)['sheets'] ?? []; + $issues = []; + foreach (array_diff_key($tileset->sheets, $usable) as $name => $asset) { + $issues[$name] = sprintf('Sheet %s (%s) cannot be drawn. Check that its PNG is readable, fits the sheet layout and shares the tileset tile size.', $name, $asset); + } + + return $issues; + } + + /** @param array $data @param list> $rows */ + public static function describePieces(array $data, array $rows): array + { + $keys = []; + foreach ($rows as $row) { + if (isset($row['key']['field']) && ($row['kind'] ?? 'info') !== 'info') { + $keys[$row['key']['field']] = $row['key']; + } + } + $pieces = []; + foreach (array_keys(is_array($data['pieces'] ?? null) ? $data['pieces'] : []) as $index => $id) { + $dataPiece = $data['pieces'][$id]; + if (! is_array($dataPiece)) { + continue; + } + $prefix = 'piece' . $index; + $connected = ($dataPiece['connects'] ?? null) === TilesetPiece::LINES; + $issue = null; + $blueprint = null; + try { + // The glyph footprint remains author-owned even while tile rows are being repaired. + $glyphData = $dataPiece; + unset($glyphData['tiles'], $glyphData['effect'], $glyphData['occupancy'], $glyphData['doorway']); + $blueprint = TilesetPiece::fromArray((string) $id, $glyphData, 'Tileset authoring'); + } catch (InvalidArgumentException $error) { + $issue = $error->getMessage(); + } + $occupancy = null; + $occupancyIssue = null; + $occupancyDeclared = array_key_exists('occupancy', $dataPiece); + if ($occupancyDeclared) { + try { + $occupancy = PhysicalFootprintCodec::exportRows($dataPiece['occupancy']); + } catch (InvalidArgumentException $error) { + $occupancyIssue = $error->getMessage(); + } + try { + TilesetPiece::fromArray((string) $id, $glyphData + ['occupancy' => $dataPiece['occupancy']], 'Tileset authoring'); + } catch (InvalidArgumentException $error) { + $occupancyIssue = $error->getMessage(); + } + } + $layers = []; + foreach (array_keys(is_array($dataPiece['tiles'] ?? null) ? $dataPiece['tiles'] : []) as $layerIndex => $name) { + $field = $prefix . 'Tiles' . $layerIndex . ($connected ? 'Tile' : 'Rows'); + $entries = $dataPiece['tiles'][$name]; + $shapes = $connected ? (is_string($entries) ? array_fill_keys(TilesetPiece::LINE_SHAPES, $entries) : $entries) : []; + $layers[] = ['name' => (string) $name, 'key' => $keys[$field] ?? null, + 'shapes' => (object) (is_array($shapes) ? $shapes : [])]; + } + $doorway = null; + $doorwayIssue = null; + if (! $connected && array_key_exists('doorway', $dataPiece) && $dataPiece['doorway'] !== []) { + try { + $doorway = \Ichiloto\Engine\Field\Doorway::exportCells(\Ichiloto\Engine\Field\Doorway::readCells($dataPiece['doorway'], 'Painted doors')); + } catch (InvalidArgumentException $error) { + $doorwayIssue = $error->getMessage(); + } + } + $pieces[] = ['id' => (string) $id, 'name' => (string) ($dataPiece['name'] ?? $id), + // Each glyph cell as the terminal shows it, for authoring a stamped piece cell by cell over its art. + 'cells' => $blueprint === null || $connected ? null : \Ichiloto\Editor\Canvas\PiecePlacer::buildPicture($blueprint), + 'doorway' => $doorway ?? [], 'doorwayIssue' => $doorwayIssue, + 'layer' => (string) ($dataPiece['layer'] ?? ''), 'connected' => $connected, + 'width' => $blueprint?->width, 'height' => $blueprint?->height, + 'key' => $keys[$prefix . 'Name'] ?? null, 'layers' => $layers, 'issue' => $issue, + 'occupancy' => $occupancy, 'occupancyKey' => $keys[$prefix . 'Occupancy'] ?? null, + 'occupancyDeclared' => $occupancyDeclared, 'occupancyIssue' => $occupancyIssue]; + } + + return $pieces; + } + + /** + * The gameplay and tile layer names a tileset's pieces and the maps drawn + * with it use, each list sorted, for naming a new piece's layers. + * + * @param list<\Ichiloto\Editor\ProjectMap> $maps + * @return array{glyph: list, tiles: list} + */ + public static function listLayerNames(Tileset $tileset, array $maps): array + { + $glyph = $tiles = []; + foreach ($tileset->pieces as $piece) { + $glyph[$piece->layer] = true; + foreach ($tileset->getPieceTileLayers($piece) as $name) { + $tiles[$name] = true; + } + } + foreach ($maps as $map) { + foreach ($map->getLayers() as $layer) { + if ($layer['id'] !== MapLayers::EVENT && ! $layer['decoration']) { + $glyph[$layer['name']] = true; + } + } + try { + foreach ($map->getTileLayerNames() as $name) { + $tiles[$name] = true; + } + } catch (\Throwable) { + // A map whose tile layers cannot be read names none; validation reports it. + } + } + [$glyph, $tiles] = [array_map(strval(...), array_keys($glyph)), array_map(strval(...), array_keys($tiles))]; + sort($glyph, SORT_STRING); + sort($tiles, SORT_STRING); + + return ['glyph' => $glyph, 'tiles' => $tiles]; + } + + /** + * The record fields that give tiles or whole sheets a ground appearance + * ({@see TilesetAppearance}), or take theirs away so they read by their + * sheet again: each class's list with the selection moved into the class + * chosen, and out of every other. An autotile is named by its kind. + * Only the fields that change are returned, as the record row values the + * Tile Appearance rows take. + * + * @param array $data The tileset record as it stands. + * @param list $selectors Tile identities and sheet names. + * @return array Comma-separated values by record field id. + * @throws InvalidArgumentException When a selector is neither a tile nor a sheet. + */ + public static function planAppearance(array $data, array $selectors, ?TileAppearance $appearance): array + { + $chosen = []; + foreach ($selectors as $selector) { + if (is_string($selector) && TilesetSheet::tryFrom($selector) !== null) { + $chosen[$selector] = $selector; + } elseif (is_int($selector) && $selector !== TileId::EMPTY && TileId::isValid($selector)) { + $chosen[TileId::getFlagId($selector)] = TileId::getFlagId($selector); + } else { + throw new InvalidArgumentException(sprintf('%s is neither a tile nor an RPG Maker sheet.', var_export($selector, true))); + } + } + $current = is_array($data[TilesetAppearance::DATA_KEY] ?? null) ? $data[TilesetAppearance::DATA_KEY] : []; + $fields = []; + foreach (TileAppearance::cases() as $class) { + $listed = is_array($current[$class->value] ?? null) ? array_values($current[$class->value]) : []; + $kept = array_values(array_filter($listed, static fn(mixed $entry): bool + => ! isset($chosen[is_int($entry) && TileId::isValid($entry) ? TileId::getFlagId($entry) : (string) $entry]))); + $next = $class === $appearance ? [...$kept, ...array_values($chosen)] : $kept; + if ($next !== $listed) { + $fields[TilesetAppearance::DATA_KEY . '.' . $class->value] = implode(', ', array_map(strval(...), $next)); + } + } + + return $fields; + } + + /** + * What each tile shows at ground level, for a view of the sheets: its + * class, or null when unknown, where that comes from (authored for the + * tile, authored for its sheet, or the sheet kind's default) and its sheet. + * + * @param list $tiles + * @return array + */ + public static function describeAppearance(Tileset $tileset, array $tiles): array + { + $described = []; + foreach ($tiles as $tile) { + if ($tile === TileId::EMPTY || isset($described[$tile])) { + continue; + } + $sheet = TileId::getSheet($tile)?->value; + $described[$tile] = ['class' => $tileset->classifyAppearance($tile)?->value, 'sheet' => (string) $sheet, + 'source' => match (true) { + isset($tileset->appearance->tiles[TileId::getFlagId($tile)]) => 'tile', + ! $tileset->isAbove($tile) && ! $tileset->isTable($tile) && isset($tileset->appearance->sheets[$sheet ?? '']) => 'sheet', + default => 'default', + }]; + } + + return $described; + } +} diff --git a/src/Maps/TilesetPieceAuthoring.php b/src/Maps/TilesetPieceAuthoring.php new file mode 100644 index 00000000..ac8b38d9 --- /dev/null +++ b/src/Maps/TilesetPieceAuthoring.php @@ -0,0 +1,114 @@ + $taken The tileset's piece ids. + * @throws InvalidArgumentException When the name has no letters or digits. + */ + public static function createPieceId(string $name, array $taken): string + { + $base = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower(trim($name))), '-'); + if ($base === '') { + throw new InvalidArgumentException('Name the piece with at least one letter or digit.'); + } + $id = $base; + for ($number = 2; in_array($id, $taken, true); $number++) { + $id = "{$base}-{$number}"; + } + + return $id; + } + + /** + * Blank glyph rows over a footprint: a space in every cell, which leaves + * the map's cell as it is until the author gives the cell a glyph. + * + * @return list + */ + public static function createBlankRows(int $width, int $height): array + { + if ($width < 1 || $height < 1) { + throw new InvalidArgumentException('A piece covers at least one cell.'); + } + + return array_fill(0, $height, str_repeat(' ', $width)); + } + + /** + * The piece's glyph rows with one cell's glyph replaced, every other cell + * keeping its authored source. The colour follows the brush contract + * ({@see CanvasEditor::resolvePaintStyle()}): null keeps the cell's own + * style, an empty string paints it plain, any other value colours it. A + * space clears the cell, so stamping leaves the map's cell as it is. + * + * @param list $rows The Glyph Rows as authored. + * @return list + * @throws InvalidArgumentException When the cell is outside the rows or the glyph is not one terminal cell. + */ + public static function rewriteGlyph(array $rows, int $x, int $y, string $symbol, ?string $color): array + { + if (! isset($rows[$y])) { + throw new InvalidArgumentException("The piece has no row {$y}."); + } + if ($symbol === '' || TerminalText::symbolCount($symbol) !== 1 || TerminalText::displayWidth($symbol) !== 1 + || str_contains($symbol, '<')) { + throw new InvalidArgumentException('A cell holds one character that fills one terminal cell.'); + } + $cells = TerminalText::parseSourceCells($rows[$y]); + if (! isset($cells[$x])) { + throw new InvalidArgumentException("Row {$y} of the piece has no cell {$x}."); + } + [$prefix, $suffix] = CanvasEditor::resolvePaintStyle($symbol, $color, ['prefix' => $cells[$x]['prefix'], 'suffix' => $cells[$x]['suffix']]); + $source = array_map(static fn(array $cell): string => $cell['prefix'] . $cell['symbol'] . $cell['suffix'], $cells); + $source[$x] = $prefix . $symbol . $suffix; + $rows[$y] = implode('', $source); + + return $rows; + } + + /** + * The piece's painted doors with the door at one cell set, its entrance + * changed, or with null taken away, in the form its Painted Doors row + * takes ({@see PieceDoorwayCodec}). + * + * @param mixed $doorway The piece's authored doorway, or null when it has none. + */ + public static function setDoor(mixed $doorway, int $x, int $y, ?DoorwayEntrance $entrance): string + { + $cells = $doorway === null || $doorway === [] ? [] : Doorway::exportCells(Doorway::readCells($doorway, 'Painted doors')); + $cells = array_values(array_filter($cells, static fn(array $cell): bool => $cell['x'] !== $x || $cell['y'] !== $y)); + if ($entrance !== null) { + $cells[] = ['x' => $x, 'y' => $y] + ($entrance === DoorwayEntrance::ON ? [] : ['entrance' => $entrance->value]); + usort($cells, static fn(array $a, array $b): int => [$a['y'], $a['x']] <=> [$b['y'], $b['x']]); + } + + return PieceDoorwayCodec::encode($cells === [] ? null : $cells); + } +} diff --git a/src/Maps/TreeBrush.php b/src/Maps/TreeBrush.php new file mode 100644 index 00000000..91f51fe5 --- /dev/null +++ b/src/Maps/TreeBrush.php @@ -0,0 +1,574 @@ +'; + private const string TRUNK_STYLE = ''; + + /** @var array Tree types by id. */ + private array $types; + + private function __construct(private readonly ProjectMap $map, FieldPresentationCatalog $catalog) + { + $this->types = array_filter($catalog->resources, self::isTree(...)); + } + + /** + * The project's tree types: placeable images with a ground footprint. + * + * @return array Names by id. + */ + public static function listTypes(ProjectMap $map): array + { + return array_map(static fn(WorldObjectResource $type): string => $type->name, + array_filter(self::loadCatalog($map)->resources, self::isTree(...))); + } + + /** + * Plants the chosen tree types over the region's cells. + * + * @param list $types Tree type ids to vary between. + * @param list $region + * @return array{command: ?Command, planted: int, removed: int} + * @throws MapSourceRefusal When the map cannot take trees or a type is unknown; nothing is changed. + */ + public static function draw(ProjectMap $map, array $types, array $region): array + { + $brush = new self($map, self::loadCatalog($map)); + $chosen = $brush->requireTypes($types); + + return $brush->apply('Draw trees', static fn(array &$entries, array &$solid) => $brush->plant($chosen, $region, $entries, $solid)); + } + + /** + * Removes every tree standing in the region, clearing its trunk's collision. + * + * @param list $region + * @return array{command: ?Command, planted: int, removed: int} + * @throws MapSourceRefusal When the map cannot take trees; nothing is changed. + */ + public static function erase(ProjectMap $map, array $region): array + { + $brush = new self($map, self::loadCatalog($map)); + + return $brush->apply('Erase trees', static fn(array &$entries, array &$solid) => $brush->fell($region, $entries, $solid)); + } + + private static function isTree(WorldObjectResource $resource): bool + { + return $resource->sprites instanceof GraphicalSpriteDefinition && $resource->sign === null && $resource->occupancyStamp !== []; + } + + /** The image's width in map cells. */ + private static function getCellsWide(WorldObjectResource $type): int + { + return max(1, intdiv($type->sprites->width, FieldViewport::TILE_SIZE)); + } + + /** The image's height in map cells. */ + private static function getCellsTall(WorldObjectResource $type): int + { + return max(1, intdiv($type->sprites->height, FieldViewport::TILE_SIZE)); + } + + private static function loadCatalog(ProjectMap $map): FieldPresentationCatalog + { + try { + return $map->getFieldCatalog(); + } catch (Throwable $error) { + throw new MapSourceRefusal('The field resources cannot be read, so no tree can be drawn: ' . $error->getMessage(), previous: $error); + } + } + + /** @return list */ + private function requireTypes(array $ids): array + { + if ($ids === []) { + throw new MapSourceRefusal('Choose at least one tree type to draw. Nothing was changed.'); + } + return array_map(fn(string $id): WorldObjectResource => $this->types[$id] + ?? throw new MapSourceRefusal("'{$id}' is not a tree type: a tree is a field resource image with a trunk stamp. Nothing was changed."), + array_values(array_unique($ids))); + } + + /** + * Runs one change over the map's world objects and solid trunk cells, then + * redraws the terminal canopy, recording everything as one step. + * + * @param callable(array, array): array{0: int, 1: int} $change Mutates the entries and solid writes; returns planted and removed counts. + * @return array{command: ?Command, planted: int, removed: int} + */ + private function apply(string $label, callable $change): array + { + $map = $this->map; + $map->assertSourcesUnchanged(); + if (! $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + throw new MapSourceRefusal('Trees need the map\'s own collision so their trunks can be solid. Separate collision first, then draw. Nothing was changed.'); + } + $standing = $map->hasMapDataField([WorldObjectDefinition::DATA_KEY]) ? $map->getMapDataField([WorldObjectDefinition::DATA_KEY]) : []; + $entries = $standing; + $writes = []; + [$planted, $removed] = $change($entries, $writes); + if (count($entries) > WorldObjectDefinition::MAX_OBJECTS) { + $room = WorldObjectDefinition::MAX_OBJECTS - (count($entries) - $planted); + throw new MapSourceRefusal(sprintf('A map holds at most %d world objects; this drawing needs %d more and only %d still fit. Draw a smaller region. Nothing was changed.', + WorldObjectDefinition::MAX_OBJECTS, $planted, max(0, $room))); + } + [$glyphs, $entries] = $this->layOutCanopy($entries); + $canopy = $this->findCanopyLayer(); + // Drawing where nothing grows still brings a stale canopy up to date. + $stale = $canopy === null ? $glyphs !== [] + : $canopy['decoration'] || $entries !== $standing || $this->findCanopyChanges($canopy['id'], $glyphs) !== []; + if ($planted === 0 && $removed === 0 && ! $stale) { + return ['command' => null, 'planted' => 0, 'removed' => 0]; + } + + $before = $map->captureLayerSnapshot(); + try { + if ($canopy === null) { + $map->createLayer(self::CANOPY_LAYER); + } elseif ($canopy['decoration']) { + $map->setLayerDecoration($canopy['id'], false); + } + try { + WorldObjectAuthoring::validateEntries($map, $entries, false); + } catch (InvalidArgumentException $invalid) { + throw new MapSourceRefusal($invalid->getMessage() . ' Nothing was changed.', previous: $invalid); + } + $map->setMapDataField([WorldObjectDefinition::DATA_KEY], $entries); + $map->writePhysicalOccupancy($writes); + $this->drawCanopy($glyphs); + } catch (Throwable $error) { + $map->restoreLayerSnapshot($before); + throw $error instanceof MapSourceRefusal ? $error : new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); + } + $after = $map->captureLayerSnapshot(); + + return ['command' => new GenericCommand($label, + static fn() => $map->restoreLayerSnapshot($after), + static fn() => $map->restoreLayerSnapshot($before)), 'planted' => $planted, 'removed' => $removed]; + } + + /** + * @param list $types + * @param list $region + * @return array{0: int, 1: int} + */ + private function plant(array $types, array $region, array &$entries, array &$writes): array + { + $inRegion = []; + foreach ($region as [$x, $y]) { + $inRegion[$y][$x] = true; + } + [$walkable, $floor] = $this->readGround(); + $claimed = $this->readClaimedCells($entries); + $anchors = $this->readTreeAnchors($entries); + $planted = 0; + foreach ($this->orderCells($region) as [$x, $y]) { + $type = $types[$this->hash($x, $y, 'type') % count($types)]; + if (! $this->canPlant($type, $x, $y, $inRegion, $walkable, $floor, $claimed, $anchors)) { + continue; + } + foreach ($type->occupancyStamp as $cell) { + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + $writes[] = [$cx, $cy, $cell['type']]; + $claimed[$cy][$cx] = true; + if ($cell['type']->blocksMovement()) { + unset($walkable[$cy][$cx], $floor[$cy][$cx]); + } + } + $anchors[] = [$x, $y, $type]; + $entries[] = ['id' => $this->findUnusedId($entries, $type->id), 'resource' => $type->id, 'anchor' => ['x' => $x, 'y' => $y]]; + $planted++; + } + + return [$planted, 0]; + } + + /** + * @param list $region + * @return array{0: int, 1: int} + */ + private function fell(array $region, array &$entries, array &$writes): array + { + $inRegion = []; + foreach ($region as [$x, $y]) { + $inRegion[$y][$x] = true; + } + $kept = $felled = []; + foreach ($entries as $entry) { + $type = $this->types[$entry['resource'] ?? ''] ?? null; + $at = $entry['anchor'] ?? []; + if ($type !== null && isset($inRegion[$at['y'] ?? -1][$at['x'] ?? -1])) { + $felled[] = [$at['x'], $at['y'], $type]; + } else { + $kept[] = $entry; + } + } + if ($felled === []) { + return [0, 0]; + } + // A trunk cell another standing tree still stamps keeps its collision. + $stillStamped = []; + foreach ($this->readTreeAnchors($kept) as [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + $stillStamped[$y + $cell['y']][$x + $cell['x']] = true; + } + } + foreach ($felled as [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if (! isset($stillStamped[$cy][$cx])) { + $writes[] = [$cx, $cy, CollisionType::NONE]; + } + } + } + $entries = $kept; + + return [0, count($felled)]; + } + + /** + * Whether a tree fits with its anchor here: every stamped cell inside the + * region and on plain floor nothing claims, room from other trunks, and no + * trunk cutting a walkable area in two. Trunks stand only on plain floor, + * so erasing a tree gives back exactly the floor it took; grass, exits and + * save points still count as walkable ground a trunk must not cut off. + * + * @param array> $inRegion + * @param array> $walkable + * @param array> $floor + * @param array> $claimed + * @param list $anchors + */ + private function canPlant(WorldObjectResource $type, int $x, int $y, array $inRegion, array $walkable, array $floor, array $claimed, array $anchors): bool + { + foreach ($type->occupancyStamp as $cell) { + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if (! isset($inRegion[$cy][$cx], $floor[$cy][$cx]) || isset($claimed[$cy][$cx])) { + return false; + } + } + foreach ($anchors as [$ax, $ay, $other]) { + // Crowns may overlap, as in a forest; trunks keep half a crown apart. + $reachX = intdiv(max(self::getCellsWide($type), self::getCellsWide($other)), 2); + $reachY = max(1, intdiv(max(self::getCellsTall($type), self::getCellsTall($other)), 3)); + if (abs($ax - $x) <= $reachX && abs($ay - $y) <= $reachY) { + return false; + } + } + foreach ($type->occupancyStamp as $cell) { + if (! $cell['type']->blocksMovement()) { + continue; + } + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if (! self::isSimple($walkable, $cx, $cy)) { + return false; + } + unset($walkable[$cy][$cx]); + } + + return true; + } + + /** + * Whether making a walkable cell solid keeps the walkable cells around it + * connected: its walkable side neighbours must still reach each other + * through the eight cells around it. Then any path through it can step + * around it, so no walkable area is cut in two. + * + * @param array> $walkable + */ + private static function isSimple(array $walkable, int $x, int $y): bool + { + $open = []; + for ($dy = -1; $dy <= 1; $dy++) { + for ($dx = -1; $dx <= 1; $dx++) { + if (($dx !== 0 || $dy !== 0) && isset($walkable[$y + $dy][$x + $dx])) { + $open["{$dx},{$dy}"] = [$dx, $dy]; + } + } + } + $sides = array_values(array_filter([[0, -1], [1, 0], [0, 1], [-1, 0]], static fn(array $side): bool => isset($open["{$side[0]},{$side[1]}"]))); + if (count($sides) < 2) { + return true; + } + $seen = ["{$sides[0][0]},{$sides[0][1]}" => true]; + $queue = [$sides[0]]; + while ($queue !== []) { + [$cx, $cy] = array_shift($queue); + foreach ([[0, -1], [1, 0], [0, 1], [-1, 0]] as [$sx, $sy]) { + $key = ($cx + $sx) . ',' . ($cy + $sy); + if (isset($open[$key]) && ! isset($seen[$key])) { + $seen[$key] = true; + $queue[] = $open[$key]; + } + } + } + foreach ($sides as [$sx, $sy]) { + if (! isset($seen["{$sx},{$sy}"])) { + return false; + } + } + + return true; + } + + /** + * @return array{0: array>, 1: array>} The cells the + * player can walk on today, and those of them that are plain floor. + */ + private function readGround(): array + { + $walkable = $floor = []; + foreach ($this->map->getResolvedCollisionMap() as $y => $row) { + foreach ($row as $x => $value) { + $type = CollisionType::from($value); + if (! $type->blocksMovement()) { + $walkable[$y][$x] = true; + } + if ($type === CollisionType::NONE) { + $floor[$y][$x] = true; + } + } + } + + return [$walkable, $floor]; + } + + /** + * Cells no trunk may take: events, NPCs and every standing tree's stamp. + * + * @return array> + */ + private function readClaimedCells(array $entries): array + { + $claimed = []; + for ($y = 0, $height = $this->map->getHeight(); $y < $height; $y++) { + for ($x = 0, $width = $this->map->getWidth(); $x < $width; $x++) { + if ($this->map->hasLayerCell(MapLayers::EVENT, $x, $y) && $this->map->getEventMarkerAt($x, $y) !== null) { + $claimed[$y][$x] = true; + } + } + } + foreach ((array) ($this->map->getEditableData()['npcs'] ?? []) as $npc) { + if (is_int($npc['x'] ?? null) && is_int($npc['y'] ?? null)) { + $claimed[$npc['y']][$npc['x']] = true; + } + } + foreach ($this->readTreeAnchors($entries) as [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + $claimed[$y + $cell['y']][$x + $cell['x']] = true; + } + } + + return $claimed; + } + + /** @return list Every standing tree: its anchor and type. */ + private function readTreeAnchors(array $entries): array + { + $anchors = []; + foreach ($entries as $entry) { + $type = $this->types[$entry['resource'] ?? ''] ?? null; + if ($type !== null && is_int($entry['anchor']['x'] ?? null) && is_int($entry['anchor']['y'] ?? null)) { + $anchors[] = [$entry['anchor']['x'], $entry['anchor']['y'], $type]; + } + } + + return $anchors; + } + + /** + * The region's cells in the order trees are tried: fixed by the map and + * cell, so the same region always grows the same forest. + * + * @param list $region + * @return list + */ + private function orderCells(array $region): array + { + $cells = array_values(array_unique(array_map(static fn(array $cell): string => $cell[0] . ',' . $cell[1], $region))); + $cells = array_map(static fn(string $key): array => array_map('intval', explode(',', $key)), $cells); + usort($cells, fn(array $a, array $b): int => [$this->hash($a[0], $a[1], 'order'), $a[1], $a[0]] <=> [$this->hash($b[0], $b[1], 'order'), $b[1], $b[0]]); + + return $cells; + } + + private function hash(int $x, int $y, string $purpose): int + { + return hexdec(substr(hash('xxh32', "{$this->map->mapId}:{$x}:{$y}:{$purpose}"), 0, 7)); + } + + private function findUnusedId(array $entries, string $stem): string + { + $ids = array_flip(array_column($entries, 'id')); + for ($number = 1; isset($ids[$stem . '-' . $number]); $number++) { + } + + return $stem . '-' . $number; + } + + /** + * Lays out the canopy from the standing trees: each crown spans the + * image's width in the row above its trunk and narrows evenly to one or two + * cells at the top of the image, and its trunk cells read `|`. Where crowns + * overlap, the cell goes to the tree drawn first, and a trunk always to its + * own tree. Each tree covers exactly the canopy cells it owns. + * + * @return array{0: array>, 1: array} The canopy glyphs by row and column, and the entries with each tree's covers. + */ + private function layOutCanopy(array $entries): array + { + $glyphs = $owners = []; + $width = $this->map->getWidth(); + $height = $this->map->getHeight(); + $inside = static fn(int $x, int $y): bool => $x >= 0 && $y >= 0 && $x < $width && $y < $height; + $trees = []; + foreach ($entries as $index => $entry) { + $type = $this->types[$entry['resource'] ?? ''] ?? null; + if ($type !== null && is_int($entry['anchor']['x'] ?? null) && is_int($entry['anchor']['y'] ?? null)) { + $trees[$index] = [$entry['anchor']['x'], $entry['anchor']['y'], $type]; + } + } + foreach ($trees as $index => [$x, $y, $type]) { + $wide = self::getCellsWide($type); + $left = $x - (int) round($type->pivot->x * $wide - 0.5); + $rows = self::getCellsTall($type) - 1; + $narrowest = intdiv($wide + 1, 2) - 1; + for ($row = 1; $row <= $rows; $row++) { + $inset = intdiv(($row - 1) * $narrowest, max(1, $rows - 1)); + $span = $wide - 2 * $inset; + for ($i = 0; $i < $span; $i++) { + [$cx, $cy] = [$left + $inset + $i, $y - $row]; + if ($inside($cx, $cy) && ! isset($glyphs[$cy][$cx])) { + $glyphs[$cy][$cx] = $i < intdiv($span, 2) ? '(' : ')'; + $owners[$cy][$cx] = $index; + } + } + } + } + foreach ($trees as $index => [$x, $y, $type]) { + foreach ($type->occupancyStamp as $cell) { + [$cx, $cy] = [$x + $cell['x'], $y + $cell['y']]; + if ($cell['type']->blocksMovement() && $inside($cx, $cy)) { + $glyphs[$cy][$cx] = '|'; + $owners[$cy][$cx] = $index; + } + } + } + $cells = []; + ksort($owners); + foreach ($owners as $cy => $row) { + ksort($row); + foreach ($row as $cx => $index) { + $cells[$index][] = [$cx, $cy]; + } + } + foreach ($trees as $index => $_) { + unset($entries[$index]['covers']); + if (isset($cells[$index])) { + $entries[$index]['covers'] = ['layer' => self::CANOPY_LAYER, 'cells' => $cells[$index], 'tileLayers' => []]; + } + } + + return [$glyphs, $entries]; + } + + /** + * The canopy cells whose glyph or style differs from the layout. + * + * @param array> $glyphs + * @return list Each cell's x, y, glyph, style prefix and suffix. + */ + private function findCanopyChanges(string $layer, array $glyphs): array + { + $changes = []; + for ($y = 0, $height = $this->map->getHeight(); $y < $height; $y++) { + for ($x = 0, $width = $this->map->getWidth(); $x < $width; $x++) { + if (! $this->map->hasLayerCell($layer, $x, $y)) { + continue; + } + $glyph = $glyphs[$y][$x] ?? ' '; + [$prefix, $suffix] = match ($glyph) { + ' ' => ['', ''], + '|' => [self::TRUNK_STYLE, ''], + default => [self::CROWN_STYLE, ''], + }; + if ($this->map->getLayerSymbol($layer, $x, $y) !== $glyph || $this->map->getLayerCellStyle($layer, $x, $y) !== ['prefix' => $prefix, 'suffix' => $suffix]) { + $changes[] = [$x, $y, $glyph, $prefix, $suffix]; + } + } + } + + return $changes; + } + + /** Redraws the canopy layer's cells that differ from the layout. */ + private function drawCanopy(array $glyphs): void + { + $layer = $this->findCanopyLayer()['id'] ?? throw new MapSourceRefusal('The canopy layer is missing. Nothing was changed.'); + $changes = $this->findCanopyChanges($layer, $glyphs); + if ($changes !== []) { + $this->map->setLayerCells($layer, array_map(static fn(array $change): array => ['x' => $change[0], 'y' => $change[1], + 'symbol' => $change[2], 'prefix' => $change[3], 'suffix' => $change[4]], $changes)); + } + } + + /** @return array{id: string, decoration: bool}|null */ + private function findCanopyLayer(): ?array + { + foreach ($this->map->getLayers() as $layer) { + if ($layer['name'] === self::CANOPY_LAYER) { + return $layer; + } + } + + return null; + } +} diff --git a/src/MenuPresentationSettings.php b/src/MenuPresentationSettings.php new file mode 100644 index 00000000..1352275b --- /dev/null +++ b/src/MenuPresentationSettings.php @@ -0,0 +1,132 @@ +categories = $categories; + } + + /** + * Every atlas setting with the default the Engine's MapAtlasTheme reads when the catalog leaves it out, and every + * atlas marker role, which shows the Engine's own pictogram until the theme binds an image. + */ + protected function listEngineSettings(): array + { + $settings = []; + foreach (get_object_vars(new MapAtlasTheme()) as $key => $default) { + $settings[self::ATLAS . '.' . $key] = ['default' => $default]; + } + foreach ($this->listMarkerRoles() as $role) { + $settings[self::ICONS . '.' . $role] = ['default' => null]; + } + + return $settings; + } + + /** An icon binding's path is its group and its whole role, dots included. */ + protected function splitPath(string $path): array + { + return str_starts_with($path, self::ICONS . '.') ? [self::ICONS, substr($path, strlen(self::ICONS) + 1)] : explode('.', $path); + } + + /** An icon bound to no image is no binding: its key goes, and the Engine's own treatment shows. */ + protected function isRemovedWhenEmpty(string $path): bool + { + return str_starts_with($path, self::ICONS . '.'); + } + + /** Whether a setting is an icon binding. */ + public static function isIcon(string $path): bool + { + return str_starts_with($path, self::ICONS . '.'); + } + + /** Whether a setting binds an atlas marker the Engine draws with a pictogram of its own when unbound. */ + public function isMarkerIcon(string $path): bool + { + return self::isIcon($path) && in_array(substr($path, strlen(self::ICONS) + 1), $this->listMarkerRoles(), true); + } + + /** @return list The atlas marker roles, as the Engine names them for the project's place categories. */ + private function listMarkerRoles(): array + { + try { + $categories = $this->categories !== null ? ($this->categories)() : MapCategoryCatalog::load($this->projectRoot . '/assets'); + } catch (\InvalidArgumentException) { + $categories = new MapCategoryCatalog(); + } + return MapAtlasPresentation::listIconRoles($categories); + } + + /** + * Refuses an atlas value the Engine would not accept beside the atlas settings as they stand: its theme type + * checks each value and how they relate (the larger corner at least as large as the compact one). + * + * @throws \InvalidArgumentException + */ + public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void + { + if (self::isIcon($path)) { + if ($value === null) { return; } + // The registry checks the role and path as the Engine binds them; the image must be one it can draw. + try { + new MenuIconRegistry($this->projectRoot . '/assets', [substr($path, strlen(self::ICONS) + 1) => $value]); + PngAssetPreflight::inspect($this->projectRoot . '/assets', (string) $value); + } catch (\Throwable $error) { + throw new \InvalidArgumentException(sprintf('%s cannot be this icon: %s', (string) $value, $error->getMessage()), previous: $error); + } + return; + } + if (! str_starts_with($path, self::ATLAS . '.')) { return; } + $atlas = []; + foreach (array_keys(get_object_vars(new MapAtlasTheme())) as $key) { + $current = $this->getCurrentValue(self::ATLAS . '.' . $key); + if ($current !== null) { $atlas[$key] = $current; } + } + $key = substr($path, strlen(self::ATLAS) + 1); + if ($value === null) { unset($atlas[$key]); } else { $atlas[$key] = $value; } + MapAtlasTheme::fromArray($atlas); + } +} diff --git a/src/PermanentGrowthCatalog.php b/src/PermanentGrowthCatalog.php index 33ba75cb..17436bbd 100644 --- a/src/PermanentGrowthCatalog.php +++ b/src/PermanentGrowthCatalog.php @@ -31,7 +31,8 @@ final class PermanentGrowthCatalog /** * Where a project defines its permanent growth. */ - public const string RELATIVE_PATH = 'assets/Data/permanent-growth.php'; + public const string ASSET_PATH = 'Data/permanent-growth.php'; + public const string RELATIVE_PATH = 'assets/' . self::ASSET_PATH; /** * @param array> $definitions By stable id, as authored. diff --git a/src/Playtest/PlaytestLaunch.php b/src/Playtest/PlaytestLaunch.php new file mode 100644 index 00000000..a45d303f --- /dev/null +++ b/src/Playtest/PlaytestLaunch.php @@ -0,0 +1,252 @@ + $context The Engine launch context, data root included. + * @param string|null $mapId The map the playtest starts on; null from the title or in a battle test. + * @param int|null $x The starting column. + * @param int|null $y The starting row. + */ + private function __construct( + public readonly string $projectRoot, + public readonly string $dataRoot, + private readonly array $context, + public readonly ?string $mapId = null, + public readonly ?int $x = null, + public readonly ?int $y = null, + ) { + } + + /** A new game starting on a map cell, past the title. */ + public static function fromCell(string $projectRoot, string $mapId, int $x, int $y): self + { + return self::prepare($projectRoot, ['mode' => GameStartupMode::NEW_GAME->value, + 'start' => ['mapId' => $mapId, 'x' => $x, 'y' => $y]], $mapId, $x, $y); + } + + /** The game from its title with the project's own starting position: the opening as a player first meets it. */ + public static function fromTitle(string $projectRoot): self + { + return self::prepare($projectRoot, ['mode' => GameStartupMode::TITLE->value]); + } + + /** A new game starting on a map cell that plays a cinematic once the player has arrived. */ + public static function fromCinematic(string $projectRoot, string $mapId, int $x, int $y, string $cinematicId): self + { + return self::prepare($projectRoot, ['mode' => GameStartupMode::NEW_GAME->value, + 'start' => ['mapId' => $mapId, 'x' => $x, 'y' => $y], 'cinematicId' => $cinematicId], $mapId, $x, $y); + } + + /** + * A battle test, as the Engine's ProjectBattleTest writes it: the troop, + * and the party and arena when it sets them. It is the battle test as the + * author has it now, unsaved edits included; the project's own is not read. + * A troop composed in it and kept is handed back through the launch's + * data root ({@see PlaytestRun::takeKeptTroops()}), never written into + * the project by the game. + * + * @param array $battleTest + */ + public static function fromBattleTest(string $projectRoot, array $battleTest): self + { + return self::prepare($projectRoot, ['battleTest' => $battleTest, 'keepTroops' => true]); + } + + /** Where the launch's own output goes: the play command's, and the game's standard output. */ + public function getLogPath(): string + { + return $this->dataRoot . DIRECTORY_SEPARATOR . self::LOG_DIRECTORY . DIRECTORY_SEPARATOR . 'playtest.log'; + } + + /** + * The environment the play command runs with: this one's, without an + * inherited start mode or launch context that would compete with the + * launch the play command is given ({@see buildPlayOption()}). + * + * @return array + */ + public function buildEnvironment(): array + { + $environment = getenv(); + $environment = is_array($environment) ? $environment : []; + unset($environment[GameStartupMode::ENVIRONMENT_VARIABLE], $environment[GameLaunchContext::ENVIRONMENT_VARIABLE]); + + return $environment; + } + + /** + * The play command's option carrying the launch: the Console checks the + * project's Engine can read it, keeps the game's logs in the data root, + * and hands it to the game process alone. + */ + public function buildPlayOption(): string + { + return '--launch-context=' . self::encodeContext($this->context); + } + + /** + * The launch context as the Engine reads it. + * + * @return array + */ + public function getContext(): array + { + return $this->context; + } + + /** + * Removes the data root and everything the playtest wrote there. Nothing + * in it links anywhere (the Engine refuses data that follows a link), and + * a link is still only unlinked, never followed into what it names. + */ + public function destroy(): void + { + self::removeTree($this->dataRoot); + } + + /** + * Checks the intent the Engine's way, then fills a fresh data root. + * + * @param array $intent The launch context without its data root. + * @throws RuntimeException When the intent is not one the Engine reads, or the data root cannot be made. + */ + private static function prepare(string $projectRoot, array $intent, ?string $mapId = null, ?int $x = null, ?int $y = null): self + { + $project = realpath($projectRoot); + if ($project === false || ! is_dir($project)) { + throw new RuntimeException("The project to playtest is not at {$projectRoot}."); + } + // Checked before anything is made; keeping troops needs the data root made next, so it is checked with it. + self::validateIntent(array_diff_key($intent, ['keepTroops' => true])); + + $created = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('ichiloto-playtest-', true); + if (! @mkdir($created, 0o700, true) && ! is_dir($created)) { + throw new RuntimeException("Unable to create the playtest's data directory at {$created}."); + } + // The Engine takes the data root only by its canonical path: the temporary directory may sit behind a link. + $root = realpath($created) ?: $created; + + try { + if (! @mkdir($root . DIRECTORY_SEPARATOR . self::LOG_DIRECTORY, 0o700)) { + throw new RuntimeException("Unable to create the playtest's log directory in {$root}."); + } + self::copyPlayerSettings($project, $root); + touch($root . DIRECTORY_SEPARATOR . self::LOG_DIRECTORY . DIRECTORY_SEPARATOR . 'playtest.log'); + LaunchDataRoot::validate($root, $project); + $context = [...$intent, 'dataRoot' => $root]; + self::validateIntent($context); + self::encodeContext($context); + } catch (InvalidArgumentException $invalid) { + self::removeTree($root); + + throw new RuntimeException('The playtest cannot keep its data apart from the project: ' . $invalid->getMessage(), previous: $invalid); + } catch (\Throwable $throwable) { + self::removeTree($root); + + throw $throwable; + } + + return new self($project, $root, $context, $mapId, $x, $y); + } + + /** + * @param array $context + * @throws RuntimeException When the Engine would not start the game that way. + */ + private static function validateIntent(array $context): void + { + try { + GameLaunchContext::fromArray($context); + } catch (InvalidArgumentException $invalid) { + throw new RuntimeException('The game cannot start that way: ' . $invalid->getMessage(), previous: $invalid); + } + } + + /** + * Copies the author's player settings into the data root: volume and + * mute, controls and other preferences belong to the player, so a + * playtest honours them, while the copy keeps any change made during the + * playtest out of the project. Saves stay behind. + */ + private static function copyPlayerSettings(string $projectRoot, string $root): void + { + $source = $projectRoot . DIRECTORY_SEPARATOR . '.data' . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS; + + if (is_file($source) && ! copy($source, $root . DIRECTORY_SEPARATOR . self::PLAYER_SETTINGS)) { + throw new RuntimeException("Unable to copy the player settings into the playtest from {$source}."); + } + } + + /** + * @param array $context + * @throws RuntimeException When the context is too large for the Engine to take. + */ + private static function encodeContext(array $context): string + { + try { + $json = json_encode($context, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (JsonException $error) { + throw new RuntimeException('The playtest launch could not be written: ' . $error->getMessage(), previous: $error); + } + if (strlen($json) > GameLaunchContext::MAX_TRANSPORT_BYTES) { + throw new RuntimeException(sprintf('The playtest launch is larger than the %d bytes the game takes.', GameLaunchContext::MAX_TRANSPORT_BYTES)); + } + + return $json; + } + + private static function removeTree(string $directory): void + { + if (is_link($directory) || is_file($directory)) { + @unlink($directory); + + return; + } + if (! is_dir($directory)) { + return; + } + + foreach (scandir($directory) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..') { + self::removeTree($directory . DIRECTORY_SEPARATOR . $entry); + } + } + + @rmdir($directory); + } +} diff --git a/src/Playtest/PlaytestLauncher.php b/src/Playtest/PlaytestLauncher.php index 32f1af3f..4c08d484 100644 --- a/src/Playtest/PlaytestLauncher.php +++ b/src/Playtest/PlaytestLauncher.php @@ -4,10 +4,13 @@ namespace Ichiloto\Editor\Playtest; +use Ichiloto\Editor\Console\ConsoleBinary; use RuntimeException; /** - * Runs `ichiloto play` against a playtest overlay. + * Runs `ichiloto play` from the author's real project root for a + * {@see PlaytestLaunch}, which carries where the game starts and where its + * data lives in the Engine's launch context. * * `--no-tmux` is always passed: the editor already owns this terminal, and * letting the play command open or reuse a tmux session would detach the game @@ -23,11 +26,7 @@ public function __construct(private readonly string $consoleBinary) } /** - * Locates the console entry point. - * - * A project may install the console in its own vendor directory, a global - * install puts it on PATH, and this package may itself be installed under - * another Composer vendor tree. + * Locates the console entry point ({@see ConsoleBinary::discover()}). * * @param string|null $override An explicit path (`ICHILOTO_CONSOLE_BIN`). * @param string|null $projectRoot The project being edited, if known. @@ -35,125 +34,113 @@ public function __construct(private readonly string $consoleBinary) */ public static function discover(?string $override = null, ?string $projectRoot = null): self { - $candidates = self::candidates($override, $projectRoot); - - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return new self($candidate); - } - } - - throw new RuntimeException(sprintf( - "Could not find the ichiloto console binary — set ICHILOTO_CONSOLE_BIN to its path.\nLooked in:\n %s", - implode("\n ", $candidates), - )); + return new self(ConsoleBinary::discover($override, $projectRoot)->path); } /** - * Returns where the console binary might be, best guess first. + * The play command's arguments, from its interpreter on: the game in this + * terminal, from the project's own root. * - * @param string|null $override An explicit path. - * @param string|null $projectRoot The project being edited, if known. - * @return string[] The paths to try. + * @return list */ - private static function candidates(?string $override, ?string $projectRoot): array + public function buildPlayArguments(PlaytestLaunch $launch): array { - return array_values(array_unique(array_filter([ - $override, - (string) getenv('ICHILOTO_CONSOLE_BIN') ?: null, - // A project that requires ichiloto/console. - is_string($projectRoot) && $projectRoot !== '' - ? rtrim($projectRoot, DIRECTORY_SEPARATOR) . '/vendor/bin/ichiloto' - : null, - // This package installed under a vendor tree, the project's or - // the console's own. - ...self::vendorBinariesAbove(), - // Installed globally. - self::binaryOnPath(), - ]))); + return [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', $launch->buildPlayOption(), '-d', $launch->projectRoot]; } + /** The renderer choice that lets the console pick: GPUI when it is installed and usable, otherwise the terminal. */ + public const string AUTOMATIC = 'auto'; + /** - * Returns every `vendor/bin/ichiloto` in a directory above this package. + * Starts the playtest in the background with a renderer of its own, for + * an editor that owns no terminal to hand over: the play command runs + * without prompts, reading nothing and writing its output to the launch's + * log. A terminal session opens in a terminal window of its own, which + * closes when the game ends. Field playtests and battle tests start the + * same way; the launch context says which it is. * - * @return string[] The paths. + * @param string $rendererId The renderer, such as `gpui`, or {@see AUTOMATIC}. + * @throws RuntimeException When the process cannot start. */ - private static function vendorBinariesAbove(): array + public function start(PlaytestLaunch $launch, string $rendererId): PlaytestRun { - $paths = []; - $directory = dirname(__DIR__, 2); - - // Deep enough to climb out of `vendor/ichiloto/editor` and any - // directory nesting a project keeps above it. - for ($depth = 0; $depth < 8; $depth++) { - $paths[] = $directory . '/vendor/bin/ichiloto'; - $parent = dirname($directory); + $log = @fopen($launch->getLogPath(), 'ab'); + if (! is_resource($log)) { + throw new RuntimeException("Unable to open the playtest log at {$launch->getLogPath()}."); + } - if ($parent === $directory) { - break; - } + try { + $pipes = []; + $process = @proc_open( + [PHP_BINARY, $this->consoleBinary, 'play', '--no-tmux', '--no-interaction', '--renderer=' . $rendererId, + '--terminal-window', $launch->buildPlayOption(), '-d', $launch->projectRoot], + [0 => ['file', '/dev/null', 'r'], 1 => $log, 2 => $log], + $pipes, + $launch->projectRoot, + $launch->buildEnvironment(), + ); + } finally { + fclose($log); + } - $directory = $parent; + if (! is_resource($process)) { + throw new RuntimeException('The playtest could not be started.'); } - return $paths; + return new PlaytestRun($process, $launch); } /** - * Returns the console binary found on PATH, if it is there. + * The renderers the project's Engine can launch, as the console reports + * them without starting any: each with its name, whether it is usable, + * and why not when it is not, and the one it prefers. * - * @return string|null The path, or null when PATH has no ichiloto. + * @return array{renderers: list, preferredId: ?string} + * @throws RuntimeException When the console cannot report them. */ - private static function binaryOnPath(): ?string + public function listRenderers(string $projectRoot): array { - $path = (string) getenv('PATH'); - - if ($path === '') { - return null; + $pipes = []; + $process = @proc_open([PHP_BINARY, $this->consoleBinary, 'renderer:list', '--directory=' . $projectRoot, '--json'], + [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $projectRoot); + if (! is_resource($process)) { + throw new RuntimeException('The renderers could not be listed: the console did not start.'); } - - foreach (explode(PATH_SEPARATOR, $path) as $directory) { - if ($directory === '') { - continue; - } - - $candidate = rtrim($directory, DIRECTORY_SEPARATOR) . '/ichiloto'; - - if (is_file($candidate)) { - return $candidate; + $output = (string) stream_get_contents($pipes[1]); + $errors = trim((string) stream_get_contents($pipes[2])); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($process); + $report = json_decode($output, true); + if ($exit !== 0 || ! is_array($report) || ($report['schema'] ?? null) !== 'ichiloto.renderers/1' || ! is_array($report['renderers'] ?? null)) { + throw new RuntimeException('The renderers could not be listed' . ($errors === '' ? '.' : ': ' . $errors)); + } + $renderers = []; + foreach ($report['renderers'] as $renderer) { + if (! is_array($renderer) || ! is_string($renderer['id'] ?? null) || ! is_bool($renderer['available'] ?? null)) { + throw new RuntimeException('The console reported a renderer the editor cannot read.'); } + $renderers[] = ['id' => $renderer['id'], 'displayName' => is_string($renderer['displayName'] ?? null) ? $renderer['displayName'] : $renderer['id'], + 'available' => $renderer['available'], 'reason' => is_string($renderer['reason'] ?? null) ? $renderer['reason'] : null]; } - return null; - } - - /** - * Returns the shell command that runs the playtest. - * - * @param PlaytestOverlay $overlay The overlay to play. - * @return string - */ - public function buildCommand(PlaytestOverlay $overlay): string - { - return sprintf( - '%s %s play --no-tmux -d %s', - escapeshellcmd(PHP_BINARY), - escapeshellarg($this->consoleBinary), - escapeshellarg($overlay->root), - ); + return ['renderers' => $renderers, 'preferredId' => is_string($report['preferredId'] ?? null) ? $report['preferredId'] : null]; } /** - * Runs the playtest, returning the child process exit code. + * Runs the playtest in this terminal, returning the play command's exit code. * - * @param PlaytestOverlay $overlay The overlay to play. - * @return int + * @throws RuntimeException When the process cannot start. */ - public function run(PlaytestOverlay $overlay): int + public function run(PlaytestLaunch $launch): int { - $exitCode = 0; - passthru($this->buildCommand($overlay), $exitCode); + $pipes = []; + $process = @proc_open($this->buildPlayArguments($launch), [0 => STDIN, 1 => STDOUT, 2 => STDERR], $pipes, + $launch->projectRoot, $launch->buildEnvironment()); + if (! is_resource($process)) { + throw new RuntimeException('The playtest could not be started.'); + } - return $exitCode; + return proc_close($process); } } diff --git a/src/Playtest/PlaytestOverlay.php b/src/Playtest/PlaytestOverlay.php deleted file mode 100644 index ef7ac559..00000000 --- a/src/Playtest/PlaytestOverlay.php +++ /dev/null @@ -1,373 +0,0 @@ -installCinematicLaunch(rtrim($projectRoot, DIRECTORY_SEPARATOR), $cinematicId); - } catch (\Throwable $throwable) { - $overlay->destroy(); - - throw $throwable; - } - - return $overlay; - } - - /** - * Replaces the overlay's start map with a copy carrying the launch trigger. - */ - private function installCinematicLaunch(string $projectRoot, string $cinematicId): void - { - $mapsSource = $projectRoot . '/assets/Maps'; - $mapsTarget = $this->root . '/assets/Maps'; - $segments = explode('/', trim(str_replace('\\', '/', $this->mapId), '/')); - $leaf = $segments[array_key_last($segments)]; - $mapSourceDirectory = $mapsSource . '/' . implode('/', $segments); - - foreach (['data', 'map', 'event'] as $part) { - if (! is_file($mapSourceDirectory . '/' . $leaf . '.' . $part . '.php')) { - throw new RuntimeException(sprintf('Map %s has no %s file to playtest from.', $this->mapId, $part)); - } - } - - // assets/Maps was one link; rebuild it as real directories down to - // the start map, linking every sibling on the way. - if (is_link($mapsTarget)) { - unlink($mapsTarget); - } - - mkdir($mapsTarget, 0777, true); - $currentSource = $mapsSource; - $currentTarget = $mapsTarget; - - foreach ($segments as $depth => $segment) { - $isLast = $depth === count($segments) - 1; - self::mirrorDirectory($currentSource, $currentTarget, [$segment]); - $currentSource .= '/' . $segment; - $currentTarget .= '/' . $segment; - mkdir($currentTarget, 0777, true); - - if (! $isLast) { - continue; - } - - self::mirrorDirectory($currentSource, $currentTarget, [$leaf . '.data.php', $leaf . '.event.php']); - self::writeCinematicLaunchFiles($currentSource, $currentTarget, $leaf, $cinematicId); - } - } - - /** - * Writes the start map's data and event-layer files with the launch - * trigger on the spawn tile. - */ - private function writeCinematicLaunchFiles(string $sourceDirectory, string $targetDirectory, string $leaf, string $cinematicId): void - { - $data = require $sourceDirectory . '/' . $leaf . '.data.php'; - $eventText = require $sourceDirectory . '/' . $leaf . '.event.php'; - - if (! is_array($data) || ! is_string($eventText)) { - throw new RuntimeException(sprintf('Map %s could not be read for the playtest.', $this->mapId)); - } - - if (! PhpValueExporter::isExportable($data)) { - throw new RuntimeException(sprintf('Map %s holds PHP objects, so a launch trigger cannot be written into a copy.', $this->mapId)); - } - - $events = is_array($data['events'] ?? null) ? $data['events'] : []; - $lines = preg_split('/\r\n|\n|\r/', rtrim($eventText, "\r\n")) ?: []; - $marker = self::freeEventMarker($events, $lines); - - if ($marker === null) { - throw new RuntimeException(sprintf('Map %s has no free event marker for the launch trigger.', $this->mapId)); - } - - $row = $lines[$this->spawnY] ?? null; - - if ($row === null) { - throw new RuntimeException(sprintf('Spawn row %d is outside map %s.', $this->spawnY, $this->mapId)); - } - - $symbols = preg_split('//u', $row, -1, PREG_SPLIT_NO_EMPTY) ?: []; - - if (! isset($symbols[$this->spawnX])) { - throw new RuntimeException(sprintf('Spawn column %d is outside map %s.', $this->spawnX, $this->mapId)); - } - - $symbols[$this->spawnX] = $marker; - $lines[$this->spawnY] = implode('', $symbols); - $events[$marker] = [ - 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\CinematicEventTrigger', - 'data' => ['cinematicId' => $cinematicId, 'mode' => 'auto', 'reusable' => false], - ]; - $data['events'] = $events; - - PhpDataFile::writeTransactionally( - $targetDirectory . '/' . $leaf . '.data.php', - " $events - * @param string[] $lines - */ - private static function freeEventMarker(array $events, array $lines): ?string - { - $layer = implode('', $lines); - - foreach (str_split('@!$%&*+=?^{}|~<>ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') as $candidate) { - if (! array_key_exists($candidate, $events) && ! str_contains($layer, $candidate)) { - return $candidate; - } - } - - return null; - } - - /** - * Removes the overlay directory. - * - * Only symlinks and the two generated entries live here, so unlinking is - * safe: `unlink()` on a symlink never follows it into the real project. - * - * @return void - */ - public function destroy(): void - { - self::removeTree($this->root); - } - - /** - * Symlinks every entry of a directory, skipping the named exceptions. - * - * @param string $source The directory to mirror. - * @param string $target The directory to fill. - * @param string[] $skip Entry names to leave out. - * @return void - */ - private static function mirrorDirectory(string $source, string $target, array $skip = []): void - { - foreach (scandir($source) ?: [] as $entry) { - if ($entry === '.' || $entry === '..' || in_array($entry, $skip, true)) { - continue; - } - - @symlink($source . DIRECTORY_SEPARATOR . $entry, $target . DIRECTORY_SEPARATOR . $entry); - } - } - - /** - * Writes a copy of system.php with the playtest spawn substituted in. - * - * The rest of the author's system data — party, battle settings, title - * screen — is preserved exactly, so the playtest is the real game. - * - * @param string $sourcePath The project's system.php. - * @param string $targetPath The overlay's system.php. - * @param string $mapId The map to start on. - * @param int $spawnX The spawn column. - * @param int $spawnY The spawn row. - * @return void - */ - private static function writeSystemOverride( - string $sourcePath, - string $targetPath, - string $mapId, - int $spawnX, - int $spawnY, - ): void { - if (! is_file($sourcePath)) { - throw new RuntimeException("The project has no assets/Data/system.php to base a playtest on."); - } - - try { - $payload = require $sourcePath; - } catch (Throwable $throwable) { - throw new RuntimeException("Unable to read system.php: {$throwable->getMessage()}."); - } - - if (! is_array($payload)) { - throw new RuntimeException('system.php did not return an array.'); - } - - if (! PhpValueExporter::isExportable($payload)) { - throw new RuntimeException( - 'system.php contains PHP objects, so a playtest spawn cannot be written without rewriting them.', - ); - } - - $player = $payload['startingPositions']['player'] ?? []; - $player = is_array($player) ? $player : []; - $player['destinationMap'] = $mapId; - $player['spawnPoint'] = ['x' => $spawnX, 'y' => $spawnY]; - $player['spawnSprite'] = $player['spawnSprite'] ?? ['^']; - $payload['startingPositions']['player'] = $player; - - PhpDataFile::writeTransactionally( - $targetPath, - ">> Troops the battle test kept, taken from the data root before it is removed. */ + private array $keptTroops = []; + + /** + * @param resource $process The play command's process. + */ + public function __construct(private $process, public readonly PlaytestLaunch $launch) + { + } + + /** Whether the game is still running; a finished run removes its data root. */ + public function isRunning(): bool + { + if ($this->finished) { + return false; + } + $status = proc_get_status($this->process); + if ($status['running']) { + return true; + } + // The exit code is only reported by the first status after exit. + $this->exitCode = $status['exitcode'] >= 0 ? $status['exitcode'] : null; + $this->finish(); + + return false; + } + + /** The play command's process id, while it runs. */ + public function getProcessId(): ?int + { + return $this->isRunning() ? proc_get_status($this->process)['pid'] : null; + } + + /** The play command's exit code once it has exited, null while running or when unknown. */ + public function getExitCode(): ?int + { + return $this->isRunning() ? null : $this->exitCode; + } + + /** Ends the game, its renderer and the play command, then removes the data root. */ + public function stop(): void + { + if (! $this->isRunning()) { + return; + } + $root = (int) $this->getProcessId(); + // The deepest processes first, so no parent outlives to restart or report them. + foreach (array_reverse(self::findDescendants($root)) as $pid) { + posix_kill($pid, SIGTERM); + } + $this->stopped = true; + proc_terminate($this->process); + $this->exitCode = proc_close($this->process); + $this->finish(closed: true); + } + + /** Whether it ended because it was stopped, not on its own. */ + public function wasStopped(): bool + { + return $this->stopped; + } + + /** How many troops a battle test has kept that are not yet taken. */ + public function countKeptTroops(): int + { + return count($this->keptTroops) + ($this->isRunning() ? LaunchTroopHandOver::countKept($this->launch->dataRoot) : 0); + } + + /** + * The troops a battle test has kept since last taken, oldest first, each + * its members ({@see LaunchTroopHandOver}); each is taken once. + * + * @return list>> + */ + public function takeKeptTroops(): array + { + $kept = [...$this->keptTroops, ...($this->isRunning() ? LaunchTroopHandOver::takeKept($this->launch->dataRoot) : [])]; + $this->keptTroops = []; + + return $kept; + } + + /** The end of the play command's output, for saying why a run failed. */ + public function readLogTail(int $lines = 12): string + { + $content = $this->finalLog ?? self::readLog($this->launch->getLogPath()); + $kept = array_slice(preg_split('/\R/', trim($content)) ?: [], -$lines); + + return implode("\n", $kept); + } + + private function finish(bool $closed = false): void + { + if ($this->finished) { + return; + } + $this->finished = true; + if (! $closed) { + proc_close($this->process); + } + $this->finalLog = self::readLog($this->launch->getLogPath()); + // Troops kept in the last moments are taken before the data root goes. + $this->keptTroops = [...$this->keptTroops, ...LaunchTroopHandOver::takeKept($this->launch->dataRoot)]; + $this->launch->destroy(); + } + + private static function readLog(string $path): string + { + return is_file($path) ? (string) file_get_contents($path) : ''; + } + + /** + * Every process below one, parents before their children. + * + * @return list + */ + private static function findDescendants(int $pid): array + { + $children = array_map(intval(...), array_filter(preg_split('/\s+/', trim((string) shell_exec('pgrep -P ' . $pid))) ?: [])); + $found = []; + foreach ($children as $child) { + $found = [...$found, $child, ...self::findDescendants($child)]; + } + + return $found; + } +} diff --git a/src/Playtest/PlaytestStart.php b/src/Playtest/PlaytestStart.php new file mode 100644 index 00000000..0d74b6e3 --- /dev/null +++ b/src/Playtest/PlaytestStart.php @@ -0,0 +1,25 @@ + 'From the selected cell', + self::TITLE => 'From the title', + }; + } +} diff --git a/src/ProjectActor.php b/src/ProjectActor.php index 06413c89..8bbfd935 100644 --- a/src/ProjectActor.php +++ b/src/ProjectActor.php @@ -5,8 +5,17 @@ namespace Ichiloto\Editor; use Ichiloto\Editor\History\TracksPersistedState; -use Ichiloto\Editor\IO\AtomicFile; - +use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; +use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; +use Ichiloto\Editor\Cutscenes\Source\SourceNode; +use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; +use Ichiloto\Editor\Database\PhpDataFile; +use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; + +use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use InvalidArgumentException; use RuntimeException; /** @@ -16,10 +25,19 @@ final class ProjectActor { use TracksPersistedState; + private ?string $establishedDefinitionId; + private readonly bool $loadedWithoutDefinitionId; + private ?string $originalSource = null; + private ?string $writtenSource = null; + /** @var array */ + private readonly array $originalPayload; + /** * The sentinel option meaning "no class reference" in the editor picker. */ public const string CLASS_NONE = 'none'; + /** The attack style of a character with no weapon of their own: the key is absent. */ + public const string ATTACK_STYLE_UNARMED = 'unarmed'; /** * @param array $payload @@ -30,6 +48,10 @@ public function __construct( private array $payload, bool $isDirty = false, ) { + $id = $this->getDefinitionId(); + $this->establishedDefinitionId = $id === '' ? null : $id; + $this->loadedWithoutDefinitionId = ! array_key_exists('id', $this->getData()); + $this->originalPayload = $payload; if (! $isDirty) { // Loaded from disk: the current content is the saved content. $this->captureBaseline(); @@ -50,11 +72,16 @@ public static function fromFile(string $path): self throw new RuntimeException("Unable to parse {$path}."); } - return new self( + $actor = new self( id: pathinfo($path, PATHINFO_FILENAME), path: $path, payload: $payload, ); + $source = file_get_contents($path); + if ($source === false) { throw new RuntimeException("Unable to read {$path}."); } + $actor->originalSource = $source; + $actor->writtenSource = $source; + return $actor; } /** @@ -73,6 +100,7 @@ public static function createBlank(string $path, string $id, string $name): self payload: [ 'class' => 'Ichiloto\\Engine\\Entities\\Character', 'data' => [ + 'id' => $id, 'name' => $name, 'description' => '', 'level' => 1, @@ -154,6 +182,57 @@ public function getClassName(): string return is_string($className) ? $className : ''; } + /** + * Returns the actor's own attack style as written: the weapon type the + * character fights with when no weapon is equipped (their own weapon, + * part of who they are, with no stats), from `data.attackStyle`. + * + * @return string The authored value, or an empty string when they fight unarmed. + */ + public function getAttackStyle(): string + { + $style = $this->getData()['attackStyle'] ?? ''; + + return is_string($style) ? $style : ''; + } + + /** + * Returns the basic skill the actor attacks with, from `data.attackSkill`. + * + * @return string The skill's name, or an empty string for the Engine's built-in attack. + */ + public function getAttackSkill(): string + { + $skill = $this->getData()['attackSkill'] ?? ''; + + return is_string($skill) ? $skill : ''; + } + + /** + * Returns the skill the actor counters with, from `data.counterAttack.skill`. + * + * @return string The skill's name, or an empty string when the actor does not counter. + */ + public function getCounterAttackSkill(): string + { + $skill = $this->getData()['counterAttack']['skill'] ?? ''; + + return is_string($skill) ? $skill : ''; + } + + /** + * Returns the Engine weapon type an attack style names, read by the + * Engine's own rule, or null when it names none. + */ + public static function findAttackStyleType(string $style): ?WeaponType + { + try { + return WeaponType::require($style); + } catch (InvalidArgumentException) { + return null; + } + } + /** * Returns the actor level. * @@ -251,16 +330,23 @@ private static function withAdjustment(array $data, array $segments, int $amount /** * Returns the durable definition id a save resolves this actor by. * - * The engine falls back to the display name when a project has not - * declared one, which is why renaming an actor used to strand a save. - * - * @return string The id, or the name when none is declared. + * @return string The explicit id, or an empty string for an invalid legacy asset. */ public function getDefinitionId(): string { - $id = trim(strval($this->getData()['id'] ?? '')); + $id = $this->getData()['id'] ?? null; + return is_string($id) ? trim($id) : ''; + } - return $id === '' ? $this->getName() : $id; + /** + * Returns the id the runtime knows this actor by: its declared id, or, + * for a legacy asset that declares none, its authored name, which the + * Engine freezes as a provisional id until the identity migration writes + * one. Never the display name of an actor that declares an id. + */ + public function getRuntimeId(): string + { + return array_key_exists('id', $this->getData()) ? $this->getDefinitionId() : trim($this->getName()); } /** @@ -270,7 +356,7 @@ public function getDefinitionId(): string */ public function hasDefinitionId(): bool { - return trim(strval($this->getData()['id'] ?? '')) !== ''; + return $this->getDefinitionId() !== ''; } /** @@ -496,6 +582,21 @@ public function getMagic(): array */ public function setField(string $field, mixed $value): void { + if ($field === 'id') { + $identity = trim((string) $value); + if ($identity === $this->getDefinitionId()) { return; } + if (($this->establishedDefinitionId !== null && $identity !== $this->establishedDefinitionId) || $identity === '') { + throw new RuntimeException('An actor id is permanent. Change the display name, not its identity.'); + } + if ($this->establishedDefinitionId === null + && (! $this->loadedWithoutDefinitionId || $identity !== trim($this->getName()))) { + throw new RuntimeException('A legacy actor repair must freeze its current name as its permanent id.'); + } + $this->establishedDefinitionId = $identity; + } + if ($field === 'name' && ! $this->hasDefinitionId()) { + throw new RuntimeException('Declare the existing actor identity before renaming this actor so references and saves remain valid.'); + } if (! isset($this->payload['data']) || ! is_array($this->payload['data'])) { $this->payload['data'] = []; } @@ -515,6 +616,55 @@ public function setField(string $field, mixed $value): void return; } + if ($field === 'attackStyle') { + $style = trim((string) $value); + + // Unarmed is the absence of a style, so it removes the key; any + // other value is written as the Engine's own spelling, and one + // the Engine does not know is refused before anything changes. + if ($style === '' || strtolower($style) === self::ATTACK_STYLE_UNARMED) { + unset($this->payload['data']['attackStyle']); + } else { + $type = self::findAttackStyleType($style) + ?? throw new RuntimeException(sprintf('The Engine has no weapon type "%s".', $style)); + $this->payload['data']['attackStyle'] = $type->value; + } + + $this->touchState(); + return; + } + + if ($field === 'attackSkill') { + // The built-in attack is the absence of a skill, so it removes + // the key; any other value names a basic skill in the catalogue. + $skill = trim((string) $value); + + if ($skill === '') { + unset($this->payload['data']['attackSkill']); + } else { + $this->payload['data']['attackSkill'] = $skill; + } + + $this->touchState(); + return; + } + + if ($field === 'counterAttack') { + // No counter is the absence of the grant, so it removes the key, + // as the Engine reads an omitted counterAttack; any other value + // names the skill it responds with. + $skill = trim((string) $value); + + if ($skill === '') { + unset($this->payload['data']['counterAttack']); + } else { + $this->payload['data']['counterAttack'] = ['skill' => $skill]; + } + + $this->touchState(); + return; + } + if ($field === 'summons') { // A list of stable summon ids, each at most once, in the order // chosen; an empty list removes the key so the actor reads as @@ -606,14 +756,73 @@ public function setField(string $field, mixed $value): void */ public function save(): void { - $directory = dirname($this->path); + SaveSet::commitOne(dirname($this->path), $this->prepareSave()); + } - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); + /** + * Works out the actor's save without writing it: its file, and the proof + * that the staged file reads back as the edited actor. + */ + public function prepareSave(): PreparedSave + { + $source = $this->getProposedSource(); + // The file keeps its authored key order: the actor holds what it reads back as. + $this->payload = PhpArraySourceDocument::parse($source)->alignValue($this->payload); + + return new PreparedSave( + sprintf('actor %s', $this->id), + fn(FileSetTransaction $transaction) => $transaction->write($this->path, $source), + fn(array $staged) => $this->validateStagedSource($staged[$this->path]), + function () use ($source): void { + $this->writtenSource = $source; + $this->captureBaseline(); + }, + ); + } + + /** Preflights edits without writing or flattening authored PHP. + * @param array|null $data Optional candidate for migration preflight. + */ + public function getProposedSource(?array $data = null): string + { + $current = $this->payload; + if ($data !== null) { $current['data'] = $data; } + $disk = is_file($this->path) ? file_get_contents($this->path) : null; + if ($disk !== null && $disk !== $this->writtenSource) { + throw new RuntimeException("{$this->path} changed outside the editor; reload before saving."); } + if ($this->originalSource !== null) { + $document = PhpArraySourceDocument::parse($this->originalSource); + if ($current !== $this->originalPayload && $document->nodeAt(['data'])?->kind !== SourceNode::ARRAY) { + throw new SourcePreservationRefusal("{$this->path}: Actor data is not an editable array literal; refusing to flatten its source."); + } + return ArraySourceWriter::rewrite($document, $this->originalPayload, $current)->source; + } + return "path)) === 'Actors' ? dirname($this->path, 4) : dirname($this->path); + $evaluated = PhpDataFile::evaluateIsolated($path, $root); + if (PhpDataFile::valueFingerprint($evaluated) !== PhpDataFile::valueFingerprint($this->payload)) { + throw new RuntimeException("{$this->path} would not read back as the edited actor; nothing was written."); + } + } - AtomicFile::write($this->path, $this->buildPersistedPayload()); - $this->captureBaseline(); + /** Restores an authoring snapshot for undo/redo without allowing identity retargeting. + * @param array $data + */ + public function restoreData(array $data): void + { + $id = $data['id'] ?? null; + $isLegacyUndo = $this->loadedWithoutDefinitionId && ! array_key_exists('id', $data); + if (! $isLegacyUndo && $id !== $this->establishedDefinitionId) { + throw new RuntimeException('An actor id is permanent. Undo cannot retarget its identity.'); + } + $this->payload['data'] = $data; + $this->touchState(); } /** @@ -621,10 +830,9 @@ public function save(): void */ protected function buildPersistedPayload(): string { - return " Character::class,\n" - . " 'data' => " . self::exportPhpValue($this->getData(), 1) . ",\n" - . "];\n"; + // Fingerprint the editable state, including unsupported source shapes. + // Source-preservation refusals belong to preflight/save, not dirty checks. + return serialize($this->payload); } /** @@ -632,7 +840,7 @@ protected function buildPersistedPayload(): string * * @return array */ - private function getData(): array + public function getData(): array { $data = $this->payload['data'] ?? []; diff --git a/src/ProjectActorDatabase.php b/src/ProjectActorDatabase.php index f2bff9c8..9eb593e6 100644 --- a/src/ProjectActorDatabase.php +++ b/src/ProjectActorDatabase.php @@ -4,6 +4,10 @@ namespace Ichiloto\Editor; +use Ichiloto\Engine\Entities\Actors\ActorDefinition; +use Ichiloto\Engine\Util\Stores\ActorStore; +use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; use Throwable; use RuntimeException; @@ -72,6 +76,15 @@ public function getActors(): array return array_values($this->actors); } + /** Builds the runtime registry from the same current records used by pickers. */ + public function createActorStore(): ActorStore + { + return new ActorStore(definitions: array_map( + static fn(ProjectActor $actor): ActorDefinition => ActorDefinition::fromArray($actor->getData(), $actor->path), + $this->getActors(), + )); + } + /** * Returns whether the database has unsaved changes. * @@ -95,6 +108,22 @@ public function isDirty(): bool return false; } + /** + * Returns a key that changes whenever an actor is added, removed or + * edited, so a caller can tell whether a step touched the actors + * without reading them. + * + * @return string The key, exact rather than summed: a removal and an + * edit never cancel out to the same key. + */ + public function getContentVersion(): string + { + return implode(',', array_map( + static fn(ProjectActor $actor): string => spl_object_id($actor) . ':' . $actor->getContentVersion(), + $this->getActors(), + )) . '|' . implode(',', $this->pendingDeletions); + } + /** * Returns the actor at the requested index. * @@ -244,6 +273,40 @@ public function save(): void } } + /** + * Works out the actor saves without writing any: the staged deletions, + * then every dirty actor's file, for a change set to install together. + * + * @return list + */ + public function prepareSaves(): array + { + $saves = []; + $deletions = $this->pendingDeletions; + + if ($deletions !== []) { + $saves[] = new PreparedSave( + 'actor deletions', + static function (FileSetTransaction $transaction) use ($deletions): void { + foreach ($deletions as $path) { + $transaction->remove($path); + } + }, + adopt: function (): void { + $this->pendingDeletions = []; + }, + ); + } + + foreach ($this->actors as $actor) { + if ($actor->isDirty()) { + $saves[] = $actor->prepareSave(); + } + } + + return $saves; + } + /** * Resolves the next available actor file id. * @@ -256,8 +319,10 @@ private function getNextAvailableId(string $baseName): string $candidate = $baseId; $suffix = 2; $existingIds = array_map(static fn(ProjectActor $actor): string => $actor->id, $this->actors); + $existingIds = array_map('strtolower', [...$existingIds, + ...array_map(static fn(ProjectActor $actor): string => $actor->getDefinitionId(), $this->actors)]); - while (in_array($candidate, $existingIds, true)) { + while (in_array(strtolower($candidate), $existingIds, true)) { $candidate = $baseId . $suffix; $suffix++; } @@ -295,4 +360,14 @@ private static function humanizeId(string $id): string return trim(str_replace(['_', '-'], ' ', $label)); } + + /** + * Returns the files a save of this database would overwrite: one per actor. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return array_map(static fn(ProjectActor $actor): string => $actor->path, $this->getActors()); + } } diff --git a/src/ProjectAnimationDatabase.php b/src/ProjectAnimationDatabase.php deleted file mode 100644 index 3762b174..00000000 --- a/src/ProjectAnimationDatabase.php +++ /dev/null @@ -1,341 +0,0 @@ -captureBaseline(); - } - } - - /** - * Loads the animation database from the project. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'assets' - . DIRECTORY_SEPARATOR - . 'Data' - . DIRECTORY_SEPARATOR - . 'animations.php'; - - if (! is_file($path)) { - return new self($path, []); - } - - $payload = require $path; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - return new self( - $path, - array_map( - static fn(array $animation): Animation => Animation::fromArray($animation), - array_values(array_filter($payload, 'is_array')) - ), - ); - } - - /** - * Returns the stored animations. - * - * @return Animation[] - */ - public function getAnimations(): array - { - return array_values($this->animations); - } - - /** - * Returns whether the database has unsaved changes. - * - * @return bool - */ - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return " $animation->toArray(), - $this->getAnimations() - )) . ";\n"; - } - - /** - * Returns the animation at the given index. - * - * @param int $index The animation index. - * @return Animation|null - */ - public function getAnimationByIndex(int $index): ?Animation - { - return $this->animations[$index] ?? null; - } - - /** - * Adds a blank animation and returns its index. - * - * @param string $name The new animation name. - * @return int - */ - public function addAnimation(string $name = 'New Animation'): int - { - $nextId = 1; - - foreach ($this->animations as $animation) { - $nextId = max($nextId, $animation->id + 1); - } - - $animation = new Animation( - id: $nextId, - name: $name, - position: AnimationTargetPosition::CENTER, - maxFrames: 5, - ); - $this->animations[] = $animation; - $this->touchState(); - - return count($this->animations) - 1; - } - - /** - * Removes the animation at the given index (rewritten to disk on save, - * so re-inserting at the same index is a complete undo). - * - * @param int $index The animation index. - * @return Animation|null The removed animation, or null when the index is unknown. - */ - public function removeAnimation(int $index): ?Animation - { - $animations = array_values($this->animations); - $animation = $animations[$index] ?? null; - - if (! $animation instanceof Animation) { - return null; - } - - array_splice($animations, $index, 1); - $this->animations = $animations; - $this->touchState(); - - return $animation; - } - - /** - * Re-inserts a previously removed animation (the undo of removeAnimation()). - * - * @param int $index The index to restore the animation at. - * @param Animation $animation The animation to restore. - * @return void - */ - public function insertAnimation(int $index, Animation $animation): void - { - $animations = array_values($this->animations); - $index = max(0, min(count($animations), $index)); - array_splice($animations, $index, 0, [$animation]); - $this->animations = $animations; - $this->touchState(); - } - - /** - * Updates a basic animation field. - * - * @param int $index The animation index. - * @param string $field The field name. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(int $index, string $field, mixed $value): void - { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return; - } - - match ($field) { - 'name' => $animation->name = strval($value), - 'position' => $animation->position = AnimationTargetPosition::fromValue(strval($value)), - 'maxFrames' => $animation->ensureFrameCount(max(1, intval($value))), - default => null, - }; - - $this->touchState(); - } - - /** - * Updates one cell in the selected animation. - * - * @param int $index The animation index. - * @param int $frameIndex The frame number. - * @param int $x The cell x offset. - * @param int $y The cell y offset. - * @param string $symbol The symbol to store. - * @param string|null $color The optional color name. - * @return void - */ - public function setFrameCell( - int $index, - int $frameIndex, - int $x, - int $y, - string $symbol, - ?string $color = null, - ): void { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return; - } - - $animation->setCell($frameIndex, $x, $y, $symbol, $color); - $this->touchState(); - } - - /** - * Replaces the cue attached to one frame. - * - * @param int $index The animation index. - * @param int $frameIndex The frame number. - * @param string $soundEffect The sound-effect cue. - * @param string|null $flashColor The flash color. - * @param int $flashDurationFrames The flash duration. - * @return void - */ - public function setFrameCue( - int $index, - int $frameIndex, - string $soundEffect, - ?string $flashColor, - int $flashDurationFrames, - ): void { - $animation = $this->getAnimationByIndex($index); - - if (! $animation instanceof Animation) { - return; - } - - $animation->setCue( - $frameIndex, - new AnimationCue( - soundEffect: $soundEffect, - flashColor: $flashColor, - flashDurationFrames: $flashDurationFrames, - ), - ); - $this->touchState(); - } - - /** - * Writes the database asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - $this->captureBaseline(); - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } -} diff --git a/src/ProjectClass.php b/src/ProjectClass.php index 509def6c..de539159 100644 --- a/src/ProjectClass.php +++ b/src/ProjectClass.php @@ -4,26 +4,21 @@ namespace Ichiloto\Editor; -use Ichiloto\Editor\History\TracksPersistedState; - /** - * Represents one editable class entry inside the project database. + * A class record as its curves read: the terminal editor's experience and + * stat curve panes chart it. The record itself is edited through the shared + * record database (the `classes` schema); this only reads it, filling what + * the record leaves out the way the engine's ClassStore does. */ -final class ProjectClass +final readonly class ProjectClass { - use TracksPersistedState; - /** * @param array $payload */ public function __construct( - public readonly int $id, + public int $id, private array $payload, - bool $isDirty = false, ) { - if (! $isDirty) { - $this->captureBaseline(); - } } /** @@ -41,60 +36,11 @@ public static function fromArray(array $payload, int $fallbackId): self ); } - /** - * Creates a blank class definition. - * - * @param int $id The class id. - * @param string $name The display name. - * @return self - */ - public static function createBlank(int $id, string $name = 'New Class'): self - { - return new self( - id: $id, - payload: [ - 'id' => $id, - 'name' => $name, - 'description' => '', - 'initialLevel' => 1, - 'maxLevel' => 99, - 'note' => '', - 'traits' => [], - 'experienceCurve' => [ - 'baseValue' => 30, - 'extraValue' => 20, - 'accelerationA' => 30, - 'accelerationB' => 30, - ], - 'parameterCurves' => [ - 'totalHp' => ['baseValue' => 120, 'extraGrowth' => 500, 'flatIncrement' => 40], - 'totalMp' => ['baseValue' => 12, 'extraGrowth' => 100, 'flatIncrement' => 10], - 'attack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], - 'defence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], - 'magicAttack' => ['baseValue' => 10, 'extraGrowth' => 50, 'flatIncrement' => 1], - 'magicDefence' => ['baseValue' => 10, 'extraGrowth' => 30, 'flatIncrement' => 1], - 'speed' => ['baseValue' => 10, 'extraGrowth' => 20, 'flatIncrement' => 1], - 'grace' => ['baseValue' => 10, 'extraGrowth' => 15, 'flatIncrement' => 1], - 'evasion' => ['baseValue' => 5, 'extraGrowth' => 10, 'flatIncrement' => 1], - ], - ], - isDirty: true, - ); - } - /** * Returns whether the class has unsaved changes. * * @return bool */ - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return serialize($this->toArray()); - } - /** * Returns the class name. * @@ -225,92 +171,4 @@ public function getParameterCurves(): array ]; } - /** - * Updates one editable class field. - * - * @param string $field The field identifier. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(string $field, mixed $value): void - { - if (in_array($field, ['name', 'description', 'note'], true)) { - $this->payload[$field] = (string) $value; - $this->touchState(); - return; - } - - if ($field === 'initialLevel') { - $this->payload['initialLevel'] = max(1, (int) $value); - $this->payload['maxLevel'] = max( - (int) ($this->payload['maxLevel'] ?? 99), - (int) $this->payload['initialLevel'], - ); - $this->touchState(); - return; - } - - if ($field === 'maxLevel') { - $this->payload['maxLevel'] = max($this->getInitialLevel(), (int) $value); - $this->touchState(); - return; - } - - $experienceMap = [ - 'expBaseValue' => 'baseValue', - 'expExtraValue' => 'extraValue', - 'expAccelerationA' => 'accelerationA', - 'expAccelerationB' => 'accelerationB', - ]; - - if (isset($experienceMap[$field])) { - if (! isset($this->payload['experienceCurve']) || ! is_array($this->payload['experienceCurve'])) { - $this->payload['experienceCurve'] = []; - } - - $this->payload['experienceCurve'][$experienceMap[$field]] = max(0, (int) $value); - $this->touchState(); - return; - } - - $parameterMap = [ - 'totalHpBaseValue' => 'totalHp', - 'totalMpBaseValue' => 'totalMp', - 'attackBaseValue' => 'attack', - 'defenceBaseValue' => 'defence', - 'magicAttackBaseValue' => 'magicAttack', - 'magicDefenceBaseValue' => 'magicDefence', - 'speedBaseValue' => 'speed', - 'graceBaseValue' => 'grace', - 'evasionBaseValue' => 'evasion', - ]; - - if (isset($parameterMap[$field])) { - if (! isset($this->payload['parameterCurves']) || ! is_array($this->payload['parameterCurves'])) { - $this->payload['parameterCurves'] = []; - } - - $curveKey = $parameterMap[$field]; - - if (! isset($this->payload['parameterCurves'][$curveKey]) || ! is_array($this->payload['parameterCurves'][$curveKey])) { - $this->payload['parameterCurves'][$curveKey] = []; - } - - $this->payload['parameterCurves'][$curveKey]['baseValue'] = max(0, (int) $value); - $this->touchState(); - } - } - - /** - * Returns the normalized payload for persistence. - * - * @return array - */ - public function toArray(): array - { - $payload = $this->payload; - $payload['id'] = $this->id; - - return $payload; - } } diff --git a/src/ProjectClassDatabase.php b/src/ProjectClassDatabase.php deleted file mode 100644 index 5947b0da..00000000 --- a/src/ProjectClassDatabase.php +++ /dev/null @@ -1,280 +0,0 @@ -captureBaseline(); - } - } - - /** - * Loads the class database from the project. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'assets' - . DIRECTORY_SEPARATOR - . 'Data' - . DIRECTORY_SEPARATOR - . 'classes.php'; - - if (! is_file($path)) { - return new self($path, []); - } - - $payload = require $path; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - $classes = []; - - foreach (array_values(array_filter($payload, 'is_array')) as $index => $classPayload) { - $classes[] = ProjectClass::fromArray($classPayload, $index + 1); - } - - return new self($path, $classes); - } - - /** - * Returns the stored class records. - * - * @return ProjectClass[] - */ - public function getClasses(): array - { - return array_values($this->classes); - } - - /** - * Returns whether the database has unsaved changes. - * - * @return bool - */ - - /** - * Returns the class at the requested index. - * - * @param int $index The class index. - * @return ProjectClass|null - */ - public function getClassByIndex(int $index): ?ProjectClass - { - return $this->classes[$index] ?? null; - } - - /** - * Adds a blank class entry and returns its index. - * - * @param string $name The default class name. - * @return int - */ - public function addClass(string $name = 'New Class'): int - { - $nextId = 1; - - foreach ($this->classes as $class) { - $nextId = max($nextId, $class->id + 1); - } - - $this->classes[] = ProjectClass::createBlank($nextId, $name); - $this->touchState(); - - return count($this->classes) - 1; - } - - /** - * Removes the class at the given index. - * - * The database asset is only rewritten on save, so undo (re-insert at - * the same index) fully restores the entry. - * - * @param int $index The class index. - * @return ProjectClass|null The removed class, or null when the index is unknown. - */ - public function removeClass(int $index): ?ProjectClass - { - $classes = array_values($this->classes); - $class = $classes[$index] ?? null; - - if (! $class instanceof ProjectClass) { - return null; - } - - array_splice($classes, $index, 1); - $this->classes = $classes; - $this->touchState(); - - return $class; - } - - /** - * Re-inserts a previously removed class (the undo of removeClass()). - * - * @param int $index The index to restore the class at. - * @param ProjectClass $class The class to restore. - * @return void - */ - public function insertClass(int $index, ProjectClass $class): void - { - $classes = array_values($this->classes); - $index = max(0, min(count($classes), $index)); - array_splice($classes, $index, 0, [$class]); - $this->classes = $classes; - $this->touchState(); - } - - /** - * Updates one class field. - * - * @param int $index The class index. - * @param string $field The field identifier. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(int $index, string $field, mixed $value): void - { - $class = $this->getClassByIndex($index); - - if (! $class instanceof ProjectClass) { - return; - } - - $class->setField($field, $value); - $this->touchState(); - } - - /** - * Writes the database asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - $this->captureBaseline(); - } - - /** - * @inheritDoc - */ - protected function dependencyVersion(): string - { - $versions = []; - - foreach ($this->classes as $class) { - $versions[] = $class->stateVersion(); - } - - return count($this->classes) . ':' . implode(',', $versions); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return " $class->toArray(), - $this->getClasses() - )) . ";\n"; - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } -} diff --git a/src/ProjectConfig.php b/src/ProjectConfig.php new file mode 100644 index 00000000..b81d958d --- /dev/null +++ b/src/ProjectConfig.php @@ -0,0 +1,200 @@ + FieldViewport::DEFAULT_ZOOM, self::INN_PRESENTATION => null, + self::SAVE_POINT_SCRIPT => null, self::ALWAYS_DASH => false]; + + /** + * Settings that name another resource, by path, with the reference kind + * ({@see \Ichiloto\Editor\Database\ReferenceCatalog}) they are chosen from. + */ + public const array ENGINE_REFERENCES = [self::INN_PRESENTATION => 'stage_timelines', self::SAVE_POINT_SCRIPT => 'common_events']; + + /** Settings under these paths each name one audio file, chosen from the project's own folder for that kind. */ + private const array ENGINE_REFERENCE_PREFIXES = ['audio.sounds.' => 'sfx', 'audio.bgm.' => 'bgm']; + + /** The reference kind a setting is chosen from, or null when it is written as a plain value. */ + public static function getEngineReference(string $path): ?string + { + if (isset(self::ENGINE_REFERENCES[$path])) { + return self::ENGINE_REFERENCES[$path]; + } + foreach (self::ENGINE_REFERENCE_PREFIXES as $prefix => $reference) { + if (str_starts_with($path, $prefix) && strlen($path) > strlen($prefix)) { + return $reference; + } + } + + return null; + } + + public function __construct(string $projectRoot) + { + parent::__construct($projectRoot, 'config.php'); + } + + /** + * A shared display term as this project words it in its own file (`vocab.`), else the Engine's + * wording ({@see Vocabulary::DEFAULTS}). A blank term keeps the Engine's, as a label cannot be blank + * ({@see Vocabulary::getLabel()}). + */ + public function getTermLabel(string $path): string + { + $term = $this->getRecord('vocab.' . $path)->get('value'); + $term = is_string($term) ? trim($term) : ''; + + return $term !== '' ? $term : (Vocabulary::DEFAULTS[$path] ?? ''); + } + + /** + * The settings the engine reads with a default, then every message the Engine says, by its path in config.php, + * with its default, what it accepts and what it is for: each is offered even where the project leaves it out, + * so any game can reword it. + */ + protected function listEngineSettings(): array + { + $settings = array_map(static fn(mixed $default): array => ['default' => $default], self::ENGINE_DEFAULTS); + // The volumes the game starts at: the master volume, and beneath it each bus's (Music, Sound Effects, Voice). + // The player changes them in Options and Config; their choices stay in player settings. + $settings[AudioManager::CONFIG_MASTER_VOLUME] = ['default' => AudioManager::DEFAULT_MASTER_VOLUME]; + foreach (AudioBus::cases() as $bus) { + $settings[$bus->getVolumeConfigPath()] = ['default' => AudioBus::DEFAULT_VOLUME]; + } + // Every sound the Engine plays for itself is offered, so a game can choose one it has not set yet, + // with its own volume beside it, so a dense sound can sit under the music. + foreach (SystemSound::cases() as $sound) { + $settings[$sound->getConfigPath()] = ['default' => null]; + $settings[$sound->getVolumeConfigPath()] = ['default' => SystemSound::DEFAULT_VOLUME]; + } + // How long each action category's announcement is read at each battle animation pace, apart from the rest of + // its turn. The Engine's time is shown to the millisecond; choosing it writes nothing, so the Engine keeps it. + foreach (BattleActionCategory::cases() as $category) { + foreach (BattlePace::cases() as $pace) { + $settings[$category->getAnnouncementConfigPath($pace)] = [ + 'default' => round($category->getAnnouncementSeconds($pace), 3), + 'description' => sprintf('Seconds a %s announcement is read at %s battle animation pace, from %g to %g.', + str_replace('_', ' ', $category->value), $pace->value, + BattlePacing::MIN_ANNOUNCEMENT_SECONDS, BattlePacing::MAX_ANNOUNCEMENT_SECONDS), + ]; + } + } + // The Engine's own wording for terms many screens share, such as the level in rows ("Lv"), is offered + // even where the project leaves it out, so a game rewords it once. + foreach (Vocabulary::DEFAULTS as $path => $default) { + $settings['vocab.' . $path] = ['default' => $default]; + } + foreach (EngineMessageTerms::listTerms() as $path => $term) { + // A message that inherits the project's own vocabulary wording reads as that wording here, as the game + // reads it, taken from this project's file, never from another project's loaded runtime state. + $inherited = isset($term['vocabularyDefault']) ? $this->readValue($this->payload, 'vocab.' . $term['vocabularyDefault']) : null; + if (is_string($inherited)) { + $term['default'] = $inherited; + } + $settings['messages.' . $path] = ['default' => $term['default'], 'placeholders' => $term['placeholders'], + 'description' => $term['description']]; + } + + return $settings; + } + + /** + * Refuses a value the engine would not accept at a path, before anything + * changes: a field zoom outside its range, a master, bus or system sound + * volume outside its range, an inn presentation that is not a timeline + * identity, an Always Dash that is not true or false, or a battle + * announcement reading time outside its range. + * + * @throws \InvalidArgumentException + */ + public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void + { + if ($path === self::FIELD_ZOOM && (! is_int($value) && ! is_float($value) + || ! is_finite((float) $value) || $value < FieldViewport::MIN_ZOOM || $value > FieldViewport::MAX_ZOOM)) { + throw new \InvalidArgumentException(sprintf('Field zoom must be a finite number from %g to %g.', FieldViewport::MIN_ZOOM, FieldViewport::MAX_ZOOM)); + } + if (self::isVolume($path) && (! is_int($value) || $value < AudioManager::MIN_VOLUME || $value > AudioManager::MAX_VOLUME)) { + throw new \InvalidArgumentException(sprintf('A volume must be a whole number from %d to %d.', + AudioManager::MIN_VOLUME, AudioManager::MAX_VOLUME)); + } + if ($path === self::INN_PRESENTATION && $value !== null) { + InnPresentationFields::assertValid($value, complete: $complete); + } + if ($path === self::ALWAYS_DASH && ! is_bool($value)) { + throw new \InvalidArgumentException('Always Dash must be true or false.'); + } + if (self::isAnnouncementSeconds($path) && (! is_int($value) && ! is_float($value) || ! is_finite((float) $value) + || $value < BattlePacing::MIN_ANNOUNCEMENT_SECONDS || $value > BattlePacing::MAX_ANNOUNCEMENT_SECONDS)) { + throw new \InvalidArgumentException(sprintf('An announcement is read for a finite number of seconds from %g to %g.', + BattlePacing::MIN_ANNOUNCEMENT_SECONDS, BattlePacing::MAX_ANNOUNCEMENT_SECONDS)); + } + } + + /** Whether a path holds the seconds an action category's battle announcement is read at one pace. */ + private static function isAnnouncementSeconds(string $path): bool + { + return array_any(BattleActionCategory::cases(), static fn(BattleActionCategory $category): bool => + array_any(BattlePace::cases(), static fn(BattlePace $pace): bool => $path === $category->getAnnouncementConfigPath($pace))); + } + + /** Whether a path holds a volume the Engine reads: the master volume, a bus's or a system sound's own. */ + private static function isVolume(string $path): bool + { + return $path === AudioManager::CONFIG_MASTER_VOLUME + || array_any(AudioBus::cases(), static fn(AudioBus $bus): bool => $path === $bus->getVolumeConfigPath()) + || array_any(SystemSound::cases(), static fn(SystemSound $sound): bool => $path === $sound->getVolumeConfigPath()); + } + + /** The inn's rest presentation is one setting, a whole timeline binding. */ + protected function isWholeValue(string $path): bool + { + return $path === self::INN_PRESENTATION; + } + + /** One picker key replacement keeps the binding's authored comments and value span. */ + protected function listKeyRenames(string $path, mixed $old, mixed $new): array + { + if (! is_array($old) || ! is_array($new) || ($old['treatment'] ?? null) !== 'leader' || ($new['treatment'] ?? null) !== 'leader' + || ! is_array($old['leaders'] ?? null) || ! is_array($new['leaders'] ?? null)) { return []; } + $before = array_keys($old['leaders']); + $after = array_keys($new['leaders']); + if (count($before) !== count($after)) { return []; } + $renames = []; + foreach ($before as $index => $key) { + if ($key === $after[$index]) { continue; } + if (array_key_exists($key, $new['leaders']) || array_key_exists($after[$index], $old['leaders'])) { return []; } + $renames[] = ['path' => ['graphics', 'inn', 'presentation', 'leaders', $key], 'key' => $after[$index]]; + } + return $renames; + } +} diff --git a/src/ProjectMap.php b/src/ProjectMap.php index ac24e75f..70a0a2eb 100644 --- a/src/ProjectMap.php +++ b/src/ProjectMap.php @@ -4,11 +4,12 @@ namespace Ichiloto\Editor; +use Ichiloto\Editor\Database\ProjectCatalogs; +use Ichiloto\Editor\Events\EventTypeCatalog; use Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter; use Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument; use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; -use Ichiloto\Editor\Database\IsolatedPhpEvaluationFailure; use Ichiloto\Editor\Database\PhpDataFile; use Ichiloto\Editor\Field\NpcCollection; use Ichiloto\Editor\Field\ProjectNpc; @@ -17,6 +18,30 @@ use Ichiloto\Editor\Storage\FileSetTransactionFailure; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; +use Ichiloto\Editor\Storage\PreparedSave; +use Ichiloto\Editor\Storage\SaveSet; +use Ichiloto\Engine\Core\CellArea; +use Ichiloto\Engine\Field\MapGraphics; +use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapLayer; +use Ichiloto\Engine\Field\MapLayerSource; +use Ichiloto\Engine\Field\MapLayerSet; +use Ichiloto\Engine\Rendering\Presentation\PresentationLayerPolicy; +use Ichiloto\Engine\Field\MapCollisionResolver; +use Ichiloto\Engine\Field\MapEncounterTerrain; +use Ichiloto\Engine\Field\MapPhysicalOccupancy; +use Ichiloto\Engine\Field\MapTerrainKinds; +use Ichiloto\Engine\Events\Enumerations\CollisionType; +use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\IO\Console\SgrStyleState; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; +use Ichiloto\Engine\Rendering\Tilesets\TilesetPiece; +use Ichiloto\Editor\Maps\EditableGrid; +use Ichiloto\Editor\Maps\MapFolder; +use Ichiloto\Editor\Maps\MapLayers; +use Ichiloto\Editor\Maps\TileLayerSettings; +use Closure; +use InvalidArgumentException; use RuntimeException; /** @@ -24,16 +49,11 @@ */ final class ProjectMap { + use \Ichiloto\Editor\Field\NpcSpriteArt; use TracksPersistedState; - /** - * @var array> - */ - private array $tileCells; - /** - * @var array> - */ - private array $eventCells; + private MapLayers $layers; + /** * @var array */ @@ -56,6 +76,9 @@ final class ProjectMap */ private ?string $dataSourceIssue = null; + /** The catalogues this map's content names, as its workspace holds them; null for a map read on its own. */ + private ?ProjectCatalogs $catalogs = null; + /** * The data file's raw bytes when its source cannot be parsed for * preservation, so a grid-only save still knows the data member is @@ -64,10 +87,13 @@ final class ProjectMap private ?string $unparsedDataSource = null; /** - * @var array The data array the document corresponds to: - * what the file held at load, or at the last successful save. + * @var array The data at the last load or successful save, + * used to validate changes against the saved checkpoint. */ private array $loadedData; + /** @var array The values of the retained authored source document. */ + private array $sourceData; + private string $baselineDataSource; /** * The tile payload as of the last load or save, so a save can tell a @@ -96,12 +122,18 @@ public function __construct( public readonly array $tileLines, public readonly array $eventLines, ?string $dataSource = null, + private readonly ?string $gridSourceIssue = null, + ?MapLayers $layers = null, ) { $this->editableData = $data; - $this->tileCells = self::parseStyledLines($tileLines); - $this->eventCells = self::parsePlainLines($eventLines); + $this->layers = $layers ?? new MapLayers($directory, true, [[ + 'id' => MapLayers::BASE, 'name' => 'terrain', 'order' => 0, 'decoration' => false, + 'path' => $mapPath, 'grid' => new EditableGrid(implode("\n", $tileLines), legacyTags: true), + ]], new EditableGrid(implode("\n", $eventLines)), $eventPath); $this->loadedData = $data; + $this->sourceData = $data; $this->adoptDataSource($dataSource ?? "baselineDataSource = $this->dataDocument?->source ?? (string) $this->unparsedDataSource; $this->baselineMapPayload = $this->buildMapPayload(); $this->baselineEventPayload = $this->buildEventPayload(); } @@ -140,144 +172,1222 @@ public static function fromDirectory(string $mapsRoot, string $directory): self $mapPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php'; $eventPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php'; - if (! is_file($dataPath) || ! is_file($mapPath) || ! is_file($eventPath)) { + if (! is_file($dataPath) || (! is_file($mapPath) && ! is_dir($directory . '/layers')) || ! is_file($eventPath)) { throw new RuntimeException("Map files are incomplete in {$directory}."); } - $data = require $dataPath; - $mapText = require $mapPath; - $eventText = require $eventPath; + $relativeDirectory = substr($directory, strlen($mapsRoot) + 1); + $mapId = str_replace(DIRECTORY_SEPARATOR, '/', $relativeDirectory); + + // Grid PHP is data, not executable map-building code. Refuse it before + // evaluating even the separate data member of an incomplete map. + try { + $set = MapLayerSource::loadFromDirectory($directory, $mapId); + $eventText = self::readGridSource($eventPath, $mapId); + if (! $set->legacy) { + $set->assertMatchingGrid(MapLayer::parseGrid($eventText), $mapId . '/' . basename($eventPath)); + } + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + $mapText = $set->layers[0]->text; + $data = require $dataPath; + + if (! is_array($data)) { + throw new RuntimeException("Map files could not be parsed in {$directory}."); + } + + return new self( + mapId: $mapId, + directory: $directory, + dataPath: $dataPath, + mapPath: $mapPath, + eventPath: $eventPath, + data: $data, + tileLines: self::splitMapText($mapText), + eventLines: self::splitMapText($eventText), + dataSource: (string) file_get_contents($dataPath), + layers: MapLayers::createFromSource($directory, $set, $eventPath, $eventText), + )->withLoadedBaseline(); + } + + /** Keeps an invalid map discoverable without evaluating or rewriting its files. */ + public static function createReadOnlyFromDirectory(string $mapsRoot, string $directory, string $issue): self + { + $baseName = basename($directory); + $mapId = str_replace(DIRECTORY_SEPARATOR, '/', substr($directory, strlen($mapsRoot) + 1)); + + return new self( + mapId: $mapId, + directory: $directory, + dataPath: $directory . DIRECTORY_SEPARATOR . $baseName . '.data.php', + mapPath: $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php', + eventPath: $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php', + data: [], + tileLines: [], + eventLines: [], + gridSourceIssue: $issue, + )->withLoadedBaseline(); + } + + public function getGridSourceIssue(): ?string + { + return $this->gridSourceIssue; + } + + private function assertEditable(): void + { + if ($this->gridSourceIssue !== null) { + throw new MapSourceRefusal("{$this->mapId} is read-only: {$this->gridSourceIssue}"); + } + $this->assertSourcesUnchanged(); + } + + /** Refuses external source edits without comparing against unsaved internal changes. */ + public function assertSourcesUnchanged(): void + { + $this->assertGridSourcesCanonical(); + try { + $this->createSourceTransaction($this->directory)->assertSourcesUnchanged(); + } catch (FileSetTransactionFailure $failure) { + throw new MapSourceRefusal($failure->getMessage() . '. Nothing was changed.', previous: $failure); + } + } + + /** Reads one canonical grid, refusing executable or generated source. */ + private static function readGridSource(string $path, string $mapId): string + { + try { + return MapGridSource::readFile($path, $mapId . '/' . basename($path)); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(sprintf( + '%s The map cannot be loaded or saved. Repair it as a literal nowdoc, then retry; nothing was changed.', + $error->getMessage(), + ), previous: $error); + } + } + + /** Refuses grid sources changed on disk after this map was opened. */ + private function assertGridSourcesCanonical(): void + { + try { + $this->layers->assertSourcesUnchanged(); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was written.', previous: $error); + } + } + + private function createSourceTransaction( + string $directory, + ?FileSetOperations $files = null, + bool $reserveFolder = false, + ): FileSetTransaction { + $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations(), $reserveFolder); + $this->expectLoadedSources($transaction); + return $transaction; + } + + /** Requires the map's members to hold what it loaded. */ + private function expectLoadedSources(FileSetTransaction $transaction): void + { + // Missing data can be recovered from the loaded source. An existing + // file must still match it; the transaction captures metadata before + // reading, so a failed move restores the original access time too. + $transaction->expectSource($this->dataPath, $this->baselineDataSource, allowMissing: true); + $this->layers->expectSources($transaction); + } + + /** + * Adopts the just-loaded content as the saved baseline. + * + * @return self This map. + */ + private function withLoadedBaseline(): self + { + $this->captureBaseline(); + + return $this; + } + + /** + * Returns the display name for the map. + * + * @return string + */ + public function getDisplayName(): string + { + return (string) ($this->editableData['name'] ?? basename($this->directory)); + } + + /** + * Returns the display region for the map. + * + * @return string + */ + public function getRegion(): string + { + return (string) ($this->editableData['region'] ?? 'Unknown'); + } + + /** + * The region the map's author gave it, empty when none was given. + */ + public function getAuthoredRegion(): string + { + return trim((string) ($this->editableData['region'] ?? '')); + } + + /** + * Returns the map description. + * + * @return string + */ + public function getDescription(): string + { + return (string) ($this->editableData['description'] ?? ''); + } + + /** + * Returns the number of tile rows. + * + * @return int + */ + public function getHeight(): int + { + return count($this->layers->getBaseGrid()->cells); + } + + /** + * Returns the widest tile row after formatting tags are stripped. + * + * @return int + */ + public function getWidth(): int + { + if ($this->cachedWidth !== null) { + return $this->cachedWidth; + } + + $width = 0; + + foreach ($this->layers->getBaseGrid()->cells as $row) { + $width = max($width, count($row)); + } + + return $this->cachedWidth = $width; + } + + /** + * Returns whether the map has unsaved changes. + * + * @return bool + */ + + + /** + * Returns the number of declared event definitions. + * + * @return int + */ + public function getEventDefinitionCount(): int + { + $events = $this->editableData['events'] ?? []; + + return is_array($events) ? count($events) : 0; + } + + /** + * Returns the number of configured triggers. + * + * @return int + */ + public function getTriggerCount(): int + { + $triggers = $this->editableData['triggers'] ?? []; + + return is_array($triggers) ? count($triggers) : 0; + } + + /** + * Returns the editable map data. + * + * @return array + */ + public function getEditableData(): array + { + return $this->editableData; + } + + /** + * The cells an event triggers on as the Engine resolves them, unsaved + * edits included: its painted marker cells, or its bound subject's cells. + * Null when the resolver gives it none; validation says why. + */ + public function getEventTriggerArea(string $marker): ?CellArea + { + $events = \Ichiloto\Engine\Field\Validation\MapEventResolver::inspect($this->editableData, $this->getEventLayerCells(), $this->mapId)->events; + $event = array_find($events, static fn(array $event): bool => ($event['marker'] ?? null) === $marker); + + return is_array($event['area'] ?? null) ? CellArea::fromArray($event['area'], "Event {$marker} area") : null; + } + + /** + * Reads the catalogues this map's content names (its tileset, the field + * resources its world objects use) through its workspace, unsaved edits + * included ({@see ProjectCatalogs}). + */ + public function useCatalogs(ProjectCatalogs $catalogs): void + { + $this->catalogs = $catalogs; + } + + /** + * The field presentation this map's world objects name resources from: its + * workspace's, as that holds it, or the project's file for a map read on + * its own. + */ + public function getFieldCatalog(): \Ichiloto\Engine\Animations\Field\FieldPresentationCatalog + { + return $this->catalogs?->loadFieldPresentation() + ?? \Ichiloto\Engine\Animations\Field\FieldPresentationCatalog::load($this->getAssetRoot()); + } + + /** + * The id of every tileset this map could name: its workspace's, as the + * Tilesets page holds them, or the project's files for a map read on its + * own. + * + * @return list + */ + public function listTilesetIds(): array + { + return $this->catalogs?->listTilesetIds() + ?? array_map(static fn(string $file): string => basename($file, '.php'), + glob($this->getAssetRoot() . '/' . Tileset::DIRECTORY . '/*.php') ?: []); + } + + /** + * The place categories this map's atlas and landmarks name: its + * workspace's, as the Place Categories page holds them, or the project's + * file for a map read on its own. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadMapCategories(): \Ichiloto\Engine\Field\MapCategoryCatalog + { + return $this->catalogs?->loadMapCategories() + ?? \Ichiloto\Engine\Field\MapCategoryCatalog::load($this->getAssetRoot()); + } + + /** + * The map roles this map's role and its destinations' roles name: its workspace's, as the Map Roles page + * holds them, or the project's file for a map read on its own. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadMapRoles(): \Ichiloto\Engine\Field\MapRoleCatalog + { + return $this->catalogs?->loadMapRoles() + ?? \Ichiloto\Engine\Field\MapRoleCatalog::load($this->getAssetRoot()); + } + + /** + * The place types this map's type and its entrances' destinations name: its workspace's, as the Place Types + * page holds them, or the project's file for a map read on its own. + * + * @throws InvalidArgumentException With the Engine's reason when they are refused. + */ + public function loadPlaceTypes(): \Ichiloto\Engine\Field\PlaceTypeCatalog + { + return $this->catalogs?->loadPlaceTypes() + ?? \Ichiloto\Engine\Field\PlaceTypeCatalog::load($this->getAssetRoot()); + } + + public function getLayers(): array + { + return array_map(static function (array $layer): array { + unset($layer['grid']); + return $layer; + }, $this->layers->getLayers()); + } + + public function getBaseLayerId(): string + { + return $this->layers->getBaseId(); + } + + public function isLegacyMap(): bool + { + return $this->layers->legacy; + } + + public function getLayerSymbol(string $layer, int $x, int $y): string + { + return $this->layers->getGrid($layer)->cells[$y][$x]['symbol'] ?? ' '; + } + + public function hasLayerCell(string $layer, int $x, int $y): bool + { + return isset($this->layers->getGrid($layer)->cells[$y][$x]); + } + + public function getLayerCellStyle(string $layer, int $x, int $y): array + { + $cell = $this->layers->getGrid($layer)->cells[$y][$x] ?? []; + return ['prefix' => $cell['prefix'] ?? '', 'suffix' => $cell['suffix'] ?? '']; + } + + public function getLayerColor(string $layer, int $x, int $y): ?string + { + return self::getInnermostForeground($this->getLayerCellStyle($layer, $x, $y)['prefix']); + } + + public function setLayerCell(string $layer, int $x, int $y, string $symbol, string $prefix = '', string $suffix = ''): void + { + $this->setLayerCells($layer, [['x' => $x, 'y' => $y, 'symbol' => $symbol, 'prefix' => $prefix, 'suffix' => $suffix]]); + } + + /** + * Sets many cells of one layer as one edit. The map's sources are checked + * against what it opened once for the whole edit, so a paint, a conversion + * or an undo of thousands of cells costs one check, as one cell does. + * Cells outside the layer are skipped. + * + * @param iterable $cells + * @throws MapSourceRefusal When the map is read-only or its sources changed on disk; nothing is set. + */ + public function setLayerCells(string $layer, iterable $cells): void + { + $this->assertEditable(); + $grid = $this->layers->getGrid($layer); + $changed = false; + foreach ($cells as $cell) { + ['x' => $x, 'y' => $y] = $cell; + if (isset($grid->cells[$y][$x])) { + $grid->cells[$y][$x] = ['symbol' => self::normalizeSymbol($cell['symbol']), 'prefix' => $cell['prefix'] ?? '', + 'suffix' => $cell['suffix'] ?? '']; + $changed = true; + } + } + if ($changed) { + $this->touchState(); + } + } + + public function createLayer(string $name, bool $decoration = false, ?int $order = null): string + { + $id = ''; + $this->changeLayers(static function (MapLayers $layers) use ($name, $decoration, $order, &$id): void { + $id = $layers->createLayer($name, $decoration, $order); + $layers->assertDimensions(); + }); + + return $id; + } + + /** + * Renames a layer. The tile layers that move with a gameplay layer keep + * moving with it under its new name. + * + * @throws MapSourceRefusal When the rename is refused, or changes collisions unconfirmed. + */ + public function renameLayer(string $id, string $name, bool $confirmCollisionChange = false): void + { + if (! $confirmCollisionChange) { + $this->assertCollisionsUnchanged($this->countRenameCollisionChanges($id, $name), 'Renaming'); + } + $layer = $this->layers->getLayer($id); + $this->changeLayers(static fn(MapLayers $layers) => $layers->renameLayer($id, $name), + static fn(mixed $settings): mixed => $layer['decoration'] ? $settings : TileLayerSettings::renameOwner($settings, $layer['name'], $name), + static function (array $objects) use ($layer, $name): array { + foreach ($objects as &$object) { + if (($object['covers']['layer'] ?? null) === $layer['name']) { $object['covers']['layer'] = $name; } + } + return $objects; + }); + } + + /** + * Moves a layer to another order; a layer holding that order takes this + * layer's order. Gameplay order decides which glyph sets collision. + * + * @throws MapSourceRefusal When the move is refused, or changes collisions unconfirmed. + */ + public function moveLayer(string $id, int $order, bool $confirmCollisionChange = false): void + { + if (! $confirmCollisionChange) { + $this->assertCollisionsUnchanged($this->countMoveCollisionChanges($id, $order), 'Moving'); + } + $this->changeLayers(static fn(MapLayers $layers) => $layers->moveLayer($id, $order)); + } + + /** + * Makes a layer decoration, drawn without collision, or gameplay. Tile + * layers that moved with a layer made decoration move with none. + * + * @throws MapSourceRefusal When the change is refused, or changes collisions unconfirmed. + */ + public function setLayerDecoration(string $id, bool $decoration, bool $confirmCollisionChange = false): void + { + if (! $confirmCollisionChange) { + $this->assertCollisionsUnchanged($this->countDecorationCollisionChanges($id, $decoration), 'Changing'); + } + $layer = $this->layers->getLayer($id); + $this->changeLayers(static fn(MapLayers $layers) => $layers->setLayerDecoration($id, $decoration), + static fn(mixed $settings): mixed => $decoration && ! $layer['decoration'] ? TileLayerSettings::removeOwner($settings, $layer['name']) : $settings); + } + + /** + * Removes a layer and its cells. Tile layers that moved with a removed + * gameplay layer move with none. + * + * @throws MapSourceRefusal When the removal is refused. + */ + public function removeLayer(string $id): void + { + $layer = $this->layers->getLayer($id); + $this->changeLayers(static fn(MapLayers $layers) => $layers->removeLayer($id), + static fn(mixed $settings): mixed => $layer['decoration'] ? $settings : TileLayerSettings::removeOwner($settings, $layer['name'])); + } + + /** + * Applies a change to the layers, and to the tile layer settings that + * name them, as one change: it is made on a copy of the layers that is + * adopted only once the settings are written, so a refusal from either + * leaves the map exactly as it was. + * + * @param Closure(MapLayers): mixed $change + * @param (Closure(mixed): mixed)|null $adjustSettings The tile layer settings the change leaves, given the current ones. + * @throws MapSourceRefusal When the change or the settings are refused. + */ + private function changeLayers(Closure $change, ?Closure $adjustSettings = null, ?Closure $adjustObjects = null): void + { + $this->assertEditable(); + $layers = clone $this->layers; + $change($layers); + $data = $this->editableData; + if ($adjustSettings !== null) { + $settings = $this->getMapDataField([MapGraphics::SETTINGS_KEY]); + $next = $adjustSettings($settings); + if ($next !== $settings) { + if ($next === null) { unset($data[MapGraphics::SETTINGS_KEY]); } + else { $data[MapGraphics::SETTINGS_KEY] = $next; } + } + } + if ($adjustObjects !== null && isset($data['worldObjects'])) { + $data['worldObjects'] = $adjustObjects($data['worldObjects']); + if ($data['worldObjects'] !== $this->editableData['worldObjects']) { + $issue = \Ichiloto\Editor\Field\WorldObjectAuthoring::readSourceIssue($this); + if ($issue !== null) { throw new MapSourceRefusal($issue); } + } + } + $this->assertWorldObjectState($layers, $data); + $this->writeData($data); + $this->layers = $layers; + $this->cachedWidth = null; + $this->touchState(); + } + + public function getLayerSet(): MapLayerSet + { + return $this->layers->getLayerSet(); + } + + /** + * Each glyph layer's presentation layer id, keyed by the editor's layer id, so an interface can name the layers of + * the composed world as it names the map's own. + * + * @return array + */ + public function getPresentationLayerIds(): array + { + $glyphLayers = array_values(array_filter($this->getLayers(), static fn(array $layer): bool => $layer['id'] !== MapLayers::EVENT)); + $ids = []; + foreach ($this->getLayerSet()->layers as $index => $layer) { + $ids[(string) $glyphLayers[$index]['id']] = PresentationLayerPolicy::getMapLayerId($layer); + } + + return $ids; + } + + public function getStoredGridPaths(): array + { + $this->assertGridSourcesCanonical(); + return $this->layers->getStoredPaths(); + } + + /** + * The map's graphical tile layers in `graphics/`, unsaved resizes and + * stamped pieces included. The TUI never paints single tiles. + * + * @return array Source bytes by path. + */ + public function getTileLayerSources(): array + { + return $this->layers->getTileSources(); + } + + /** + * Writes a stamped piece's tile entries into the named tile layers with + * their top-left cell at (x, y), creating a tile layer the map does not + * have yet. A `0` entry leaves its cell as it was. + * + * @param array>> $tiles Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot take the entries; nothing is changed. + */ + public function writeTileEntries(array $tiles, int $x, int $y): void + { + $this->assertEditable(); + $this->layers->writeTileEntries($tiles, $x, $y, $this->findNewTileLayerPlace(...)); + $this->touchState(); + } + + /** + * Sets tile entries cell by cell in the named tile layers, `0` included, + * for a drawn or erased connected piece, creating a tile layer the map + * does not have yet when it gets a tile. + * + * @param array> $cells The entry for each cell, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot take the entries; nothing is changed. + */ + public function writeTileCells(array $cells): void + { + $this->assertEditable(); + $this->layers->writeTileCells($cells, $this->findNewTileLayerPlace(...)); + $this->touchState(); + } + + /** + * Where a new tile layer goes among the map's tile layers: before the + * first that belongs to a gameplay layer drawn above the new layer's + * own, so terrain tiles draw under building tiles and those under + * fixtures. A stack's layer, whose objects may belong to several + * gameplay layers, draws with the lowest of them, as peaks and hill + * crests stand over the terrain. A layer whose owner is unknown goes last. + * + * @param list $names The map's tile layer names, in order. + * @return string|null The tile layer to place it before, or null for last. + */ + private function findNewTileLayerPlace(string $name, array $names): ?string + { + $gameplay = array_values(array_filter($this->layers->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration'])); + $rank = array_flip(array_column($gameplay, 'name')); + try { + $tileset = $this->loadTileset(); + $owners = MapGraphics::resolveLayerOwners($this->getMapDataField([MapGraphics::SETTINGS_KEY]), [...$names, $name], + array_keys($rank), $tileset, $this->mapId, $this->isPaintedInKinds()); + } catch (InvalidArgumentException | RuntimeException) { + return null; + } + $stack = $tileset?->findStackHolding($name); + $stackRanks = $stack === null ? [] : array_values(array_filter(array_map(static fn(TilesetPiece $piece): ?int + => $tileset->getStackOf($piece) === $stack ? ($rank[$piece->layer] ?? null) : null, $tileset->pieces), is_int(...))); + if (($owners[$name] ?? null) === null && $stackRanks === []) { + return null; + } + $own = ($owners[$name] ?? null) !== null ? $rank[$owners[$name]] : min($stackRanks); + foreach ($names as $existing) { + $owner = $owners[$existing] ?? null; + if ($owner !== null && $rank[$owner] > $own) { + return $existing; + } + } + + return null; + } + + /** + * The tile layers whose tiles move with a gameplay layer's glyphs: those + * that belong to it ({@see MapGraphics::resolveLayerOwners()}), named by + * the map data (`'tileLayers' => ['floor' => ['movesWith' => + * 'buildings']]`) or otherwise written only by this layer's tileset + * pieces. The event layer moves no tiles, and neither does any layer + * while the settings are invalid, which validation reports. A tileset + * stack's layers are never among them: their objects move with the layer + * each belongs to, one by one ({@see \Ichiloto\Editor\Canvas\StackedObjects}). + * + * @return list Tile layer names. + */ + public function getTileLayersMovingWith(string $layerId): array + { + $layer = $this->layers->getLayer($layerId); + $names = $this->layers->getTileLayerNames(); + if ($layer['id'] === MapLayers::EVENT || $layer['decoration'] || $names === []) { + return []; + } + $gameplay = array_column(array_filter($this->layers->getLayers(), static fn(array $candidate): bool => + $candidate['id'] !== MapLayers::EVENT && ! $candidate['decoration']), 'name'); + try { + $tileset = $this->loadTileset(); + } catch (InvalidArgumentException | RuntimeException) { + // A tileset that cannot load says nothing about its pieces. + $tileset = null; + } + try { + $owners = MapGraphics::resolveLayerOwners($this->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, $gameplay, + $tileset, $this->mapId, $this->isPaintedInKinds()); + } catch (InvalidArgumentException) { + return []; + } + + $stacked = $tileset?->getStackLayers() ?? []; + + return array_values(array_filter(array_keys(array_filter($owners, static fn(?string $owner): bool => $owner === $layer['name'])), + static fn(string $name): bool => ! in_array($name, $stacked, true))); + } + + /** + * The names of the map's tile layers, in drawing order. + * + * @return list + */ + public function getTileLayerNames(): array + { + return $this->layers->getTileLayerNames(); + } + + /** + * Reads the entries of the named tile layers over a rectangle, by row. + * + * @param list $names Tile layer names. + * @return array>> Entries by row, keyed by tile layer name. + * @throws MapSourceRefusal When a layer cannot be read or does not match the map. + */ + public function readTileEntries(array $names, int $x, int $y, int $width, int $height): array + { + return $this->layers->readTileEntries($names, $x, $y, $width, $height); + } + + /** + * Restores the tile layers {@see getTileLayerSources()} returned, for + * undo and redo. + * + * @param array $sources Source bytes by path. + */ + public function restoreTileLayerSources(array $sources): void + { + $this->assertEditable(); + $this->layers->restoreTileSources($sources); + $this->touchState(); + } + + /** + * The map's tile layers in drawing order, as an editor lists them: each + * one's name, order, file in the map's folder, and its settings as the + * Engine reads them: its offset across and down in field cells, the + * gameplay layer its settings name it `movesWith`, and the gameplay layer + * it belongs to ({@see MapGraphics::resolveLayerOwners()}). When the + * settings cannot be read, `issue` says why and no layer has settings. + * + * @return array{layers: list, issue: ?string} + */ + public function describeTileLayers(): array + { + $layers = $this->layers->getTileLayers(); + $names = array_column($layers, 'name'); + $settings = $this->getMapDataField([MapGraphics::SETTINGS_KEY]); + $gameplay = $this->getGameplayLayerNames(); + try { + $tileset = $this->loadTileset(); + } catch (InvalidArgumentException | RuntimeException) { + // A tileset that cannot load says nothing about its pieces. + $tileset = null; + } + $issue = null; + try { + $offsets = MapGraphics::readLayerOffsets($settings, $names, $this->mapId); + $movesWith = MapGraphics::readLayersMovingWith($settings, $names, $gameplay, $this->mapId); + $owners = MapGraphics::resolveLayerOwners($settings, $names, $gameplay, $tileset, $this->mapId, $this->isPaintedInKinds()); + } catch (InvalidArgumentException $error) { + $offsets = $movesWith = $owners = []; + $issue = $error->getMessage(); + } + + return ['layers' => array_map(static fn(array $layer): array => [ + 'name' => $layer['name'], + 'order' => $layer['order'], + 'file' => MapGraphics::DIRECTORY . '/' . basename($layer['path']), + 'offset' => $issue === null ? ($offsets[$layer['name']] ?? [0.0, 0.0]) : null, + 'movesWith' => $movesWith[$layer['name']] ?? null, + 'owner' => $owners[$layer['name']] ?? null, + ], $layers), 'issue' => $issue]; + } + + /** + * Adds an empty tile layer, placed among the tile layers as a tile layer + * a piece names is. + * + * @throws MapSourceRefusal When the name is taken or invalid, or the map cannot take another tile layer. + */ + public function createTileLayer(string $name): void + { + $this->changeLayers(fn(MapLayers $layers) => $layers->createTileLayer($name, $this->findNewTileLayerPlace(...))); + } + + /** + * Renames a tile layer and its settings. + * + * @throws MapSourceRefusal When there is no such layer, or the new name is taken or invalid. + */ + public function renameTileLayer(string $name, string $newName): void + { + $this->changeLayers(static fn(MapLayers $layers) => $layers->renameTileLayer($name, $newName), + static fn(mixed $settings): mixed => TileLayerSettings::renameLayer($settings, $name, $newName), + static function (array $objects) use ($name, $newName): array { + foreach ($objects as &$object) { + if (isset($object['covers'])) { + $object['covers']['tileLayers'] = array_map(static fn($layer): string => $layer === $name ? $newName : $layer, $object['covers']['tileLayers']); + } + } + return $objects; + }); + } + + /** + * Moves a tile layer to another drawing order; a tile layer holding that + * order takes this layer's order. + * + * @throws MapSourceRefusal When there is no such layer or the order is not 00-99. + */ + public function moveTileLayer(string $name, int $order): void + { + $this->changeLayers(static fn(MapLayers $layers) => $layers->moveTileLayer($name, $order)); + } + + /** + * Removes a tile layer, its tiles and its settings. + * + * @throws MapSourceRefusal When there is no such layer. + */ + public function removeTileLayer(string $name): void + { + $this->changeLayers(static fn(MapLayers $layers) => $layers->removeTileLayer($name), + static fn(mixed $settings): mixed => TileLayerSettings::removeLayer($settings, $name)); + } + + /** + * Sets a tile layer's offset across and down, in field cells, and the + * gameplay layer its tiles move with, or none. The map's settings must + * read as the Engine reads them once set. + * + * @param array $offset Across and down, each one of {@see MapGraphics::OFFSET_STEPS}. + * @throws MapSourceRefusal When there is no such layer, or the Engine would refuse the settings. + */ + public function setTileLayerSettings(string $name, array $offset, ?string $movesWith): void + { + $this->assertEditable(); + $names = array_column($this->layers->getTileLayers(), 'name'); + if (! in_array($name, $names, true)) { + throw new MapSourceRefusal("There is no tile layer {$name}. Nothing was changed."); + } + $settings = $this->getMapDataField([MapGraphics::SETTINGS_KEY]); + $next = TileLayerSettings::setLayer($settings, $name, $offset, $movesWith); + try { + MapGraphics::readLayersMovingWith($next, $names, $this->getGameplayLayerNames(), $this->mapId); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); + } + if ($next !== $settings) { + $data = $this->editableData; + $data[MapGraphics::SETTINGS_KEY] = $next; + $this->assertWorldObjectState($this->layers, $data); + $this->setMapDataField([MapGraphics::SETTINGS_KEY], $next); + } + } + + /** @return list The names of the map's gameplay layers, in order. */ + private function getGameplayLayerNames(): array + { + return array_values(array_column(array_filter($this->layers->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name')); + } + + /** + * Loads the tileset the map data names, or null when it names none. + * + * @throws InvalidArgumentException When the named tileset cannot be loaded. + */ + public function loadTileset(): ?Tileset + { + $id = $this->getMapDataField(['tileset']); + if ($id === null) { + return null; + } + if (! is_string($id)) { + throw new InvalidArgumentException(sprintf('Its tileset is %s, not a tileset id.', get_debug_type($id))); + } + + return $this->loadTilesetById($id); + } + + /** + * Loads a tileset of this map's project by id, as the workspace holds it, unsaved edits included: the one a + * change of the map's kind would make it name. + * + * @throws InvalidArgumentException When the tileset cannot be loaded. + */ + public function loadTilesetById(string $id): Tileset + { + return $this->catalogs?->loadTileset($id) ?? Tileset::load($this->getAssetRoot(), $id); + } + + /** + * The map's graphics as the game loads them, from its current tile + * layers and its tileset as the workspace holds it, unsaved edits + * included, or null when it has none. + * + * @throws InvalidArgumentException When the game would refuse them. + */ + public function loadGraphics(): ?MapGraphics + { + $tilesetId = $this->getMapDataField(['tileset']); + + return MapGraphics::fromSources($this->getTileLayerSources(), $this->mapId, $tilesetId, + $this->getLayerSet(), $this->getAssetRoot(), $this->getMapDataField([MapGraphics::SETTINGS_KEY]), + is_string($tilesetId) ? $this->loadTileset() : null, $this->isPaintedInKinds()); + } + + /** + * Everything an editor places on this map from its tileset, by id: its + * pieces and, when the map is painted in terrain kinds, its kinds' + * pieces ({@see Tileset::getPlacements()}). None without a tileset. + * + * @return array + * @throws InvalidArgumentException|RuntimeException When the tileset cannot be read. + */ + public function loadPlacements(): array + { + return $this->loadTileset()?->getPlacements($this->isPaintedInKinds()) ?? []; + } + + /** + * Whether the map is painted in its tileset's terrain kinds. A malformed + * declaration counts as not, and is reported by validation. + */ + public function isPaintedInKinds(): bool + { + try { + return MapTerrainKinds::isDeclared($this->editableData); + } catch (InvalidArgumentException) { + return false; + } + } + /** The project's asset root, where tilesets and their sheets live. */ + public function getAssetRoot(): string + { + return dirname($this->getMapsRoot()); + } + + /** Proves the layers compose and resolve collisions as the engine will load them. */ + public function validateLayerContracts(): void + { + $this->assertGridsAgree(); + $this->getResolvedCollisionMap(); + } + + /** @return int[][] The same physical cells the Engine installs at map load. */ + public function getResolvedCollisionMap(): array + { + return $this->getPhysicalOccupancy()->collisionGrid; + } + + /** The map's physical cells as the Engine resolves them at load: declared occupancy, or its glyphs by the collision dictionary. */ + public function getPhysicalOccupancy(): MapPhysicalOccupancy + { + $dictionary = $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]) ? [] : $this->loadCollisionDictionary(); + + return MapCollisionResolver::resolveMap($this->layers->getLayerSet(), $this->editableData, $dictionary, $this->loadTerrainKinds()); + } + + /** + * Each cell's encounter terrain as the Engine resolves it at map load + * (MapManager::prepareMap()): the terrain kinds' glyph walk, read with the + * dictionary the map's glyphs are read with, none under declared + * occupancy. Null for a map not painted in terrain kinds. + * + * @throws InvalidArgumentException When the map opts into kinds and they cannot be read. + */ + public function resolveEncounterTerrain(): ?MapEncounterTerrain + { + $kinds = $this->loadTerrainKinds(); + if ($kinds === null) { + return null; + } + $dictionary = $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]) ? [] : $kinds->composeDictionary($this->loadCollisionDictionary()); + + return $kinds->resolveEncounterTerrain($this->layers->getLayerSet(), $dictionary); + } + + /** + * The collision a trial of this map would resolve to, read as + * {@see getPhysicalOccupancy()} reads the map itself: its declared + * occupancy, or the trial's glyphs by the project's dictionary, with the + * terrain kinds the trial data opts into. Nothing changes. + * + * @param array $data The trial's map data. + * @return int[][] + * @throws InvalidArgumentException When the trial's collision cannot be resolved. + */ + public function resolveTrialCollision(MapLayerSet $layers, array $data, ?MapTerrainKinds $kinds): array + { + $dictionary = array_key_exists(MapPhysicalOccupancy::DATA_KEY, $data) ? [] : $this->loadCollisionDictionary(); + + return MapCollisionResolver::resolveMap($layers, $data, $dictionary, $kinds)->collisionGrid; + } + + /** + * The terrain kinds the map is painted in, from its tileset as the + * workspace holds it, or null when the map does not opt into kinds + * ({@see MapTerrainKinds}). + * + * @throws InvalidArgumentException When the map opts in and its tileset cannot be read or has no kinds. + */ + public function loadTerrainKinds(): ?MapTerrainKinds + { + if (! MapTerrainKinds::isDeclared($this->editableData)) { + return null; + } + try { + $tileset = $this->loadTileset(); + } catch (\Throwable $error) { + throw new InvalidArgumentException(sprintf('The map is painted in terrain kinds, but its tileset cannot be read, so its passage is unknown: %s', + $error->getMessage()), previous: $error); + } + + return new MapTerrainKinds($tileset ?? throw new InvalidArgumentException('The map is painted in terrain kinds but names no tileset.')); + } + + /** + * Paints declared physical cells only, independently of either presentation. + * Every cell is validated before the single source-preserving data mutation. + * + * @param list $cells + * @return int The number of distinct cells whose collision changed. + */ + public function paintPhysicalOccupancy(array $cells, CollisionType $collision): int + { + if ($collision === CollisionType::PASS_THROUGH) { + throw new MapSourceRefusal('PASS_THROUGH is not a final physical collision. Nothing was changed.'); + } + if (! array_is_list($cells)) { + throw new MapSourceRefusal('Physical cells must be a list of [x, y] integer pairs. Nothing was changed.'); + } + $writes = []; + foreach ($cells as $cell) { + if (! is_array($cell) || ! array_is_list($cell) || count($cell) !== 2 + || ! is_int($cell[0]) || ! is_int($cell[1])) { + throw new MapSourceRefusal('Physical cells must be a list of [x, y] integer pairs. Nothing was changed.'); + } + $writes[] = [$cell[0], $cell[1], $collision]; + } + + return $this->writePhysicalOccupancy($writes); + } + + /** @param list $writes Atomic heterogeneous physical writes. */ + public function writePhysicalOccupancy(array $writes): int + { + $this->assertEditable(); + $rows = $this->getDeclaredPhysicalOccupancy(); + if ($rows === null) { + throw new MapSourceRefusal('Physical occupancy must be explicitly migrated before painting. Nothing was changed.'); + } + if (! array_is_list($writes)) { + throw new MapSourceRefusal('Physical writes must be a list of [x, y, CollisionType] entries. Nothing was changed.'); + } + $unique = []; + foreach ($writes as $write) { + if (! is_array($write) || ! array_is_list($write) || count($write) !== 3 + || ! is_int($write[0]) || ! is_int($write[1]) || ! $write[2] instanceof CollisionType) { + throw new MapSourceRefusal('Physical writes must be a list of [x, y, CollisionType] entries. Nothing was changed.'); + } + [$x, $y, $collision] = $write; + if ($collision === CollisionType::PASS_THROUGH) { + throw new MapSourceRefusal('PASS_THROUGH is not a final physical collision. Nothing was changed.'); + } + if (! isset($rows[$y][$x])) { + throw new MapSourceRefusal("Physical cell {$x}, {$y} is outside the map. Nothing was changed."); + } + if (isset($unique["{$x},{$y}"]) && $unique["{$x},{$y}"][2] !== $collision) { + throw new MapSourceRefusal("Physical cell {$x}, {$y} has conflicting writes. Nothing was changed."); + } + $unique["{$x},{$y}"] = $write; + } + $changed = 0; + foreach ($unique as [$x, $y, $collision]) { + if ($rows[$y][$x] !== $collision) { + $rows[$y][$x] = $collision; + $changed++; + } + } + if ($changed > 0) { + $this->setMapDataField([MapPhysicalOccupancy::DATA_KEY], $rows); + } + + return $changed; + } - if (! is_array($data) || ! is_string($mapText) || ! is_string($eventText)) { - throw new RuntimeException("Map files could not be parsed in {$directory}."); - } + /** Explicitly preserves current collisions independently of both presentations. */ + public function migratePhysicalOccupancy(): bool + { + $this->assertEditable(); + try { + if ($this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + $this->getResolvedCollisionMap(); - $relativeDirectory = substr($directory, strlen($mapsRoot) + 1); + return false; + } + $occupancy = MapCollisionResolver::resolveLegacyOccupancy($this->layers->getLayerSet(), $this->loadCollisionDictionary(), + $this->loadTerrainKinds()); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); + } + $this->setMapDataField([MapPhysicalOccupancy::DATA_KEY], $occupancy->exportDeclaration()); - return new self( - mapId: str_replace(DIRECTORY_SEPARATOR, '/', $relativeDirectory), - directory: $directory, - dataPath: $dataPath, - mapPath: $mapPath, - eventPath: $eventPath, - data: $data, - tileLines: self::splitMapText($mapText), - eventLines: self::splitMapText($eventText), - dataSource: (string) file_get_contents($dataPath), - )->withLoadedBaseline(); + return true; } /** - * Adopts the just-loaded content as the saved baseline. + * The collision the terminal layers give cells as they stand, read as an + * undeclared map's are, through the collision dictionary and terrain + * kinds: what a cell is once a piece that set its own collision there is + * taken away, as separating collision would have captured it. * - * @return self This map. + * @param list $cells + * @return list Physical writes for the cells on the map. + * @throws InvalidArgumentException When the layers' collision cannot be resolved. */ - private function withLoadedBaseline(): self + public function resolveGlyphCollision(array $cells): array { - $this->captureBaseline(); + $rows = MapCollisionResolver::resolveLegacyOccupancy($this->layers->getLayerSet(), $this->loadCollisionDictionary(), + $this->loadTerrainKinds())->collisionGrid; + $writes = []; + foreach ($cells as [$x, $y]) { + if (isset($rows[$y][$x])) { + $writes[] = [$x, $y, CollisionType::from($rows[$y][$x])]; + } + } - return $this; + return $writes; } /** - * Returns the display name for the map. + * How many cells would resolve to another collision if the layer were + * renamed: collision lookup uses the layer name ({@see MapCollisionResolver}). * - * @return string + * @throws MapSourceRefusal When the rename is refused, or collisions cannot be resolved after it. */ - public function getDisplayName(): string + public function countRenameCollisionChanges(string $id, string $name): int { - return (string) ($this->editableData['name'] ?? basename($this->directory)); + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->renameLayer($id, $name)); } /** - * Returns the display region for the map. + * How many cells would resolve to another collision if the layer moved + * to the order: the topmost gameplay glyph sets a cell's collision. * - * @return string + * @throws MapSourceRefusal When the move is refused, or collisions cannot be resolved after it. */ - public function getRegion(): string + public function countMoveCollisionChanges(string $id, int $order): int { - return (string) ($this->editableData['region'] ?? 'Unknown'); + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->moveLayer($id, $order)); } /** - * Returns the map description. + * How many cells would resolve to another collision if the layer became + * decoration, which has none, or gameplay. * - * @return string + * @throws MapSourceRefusal When the change is refused, or collisions cannot be resolved after it. */ - public function getDescription(): string + public function countDecorationCollisionChanges(string $id, bool $decoration): int { - return (string) ($this->editableData['description'] ?? ''); + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->setLayerDecoration($id, $decoration)); } /** - * Returns the number of tile rows. + * How many cells would resolve to another collision if the layer were removed. * - * @return int + * @throws MapSourceRefusal When the removal is refused, or collisions cannot be resolved after it. */ - public function getHeight(): int + public function countRemovalCollisionChanges(string $id): int { - return count($this->tileCells); + return $this->countCollisionChanges(static fn(MapLayers $layers) => $layers->removeLayer($id)); } /** - * Returns the widest tile row after formatting tags are stripped. + * Resolves collisions before and after a change tried on a copy of the + * layers, and counts the cells that differ. Nothing changes. * - * @return int + * @param Closure(MapLayers): mixed $change + * @throws MapSourceRefusal When the change is refused, or collisions cannot be resolved. */ - public function getWidth(): int + private function countCollisionChanges(Closure $change): int { - if ($this->cachedWidth !== null) { - return $this->cachedWidth; + $this->assertEditable(); + $trial = clone $this->layers; + $change($trial); + $dictionary = $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]) ? [] : $this->loadCollisionDictionary(); + try { + $kinds = $this->loadTerrainKinds(); + $before = MapCollisionResolver::resolveMap($this->layers->getLayerSet(), $this->editableData, $dictionary, $kinds)->collisionGrid; + $after = MapCollisionResolver::resolveMap($trial->getLayerSet(), $this->editableData, $dictionary, $kinds)->collisionGrid; + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal(rtrim($error->getMessage(), '.') . '. Nothing was changed.', previous: $error); + } + $changed = 0; + foreach ($before as $y => $row) { + foreach ($row as $x => $type) { + $changed += $type !== ($after[$y][$x] ?? null) ? 1 : 0; + } } - $width = 0; + return $changed; + } - foreach ($this->tileCells as $row) { - $width = max($width, count($row)); + /** @throws MapSourceRefusal When an unconfirmed change changes collisions. */ + private function assertCollisionsUnchanged(int $changed, string $action): void + { + if ($changed > 0) { + throw new MapSourceRefusal("{$action} this layer changes resolved collisions. Explicit confirmation is required."); } - - return $this->cachedWidth = $width; } /** - * Returns whether the map has unsaved changes. - * - * @return bool - */ - - - /** - * Returns the number of declared event definitions. + * The project's shared collision dictionary, or none. * - * @return int + * @return array + * @throws MapSourceRefusal When it does not return an array. */ - public function getEventDefinitionCount(): int + private function loadCollisionDictionary(): array { - $events = $this->editableData['events'] ?? []; + $path = $this->getMapsRoot() . '/collisions.php'; + $dictionary = is_file($path) ? (static fn(string $path): mixed => require $path)($path) : []; + if (! is_array($dictionary)) { + throw new MapSourceRefusal('The collision dictionary must return an array.'); + } - return is_array($events) ? count($events) : 0; + return $dictionary; } - /** - * Returns the number of configured triggers. - * - * @return int - */ - public function getTriggerCount(): int + public function captureLayerSnapshot(): array { - $triggers = $this->editableData['triggers'] ?? []; - - return is_array($triggers) ? count($triggers) : 0; + return ['grid' => $this->captureGridSnapshot(), 'data' => $this->editableData, + 'source' => $this->dataDocument === null ? $this->unparsedDataSource : $this->proposedDataSource()]; } - /** - * Returns the editable map data. - * - * @return array - */ - public function getEditableData(): array + public function restoreLayerSnapshot(array $snapshot): void { - return $this->editableData; + // The snapshot owns both halves; validate them together, not old data against restored layer names. + $candidate = clone $this; + $candidate->editableData = $snapshot['data']; + $candidate->restoreGridSnapshot($snapshot['grid']); + // History restores edits, never the last successful save checkpoint + // or the original source needed by other outstanding undo entries. + $this->editableData = $snapshot['data']; + $this->layers = $candidate->layers; + $this->cachedWidth = null; + $this->touchState(); } /** @@ -288,6 +1398,9 @@ public function getEditableData(): array * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. * @param bool $showEventOverlay Whether event markers should be rendered. + * @param array> $stampPreview A stamp's footprint by row and column, + * drawn highlighted over the map and never painted: the glyph it would write, or null where it + * leaves the map's cell as it is. * @return string[] */ public function renderPreview( @@ -299,6 +1412,11 @@ public function renderPreview( bool $showNpcOverlay = false, ?int $selectedNpcIndex = null, ?string $selectedNpcSprite = null, + array $layerVisibility = [], + ?string $activeLayer = null, + bool $terminalPreview = false, + bool $dimInactive = false, + array $stampPreview = [], ): array { if ($width < 1 || $height < 1) { @@ -308,12 +1426,30 @@ public function renderPreview( $lines = []; $offsetX = max(0, $offsetX); $offsetY = max(0, $offsetY); - $rowLimit = min($offsetY + $height, max(count($this->tileCells), count($this->eventCells))); + if ($terminalPreview) { + $rows = array_slice($this->getLayerSet()->getComposedGrid(), $offsetY, $height); + return array_pad(array_map(static fn(array $row): string => rtrim(implode('', array_slice($row, $offsetX, $width))), $rows), $height, ''); + } + $rowLimit = min($offsetY + $height, max(count($this->layers->getBaseGrid()->cells), count($this->layers->getEventGrid()->getSymbols()))); $npcCells = $showNpcOverlay ? $this->npcOverlayCells($selectedNpcIndex, $selectedNpcSprite) : []; + $baseLayerId = $this->layers->getBaseId(); for ($row = $offsetY; $row < $rowLimit; $row++) { - $tileRow = $this->tileCells[$row] ?? []; - $eventSymbols = $showEventOverlay ? ($this->eventCells[$row] ?? []) : []; + $tileRow = []; + foreach ($this->layers->getLayers() as $layer) { + if ($layer['decoration']) { + continue; + } + if (($layerVisibility[$layer['id']] ?? true) === false + || ($layer['id'] === MapLayers::EVENT && ! $showEventOverlay)) { + continue; + } + foreach ($layer['grid']->cells[$row] ?? [] as $x => $cell) { + if ($cell['symbol'] !== ' ' || $layer['id'] === $baseLayerId) { + $tileRow[$x] = [...$cell, 'dim' => $dimInactive && $activeLayer !== $layer['id']]; + } + } + } $mergedSymbols = []; for ($column = $offsetX; $column < $offsetX + $width; $column++) { @@ -324,21 +1460,30 @@ public function renderPreview( continue; } - $eventSymbol = $eventSymbols[$column] ?? ' '; - - if (trim($eventSymbol) !== '') { - // Event markers are authoring geometry: they stay plain - // so they read as markers over any terrain colour. - $mergedSymbols[] = $eventSymbol; + if (isset($stampPreview[$row][$column])) { + // A stamp's footprint shows what it would write, in + // reverse video, over whatever the map holds there. + $mergedSymbols[] = "\033[7m" . $stampPreview[$row][$column] . "\033[0m"; continue; } $tileCell = $tileRow[$column] ?? null; $tileSymbol = is_array($tileCell) ? $tileCell['symbol'] : ' '; + // A selected map-owned NPC, and a stamp's cell that keeps the map's + // glyph, mark the existing cell, never a replacement. + $anchorHighlight = array_key_exists($column, $npcCells[$row] ?? []) || array_key_exists($column, $stampPreview[$row] ?? []) + ? "\033[7m" : ''; + if (! $this->layers->legacy && is_array($tileCell)) { + $styled = TerminalText::formatStyles($tileCell['prefix'] . $anchorHighlight . $tileSymbol . $tileCell['suffix']); + $styled = ($tileCell['dim'] ?? false) ? "\033[2m" . $styled . "\033[0m" : $styled; + $mergedSymbols[] = $anchorHighlight === '' ? $styled : $styled . "\033[0m"; + continue; + } $ansiOpen = is_array($tileCell) ? self::ansiOpenForPrefix((string) ($tileCell['prefix'] ?? '')) : null; - $mergedSymbols[] = $ansiOpen === null ? $tileSymbol : $ansiOpen . $tileSymbol . "\033[0m"; + $dim = (($tileCell['dim'] ?? false) ? "\033[2m" : '') . $anchorHighlight; + $mergedSymbols[] = $ansiOpen === null && $dim === '' ? $tileSymbol : $dim . $ansiOpen . $tileSymbol . "\033[0m"; } $lines[] = rtrim(implode('', $mergedSymbols)); @@ -351,6 +1496,8 @@ public function renderPreview( * The 4-bit ANSI foreground codes for the formatter's colour names. * `gray` is the formatter's name for bright black. */ + private const string STYLE_CLOSE_TAG = ''; + private const array ANSI_FOREGROUNDS = [ 'black' => 30, 'red' => 31, 'green' => 32, 'yellow' => 33, 'blue' => 34, 'magenta' => 35, 'cyan' => 36, 'white' => 37, @@ -371,12 +1518,12 @@ public function renderPreview( */ private static function ansiOpenForPrefix(string $prefix): ?string { - if ($prefix === '' || preg_match('/]+)[^>]*>/', $prefix, $matches) !== 1) { + $value = self::getInnermostForeground($prefix); + + if ($value === null) { return null; } - $value = $matches[1]; - if (isset(self::ANSI_FOREGROUNDS[$value])) { return sprintf("\033[%dm", self::ANSI_FOREGROUNDS[$value]); } @@ -402,13 +1549,15 @@ private static function ansiOpenForPrefix(string $prefix): ?string * cell holds an empty string, so the terminal draws the wide glyph in * the space it needs rather than a symbol shoved half under it. The * selected NPC is drawn with brackets around a one-column sprite, or as - * itself when wide, since brackets would misalign the row. + * itself when wide, since brackets would misalign the row. An explicitly + * empty sprite contributes only a selected anchor (null), highlighting + * the underlying map cell without replacing its glyph or neighbours. * * @param int|null $selectedNpcIndex The NPC to mark selected. * @param string|null $selectedNpcSprite A glyph to draw for the selected * NPC in place of its base sprite -- a directional sprite being * previewed -- as authored; it is shown as the terminal would show it. - * @return array> The cells. + * @return array> The cells, or selected map-owned anchors. */ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSprite = null): array { @@ -425,6 +1574,13 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr $columns = max(1, mb_strwidth($sprite)); } + if ($sprite === '') { + if ($index === $selectedNpcIndex) { + $cells[$y][$x] = null; + } + continue; + } + if ($index === $selectedNpcIndex && $columns === 1) { $sprite = '[' . $sprite . ']'; $x = max(0, $x - 1); @@ -450,7 +1606,7 @@ private function npcOverlayCells(?int $selectedNpcIndex, ?string $selectedNpcSpr */ public function getTileSymbol(int $x, int $y): string { - return $this->tileCells[$y][$x]['symbol'] ?? ' '; + return $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] ?? ' '; } /** @@ -462,7 +1618,7 @@ public function getTileSymbol(int $x, int $y): string */ public function getEventSymbol(int $x, int $y): string { - return $this->eventCells[$y][$x] ?? ' '; + return $this->getLayerSymbol(MapLayers::EVENT, $x, $y); } /** @@ -475,11 +1631,12 @@ public function getEventSymbol(int $x, int $y): string */ public function setTileSymbol(int $x, int $y, string $symbol): void { - if (! isset($this->tileCells[$y][$x])) { + $this->assertEditable(); + if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { return; } - $this->tileCells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); + $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); $this->touchState(); } @@ -495,7 +1652,7 @@ public function setTileSymbol(int $x, int $y, string $symbol): void */ public function getTileCellStyle(int $x, int $y): array { - $cell = $this->tileCells[$y][$x] ?? null; + $cell = $this->layers->getBaseGrid()->cells[$y][$x] ?? null; return [ 'prefix' => is_array($cell) ? (string) ($cell['prefix'] ?? '') : '', @@ -512,13 +1669,52 @@ public function getTileCellStyle(int $x, int $y): array */ public function getTileColor(int $x, int $y): ?string { - $style = $this->getTileCellStyle($x, $y); + return self::getInnermostForeground($this->getTileCellStyle($x, $y)['prefix']); + } - if (preg_match('/]+)[^>]*>/', $style['prefix'], $matches) === 1) { - return $matches[1]; + /** + * Returns the `fg=` value a styling prefix renders in. Nested tags + * override outer ones, so the last declared foreground wins. + * + * @param string $prefix The cell's styling prefix bytes. + * @return string|null The `fg=` value, or null when none is declared. + */ + public static function getInnermostForeground(string $prefix): ?string + { + $state = new SgrStyleState(); + preg_match_all('/\x1b\[[0-9;]*m/', $prefix, $sequences); + foreach ($sequences[0] as $sequence) { + $state->apply($sequence); + } + $ansi = $state->prefix(); + if (preg_match('/\x1b\[38;2;(\d+);(\d+);(\d+)m/', $ansi, $rgb) === 1) { + return sprintf('#%02x%02x%02x', (int) $rgb[1], (int) $rgb[2], (int) $rgb[3]); + } + foreach (self::ANSI_FOREGROUNDS as $name => $code) { + if (str_contains($ansi, "\033[{$code}m")) { + return $name; + } + } + if (preg_match('/\x1b\[38;5;(\d+)m/', $ansi, $indexed) === 1) { + $index = (int) $indexed[1]; + if ($index < 16) { + return array_keys(self::ANSI_FOREGROUNDS)[$index]; + } + if ($index <= 255) { + if ($index >= 232) { + $shade = 8 + ($index - 232) * 10; + return sprintf('#%02x%02x%02x', $shade, $shade, $shade); + } + $cube = [0, 95, 135, 175, 215, 255]; + $index -= 16; + return sprintf('#%02x%02x%02x', $cube[intdiv($index, 36)], $cube[intdiv($index % 36, 6)], $cube[$index % 6]); + } + } + if (preg_match_all('/<[^>]*\bfg=([^;>]+)[^>]*>/', $prefix, $matches) < 1) { + return null; } - return null; + return $matches[1][array_key_last($matches[1])]; } /** @@ -533,13 +1729,14 @@ public function getTileColor(int $x, int $y): ?string */ public function setTileCell(int $x, int $y, string $symbol, string $prefix, string $suffix): void { - if (! isset($this->tileCells[$y][$x])) { + $this->assertEditable(); + if (! isset($this->layers->getBaseGrid()->cells[$y][$x])) { return; } - $this->tileCells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); - $this->tileCells[$y][$x]['prefix'] = $prefix; - $this->tileCells[$y][$x]['suffix'] = $suffix; + $this->layers->getBaseGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); + $this->layers->getBaseGrid()->cells[$y][$x]['prefix'] = $prefix; + $this->layers->getBaseGrid()->cells[$y][$x]['suffix'] = $suffix; $this->touchState(); } @@ -553,11 +1750,12 @@ public function setTileCell(int $x, int $y, string $symbol, string $prefix, stri */ public function setEventSymbol(int $x, int $y, string $symbol): void { - if (! isset($this->eventCells[$y][$x])) { + $this->assertEditable(); + if (! $this->hasLayerCell(MapLayers::EVENT, $x, $y)) { return; } - $this->eventCells[$y][$x] = self::normalizeSymbol($symbol); + $this->layers->getEventGrid()->cells[$y][$x]['symbol'] = self::normalizeSymbol($symbol); $this->touchState(); } @@ -566,11 +1764,22 @@ public function setEventSymbol(int $x, int $y, string $symbol): void * * @return string[] */ + /** + * The event layer's cells as its source holds them, row by row, unsaved + * edits included: what the Engine reads as the map's event layer. + * + * @return list> + */ + public function getEventLayerCells(): array + { + return array_values(array_map(array_values(...), $this->layers->getEventGrid()->getSymbols())); + } + public function getPlacedEventMarkers(): array { $markers = []; - foreach ($this->eventCells as $row) { + foreach ($this->layers->getEventGrid()->getSymbols() as $row) { foreach ($row as $symbol) { if (trim($symbol) !== '') { $markers[$symbol] = $symbol; @@ -585,28 +1794,36 @@ public function getPlacedEventMarkers(): array * Captures the editable grid state for undoable whole-grid mutations * (resize, event bounds rewrites). * - * @return array{tiles: array>, events: array>} + * @return array{tiles: array, events: array, layers: array, occupancy: ?array} */ public function captureGridSnapshot(): array { return [ - 'tiles' => $this->tileCells, - 'events' => $this->eventCells, + 'tiles' => $this->layers->getBaseGrid()->cells, + 'events' => $this->layers->getEventGrid()->getSymbols(), + 'layers' => $this->layers->captureSnapshot(), + 'occupancy' => $this->getDeclaredPhysicalOccupancy(), ]; } /** * Restores a previously captured grid snapshot. * - * @param array{tiles: array>, events: array>} $snapshot The captured state. + * Occupancy is restored with geometry, without reverting unrelated metadata. + * Older snapshots remain usable on maps without declared occupancy only. + * + * @param array{tiles: array, events: array, layers: array, occupancy?: ?array} $snapshot The captured state. * @return void */ public function restoreGridSnapshot(array $snapshot): void { - $this->tileCells = $snapshot['tiles']; - $this->eventCells = $snapshot['events']; - $this->cachedWidth = null; - $this->touchState(); + $this->assertEditable(); + if (! array_key_exists('occupancy', $snapshot) && $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + throw new MapSourceRefusal('This grid snapshot has no physical occupancy state; nothing was changed.'); + } + $layers = clone $this->layers; + $layers->restoreSnapshot($snapshot['layers']); + $this->applyGridState($layers, $snapshot['occupancy'] ?? null); } /** @@ -699,6 +1916,7 @@ public function getNpcs(): NpcCollection */ public function setNpcs(NpcCollection $npcs): void { + $this->assertEditable(); $entries = $npcs->toMapData(); if (($this->editableData['npcs'] ?? null) === $entries) { @@ -740,24 +1958,56 @@ public function getMapField(string $field): mixed */ private function writeData(array $next): void { + $this->assertSourcesUnchanged(); + $next = $this->dropAbsentOptionalEventData($next); if ($next === $this->editableData) { // Setting data to what it already is neither dirties nor // deserves a history entry at the call site. return; } - $this->assertDataPreservable($next); - $this->editableData = $next; + $document = $this->assertDataPreservable($next); + $this->editableData = $document->alignValue($next); $this->touchState(); } + /** + * An event this write adds or changes keeps none of its type's optional + * data that reads as absent (a Story Script's empty script id): the + * Engine reads it as unset, and the game's checks would read it as a + * script that does not exist. Events the write leaves alone keep their + * source as authored. + * + * @param array $next + * @return array + */ + private function dropAbsentOptionalEventData(array $next): array + { + foreach ((array) ($next['events'] ?? []) as $marker => $event) { + if (! is_array($event) || ! is_array($event['data'] ?? null) + || $event === ($this->editableData['events'][$marker] ?? null)) { + continue; + } + $className = is_string($event['class'] ?? null) ? ltrim($event['class'], '\\') : null; + foreach (EventTypeCatalog::findByClass($className)?->optionalData ?? [] as $key => $absent) { + $value = $event['data'][$key] ?? null; + if (array_key_exists($key, $event['data']) && ($value === $absent + || (is_string($value) && is_string($absent) && trim($value) === trim($absent)))) { + unset($next['events'][$marker]['data'][$key]); + } + } + } + + return $next; + } + /** * Proves the authored data source can be rewritten into this array. * * @param array $next * @throws MapSourceRefusal When it cannot. */ - private function assertDataPreservable(array $next): void + private function assertDataPreservable(array $next): PhpArraySourceDocument { if ($this->dataDocument === null) { throw new MapSourceRefusal(sprintf( @@ -769,7 +2019,7 @@ private function assertDataPreservable(array $next): void } try { - ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $next); + return ArraySourceWriter::rewrite($this->dataDocument, $this->sourceData, $next); } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Everything else in the file is untouched.', @@ -788,7 +2038,7 @@ private function assertDataPreservable(array $next): void private function proposedDataSource(): string { if ($this->dataDocument === null) { - if ($this->editableData === $this->loadedData) { + if ($this->editableData === $this->sourceData) { throw new RuntimeException(sprintf('%s has no preservable data source to rewrite.', $this->mapId)); } @@ -796,7 +2046,7 @@ private function proposedDataSource(): string } try { - return ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $this->editableData)->source; + return ArraySourceWriter::rewrite($this->dataDocument, $this->sourceData, $this->editableData)->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s', @@ -852,6 +2102,7 @@ public function getMapDataField(array $path): mixed */ public function setMapDataField(array $path, mixed $value): void { + $this->assertEditable(); if ($path === []) { return; } @@ -937,6 +2188,22 @@ public function getEventField(string $marker, array $path): mixed return $reference; } + /** Whether an event holds a field at a path, even one whose value is null. */ + public function hasEventField(string $marker, array $path): bool + { + $reference = $this->editableData['events'][$marker] ?? null; + + foreach ($path as $segment) { + if (! is_array($reference) || ! array_key_exists($segment, $reference)) { + return false; + } + + $reference = $reference[$segment]; + } + + return $path !== []; + } + /** * Returns the event marker located at the given coordinate. * @@ -971,128 +2238,365 @@ public function getEventDefinition(string $marker): ?array } /** - * Returns the event bounds for a marker as x/y/width/height. + * Returns the cells an event marker occupies, as the runtime triggers + * it: exactly the painted cells, in any shape, connected or not. * * @param string $marker The event marker. - * @return array{x: int, y: int, width: int, height: int}|null + * @return CellArea|null The cells, or null when the marker is not placed. */ - public function getEventBounds(string $marker): ?array + public function getEventArea(string $marker): ?CellArea { - $positions = []; + $cells = []; - foreach ($this->eventCells as $rowIndex => $row) { + foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { foreach ($row as $columnIndex => $symbol) { if ($symbol === $marker) { - $positions[] = [$columnIndex, $rowIndex]; + $cells[] = [$columnIndex, $rowIndex]; } } } - if ($positions === []) { - return null; - } + return $cells === [] ? null : CellArea::fromCells($cells); + } - $xValues = array_column($positions, 0); - $yValues = array_column($positions, 1); - $minX = min($xValues); - $maxX = max($xValues); - $minY = min($yValues); - $maxY = max($yValues); + /** + * Returns the bounds of an event marker's cells as x/y/width/height. + * + * @param string $marker The event marker. + * @return array{x: int, y: int, width: int, height: int}|null + */ + public function getEventBounds(string $marker): ?array + { + $bounds = $this->getEventArea($marker)?->bounds; - return [ - 'x' => $minX, - 'y' => $minY, - 'width' => ($maxX - $minX) + 1, - 'height' => ($maxY - $minY) + 1, + return $bounds === null ? null : [ + 'x' => $bounds->getX(), + 'y' => $bounds->getY(), + 'width' => $bounds->getWidth(), + 'height' => $bounds->getHeight(), ]; } /** - * Reports whether every cell inside an event marker's bounds contains - * that marker. The runtime represents one marker as one rectangular - * trigger area and rejects sparse, cross-shaped, or disconnected areas. + * Moves every cell of an event marker by an offset, keeping its shape. + * + * Refused, leaving the grid unchanged, when a moved cell would leave the + * event layer or land on another marker's cell. + * + * @param string $marker The event marker. + * @param int $deltaX Columns to move by. + * @param int $deltaY Rows to move by. + * @return string|null Why the move was refused, or null when it was made. */ - public function isEventMarkerSolidRectangle(string $marker): bool + public function moveEventCells(string $marker, int $deltaX, int $deltaY): ?string { - $bounds = $this->getEventBounds($marker); + $this->assertEditable(); + $area = $this->getEventArea($marker); - if ($bounds === null) { - return false; + if ($area === null) { + return sprintf('Marker %s is not placed.', $marker); + } + + foreach ($area->cells as [$x, $y]) { + [$toX, $toY] = [$x + $deltaX, $y + $deltaY]; + + if (! $this->hasLayerCell(MapLayers::EVENT, $toX, $toY)) { + return sprintf('Marker %s would leave the map at (%d, %d).', $marker, $toX, $toY); + } + + $occupant = $this->getEventSymbol($toX, $toY); + + if ($occupant !== $marker && trim($occupant) !== '') { + return sprintf('Marker %s would cover marker %s at (%d, %d).', $marker, $occupant, $toX, $toY); + } } - $maxX = $bounds['x'] + $bounds['width']; - $maxY = $bounds['y'] + $bounds['height']; + foreach ($area->cells as [$x, $y]) { + $this->layers->getEventGrid()->cells[$y][$x]['symbol'] = ' '; + } + + foreach ($area->cells as [$x, $y]) { + $this->layers->getEventGrid()->cells[$y + $deltaY][$x + $deltaX]['symbol'] = $marker; + } + + $this->touchState(); + + return null; + } + + /** + * Updates a top-level map metadata field. + * + * @param string $field The field to update. + * @param mixed $value The new value. + * @return void + */ + public function setMapField(string $field, mixed $value): void + { + $this->assertEditable(); + $next = $this->editableData; + $next[$field] = $value; + $this->writeData($next); + } + + /** + * Resizes the map, its event overlay and its graphical tile layers while + * preserving existing content. A tile layer the Engine cannot read + * refuses the resize before anything changes. + * Declared physical cells are cropped or padded with SOLID, independently + * of blank terminal glyphs and empty graphical tiles. Absent occupancy + * stays absent; this operation never migrates a legacy map. + * + * @param int $width The new map width. + * @param int $height The new map height. + * @return void + */ + public function resize(int $width, int $height): void + { + $this->assertEditable(); + $width = max(1, $width); + $height = max(1, $height); + $occupancy = $this->getDeclaredPhysicalOccupancy(); + + if ($width === $this->getWidth() && $height === $this->getHeight()) { + return; + } + + $layers = $this->copyEditableLayers(); + $layers->resize($width, $height); + if ($occupancy !== null) { + $occupancy = array_map(static fn(array $row): array => array_pad(array_slice($row, 0, $width), $width, CollisionType::SOLID), + array_slice($occupancy, 0, $height)); + $occupancy = array_pad($occupancy, $height, array_fill(0, $width, CollisionType::SOLID)); + } + $this->applyGridState($layers, $occupancy); + } + + /** + * Inserts `$count` blank rows before row `$at` (axis `y`) or blank + * columns before column `$at` (axis `x`) into every grid the map has: + * its terminal layers, its event layer and its tile layers. Declared + * physical occupancy moves with these grids; new physical cells are SOLID. + * Ragged rows follow terminal geometry: columns affect only rows reaching + * `$at`, and new rows take the adjacent row's width. Absent occupancy stays + * absent. Other data coordinates move through the project-wide insertion plan. + * + * @throws MapSourceRefusal When the line is outside the map or a tile layer cannot take it; nothing is changed. + */ + public function insertLines(string $axis, int $at, int $count): void + { + $this->assertEditable(); + $size = match ($axis) { + 'x' => $this->getWidth(), + 'y' => $this->getHeight(), + default => throw new MapSourceRefusal("Insert rows (y) or columns (x), not '{$axis}'."), + }; + if ($count < 1 || $at < 0 || $at > $size) { + throw new MapSourceRefusal(sprintf('%s cannot take %d %s at %d; nothing was changed.', + $this->mapId, $count, $axis === 'y' ? 'rows' : 'columns', $at)); + } + + $occupancy = $this->getDeclaredPhysicalOccupancy(); + $layers = $this->copyEditableLayers(); + $layers->insertLines($axis, $at, $count); + if ($occupancy !== null) { + if ($axis === 'y') { + $beside = $occupancy[$at] ?? $occupancy[$at - 1] ?? []; + array_splice($occupancy, $at, 0, array_fill(0, $count, array_fill(0, count($beside), CollisionType::SOLID))); + } else { + foreach ($occupancy as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, $count, CollisionType::SOLID)); + } + } + unset($row); + } + } + $this->applyGridState($layers, $occupancy, static fn(int $line): ?int => $line >= $at ? $line + $count : $line, $axis); + } + + /** + * Removes rows `$at` to `$at + $count - 1` (axis `y`) or those columns + * (axis `x`) from every grid the map has: its terminal layers, its event + * layer and its tile layers. Declared physical occupancy loses the same + * cells; absent occupancy stays absent. Ragged rows follow terminal + * geometry: columns affect only rows reaching them. An event marker on a + * removed line refuses the removal, so no event is dropped or cut. Other + * data coordinates move through the project-wide line plan. + * + * @throws MapSourceRefusal When the lines are outside the map, would leave it empty, hold an event marker, or a tile layer cannot lose them; nothing is changed. + */ + public function removeLines(string $axis, int $at, int $count): void + { + $this->assertEditable(); + $size = match ($axis) { + 'x' => $this->getWidth(), + 'y' => $this->getHeight(), + default => throw new MapSourceRefusal("Remove rows (y) or columns (x), not '{$axis}'."), + }; + $noun = $axis === 'y' ? 'rows' : 'columns'; + if ($count < 1 || $at < 0 || $at + $count > $size) { + throw new MapSourceRefusal(sprintf('%s has no %s %d to %d to remove; nothing was changed.', $this->mapId, $noun, $at + 1, $at + $count)); + } + if ($count === $size) { + throw new MapSourceRefusal(sprintf('Removing every one of its %s would leave %s empty; nothing was changed.', $noun, $this->mapId)); + } + $markers = []; + for ($line = $at; $line < $at + $count; $line++) { + for ($other = 0, $extent = $axis === 'y' ? $this->getWidth() : $this->getHeight(); $other < $extent; $other++) { + [$x, $y] = $axis === 'y' ? [$other, $line] : [$line, $other]; + $marker = $this->hasLayerCell(MapLayers::EVENT, $x, $y) ? $this->getEventMarkerAt($x, $y) : null; + if ($marker !== null) { + $markers[] = sprintf('event %s at x %d, y %d', $marker, $x, $y); + } + } + } + if ($markers !== []) { + throw new MapSourceRefusal(sprintf('Move these off the removed %s first; nothing was changed: %s.', $noun, implode('; ', $markers))); + } - for ($y = $bounds['y']; $y < $maxY; $y++) { - for ($x = $bounds['x']; $x < $maxX; $x++) { - if ($this->getEventSymbol($x, $y) !== $marker) { - return false; + $occupancy = $this->getDeclaredPhysicalOccupancy(); + $layers = $this->copyEditableLayers(); + $layers->removeLines($axis, $at, $count); + if ($occupancy !== null) { + if ($axis === 'y') { + array_splice($occupancy, $at, $count); + } else { + foreach ($occupancy as &$row) { + if (count($row) > $at) { + array_splice($row, $at, $count); + } } + unset($row); } } + $this->applyGridState($layers, $occupancy, + static fn(int $line): ?int => $line < $at ? $line : ($line < $at + $count ? null : $line - $count), $axis); + } - return true; + /** @return list>|null Null means undeclared, never invalid. */ + private function getDeclaredPhysicalOccupancy(): ?array + { + if (! $this->hasMapDataField([MapPhysicalOccupancy::DATA_KEY])) { + return null; + } + try { + return new MapPhysicalOccupancy($this->editableData[MapPhysicalOccupancy::DATA_KEY], $this->layers->getLayerSet(), $this->mapId . ' occupancy') + ->exportDeclaration(); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); + } } + /** A shallow clone alone would still share the mutable EditableGrid objects. */ + private function copyEditableLayers(): MapLayers + { + $layers = clone $this->layers; + $layers->restoreSnapshot($this->layers->captureSnapshot()); + return $layers; + } + + /** Installs geometry and its declaration only after validation and source preflight. */ /** - * Updates a top-level map metadata field. - * - * @param string $field The field to update. - * @param mixed $value The new value. - * @return void + * @param (Closure(int): ?int)|null $moveLine Where a line edit moves each line on `$axis`, null for a removed + * one. The project-wide line plan ({@see MapLinePlanner}) moves the map's world objects with their lines, + * so they are judged where the plan leaves them. */ - public function setMapField(string $field, mixed $value): void + private function applyGridState(MapLayers $layers, mixed $occupancy, ?Closure $moveLine = null, string $axis = 'x'): void { $next = $this->editableData; - $next[$field] = $value; - $this->writeData($next); + if ($occupancy === null) { + unset($next[MapPhysicalOccupancy::DATA_KEY]); + } else { + try { + $next[MapPhysicalOccupancy::DATA_KEY] = new MapPhysicalOccupancy($occupancy, $layers->getLayerSet(), $this->mapId . ' occupancy') + ->exportDeclaration(); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage() . ' Nothing was changed.', previous: $error); + } + } + $this->assertWorldObjectState($layers, $moveLine === null ? $next : self::moveWorldObjects($next, $moveLine, $axis)); + if ($next !== $this->editableData) { + $document = $this->assertDataPreservable($next); + $next = $document->alignValue($next); + } + $this->layers = $layers; + $this->editableData = $next; + $this->cachedWidth = null; + $this->touchState(); } /** - * Resizes the map and its event overlay while preserving existing content. + * The map data with its world objects' anchors and covered cells moved + * as a line edit moves their lines. An object on a removed line is left + * out: the line plan refuses the removal, naming it. * - * @param int $width The new map width. - * @param int $height The new map height. - * @return void + * @param Closure(int): ?int $moveLine + * @return array */ - public function resize(int $width, int $height): void + private static function moveWorldObjects(array $data, Closure $moveLine, string $axis): array { - $width = max(1, $width); - $height = max(1, $height); - - if ($width === $this->getWidth() && $height === $this->getHeight()) { - return; + if (! is_array($data['worldObjects'] ?? null)) { + return $data; } - - foreach ($this->tileCells as $rowIndex => $row) { - $this->tileCells[$rowIndex] = array_slice($row, 0, $width); - - while (count($this->tileCells[$rowIndex]) < $width) { - $this->tileCells[$rowIndex][] = self::createBlankTileCell(); + $move = static function (mixed $point) use ($moveLine, $axis): mixed { + $key = $axis === 'y' ? 1 : 0; + $named = is_array($point) && array_key_exists($axis, $point); + $line = $named ? $point[$axis] : (is_array($point) ? ($point[$key] ?? null) : null); + if (! is_int($line)) { + return $point; } - } - - foreach ($this->eventCells as $rowIndex => $row) { - $this->eventCells[$rowIndex] = array_slice($row, 0, $width); - - while (count($this->eventCells[$rowIndex]) < $width) { - $this->eventCells[$rowIndex][] = ' '; + $moved = $moveLine($line); + if ($moved === null) { + return null; + } + $point[$named ? $axis : $key] = $moved; + + return $point; + }; + $objects = []; + foreach ($data['worldObjects'] as $index => $object) { + if (! is_array($object)) { + $objects[$index] = $object; + continue; + } + if (array_key_exists('anchor', $object)) { + $object['anchor'] = $move($object['anchor']); + } + if (is_array($object['covers']['cells'] ?? null)) { + $object['covers']['cells'] = array_map($move, $object['covers']['cells']); + } + if (($object['anchor'] ?? false) === null || in_array(null, $object['covers']['cells'] ?? [], true)) { + continue; } + $objects[$index] = $object; } + $data['worldObjects'] = array_is_list($data['worldObjects']) ? array_values($objects) : $objects; - $this->tileCells = array_slice($this->tileCells, 0, $height); - $this->eventCells = array_slice($this->eventCells, 0, $height); - - while (count($this->tileCells) < $height) { - $this->tileCells[] = self::createBlankTileRow($width); - } + return $data; + } - while (count($this->eventCells) < $height) { - $this->eventCells[] = array_fill(0, $width, ' '); - } + /** Layer/geometry edits cannot leave map-owned anchors or coverage dangling. */ + private function assertWorldObjectState(MapLayers $layers, array $data): void + { + if (!isset($data['worldObjects'])) { return; } + $candidate = clone $this; + $candidate->layers = $layers; + $candidate->editableData = $data; + $candidate->cachedWidth = null; + try { \Ichiloto\Editor\Field\WorldObjectAuthoring::validateEntries($candidate, $data['worldObjects'], false); } + catch (InvalidArgumentException $error) { throw new MapSourceRefusal('World-object ownership: ' . $error->getMessage() . ' Nothing was changed.', previous: $error); } + } - $this->cachedWidth = null; - $this->touchState(); + /** + * Every grid file's source as the map now holds it: terminal and event + * layers, and tile layers, keyed by path. + * + * @return array + */ + public function getGridSources(): array + { + return $this->layers->getSources(); } /** @@ -1105,6 +2609,7 @@ public function resize(int $width, int $height): void */ public function setEventField(string $marker, array $path, mixed $value): void { + $this->assertEditable(); if ($path === []) { return; } @@ -1120,6 +2625,7 @@ public function setEventField(string $marker, array $path, mixed $value): void if ($index === array_key_last($path)) { $reference[$segment] = $value; unset($reference); + $this->assertChangedInnPresentations($this->editableData, $next, complete: false); $this->writeData($next); return; } @@ -1132,6 +2638,49 @@ public function setEventField(string $marker, array $path, mixed $value): void } } + /** + * Removes an event's field at a path, leaving everything else it holds; + * nothing changes when it holds none there. + */ + public function removeEventField(string $marker, array $path): void + { + $this->assertEditable(); + if (! $this->hasEventField($marker, $path)) { + return; + } + + $next = $this->editableData; + $reference = &$next['events'][$marker]; + $last = array_pop($path); + + foreach ($path as $segment) { + $reference = &$reference[$segment]; + } + + unset($reference[$last]); + unset($reference); + $this->assertChangedInnPresentations($this->editableData, $next, complete: false); + $this->writeData($next); + } + + /** Incomplete picker drafts may be edited, but no incomplete descriptor can be saved. */ + private function assertChangedInnPresentations(array $old, array $next, bool $complete): void + { + foreach ((array) ($next['events'] ?? []) as $marker => $event) { + if (! is_array($event)) { continue; } + $before = is_array($old['events'][$marker] ?? null) ? $old['events'][$marker] : []; + if (str_ends_with(strval($event['class'] ?? ''), 'SleepEventTrigger')) { + $value = $event['data']['presentation'] ?? null; + if ($value !== ($before['data']['presentation'] ?? null)) { + \Ichiloto\Editor\Database\InnPresentationFields::assertValid($value, complete: $complete); + } + } + \Ichiloto\Editor\Database\InnPresentationFields::assertChangedCommandsValid($before, $event, complete: $complete); + } + \Ichiloto\Editor\Database\InnPresentationFields::assertChangedCommandsValid( + (array) ($old['npcs'] ?? []), (array) ($next['npcs'] ?? []), complete: $complete); + } + /** * Replaces the definition for the given event marker. * @@ -1141,6 +2690,7 @@ public function setEventField(string $marker, array $path, mixed $value): void */ public function setEventDefinition(string $marker, array $definition): void { + $this->assertEditable(); $next = $this->editableData; if (! isset($next['events']) || ! is_array($next['events'])) { @@ -1160,6 +2710,7 @@ public function setEventDefinition(string $marker, array $definition): void */ public function removeEventDefinition(string $marker): void { + $this->assertEditable(); if (! isset($this->editableData['events'][$marker])) { return; } @@ -1170,37 +2721,59 @@ public function removeEventDefinition(string $marker): void } /** - * Updates the rectangular bounds of an event marker. + * Repaints an event marker as exactly the given rectangle. + * + * Refused, leaving the grid unchanged, when the rectangle is empty, + * leaves the event layer or covers another marker's cell: the bounds an + * author asks for are the bounds the event gets, never a clamped or + * overwriting approximation of them. * * @param string $marker The event marker. * @param int $x The left coordinate. * @param int $y The top coordinate. * @param int $width The marker width. * @param int $height The marker height. - * @return void + * @return string|null Why the change was refused, or null when it was made. */ - public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): void + public function setEventBounds(string $marker, int $x, int $y, int $width, int $height): ?string { - foreach ($this->eventCells as $rowIndex => $row) { + $this->assertEditable(); + + if ($width < 1 || $height < 1) { + return sprintf('Marker %s needs a size of at least 1x1, not %dx%d.', $marker, $width, $height); + } + + for ($row = $y; $row < $y + $height; $row++) { + for ($column = $x; $column < $x + $width; $column++) { + if (! $this->hasLayerCell(MapLayers::EVENT, $column, $row)) { + return sprintf('Marker %s would leave the map at (%d, %d).', $marker, $column, $row); + } + + $occupant = $this->getEventSymbol($column, $row); + + if ($occupant !== $marker && trim($occupant) !== '') { + return sprintf('Marker %s would cover marker %s at (%d, %d).', $marker, $occupant, $column, $row); + } + } + } + + foreach ($this->layers->getEventGrid()->getSymbols() as $rowIndex => $row) { foreach ($row as $columnIndex => $symbol) { if ($symbol === $marker) { - $this->eventCells[$rowIndex][$columnIndex] = ' '; + $this->layers->getEventGrid()->cells[$rowIndex][$columnIndex]['symbol'] = ' '; } } } - $maxX = max(0, min($this->getWidth() - 1, $x + max(1, $width) - 1)); - $maxY = max(0, min($this->getHeight() - 1, $y + max(1, $height) - 1)); - - for ($row = max(0, $y); $row <= $maxY; $row++) { - for ($column = max(0, $x); $column <= $maxX; $column++) { - if (isset($this->eventCells[$row][$column])) { - $this->eventCells[$row][$column] = $marker; - } + for ($row = $y; $row < $y + $height; $row++) { + for ($column = $x; $column < $x + $width; $column++) { + $this->layers->getEventGrid()->cells[$row][$column]['symbol'] = $marker; } } $this->touchState(); + + return null; } /** @@ -1232,115 +2805,129 @@ public function willMoveOnSave(): bool */ public function save(?callable $backup = null, ?FileSetOperations $files = null): string { + $target = $this->resolveSaveTarget(); + SaveSet::commitOne($target['directory'], $this->prepareSave(), $backup, $files); + + return $target['mapId']; + } + + /** + * Works out this map's save without writing it: the members that + * changed, and the proof that the staged data file evaluates to exactly + * the map being saved -- run where the game would run it, so relative + * requires resolve. {@see SaveSet} installs it, alone or with the rest of + * a change set. A map with nothing to write still proves its sources + * are the ones it loaded. + * + * @throws MapSourceRefusal When the map's sources cannot take its edits; nothing is written. + */ + public function prepareSave(): PreparedSave + { + $this->assertEditable(); + if (($this->loadedData['worldObjects'] ?? null) !== ($this->editableData['worldObjects'] ?? null)) { + try { + \Ichiloto\Editor\Field\WorldObjectAuthoring::validateEntries($this, + \Ichiloto\Editor\Field\WorldObjectAuthoring::readEntries($this)); + } catch (\InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + } + $this->assertChangedInnPresentations($this->loadedData, $this->editableData, complete: true); + \Ichiloto\Editor\Database\MovementRouteFields::assertChangedCommandsValid($this->loadedData, $this->editableData); $target = $this->resolveSaveTarget(); $moving = $target['directory'] !== $this->directory; + $this->assertGridSourcesCanonical(); + $label = sprintf('map %s', $this->mapId); + $expectLoaded = fn(FileSetTransaction $transaction) => $this->expectLoadedSources($transaction); $tripletExists = is_file($this->dataPath) - && is_file($this->mapPath) + && count($this->layers->getSources()) > 0 && is_file($this->eventPath); if (! $this->isDirty() && ! $moving && is_dir($this->directory) && $tripletExists) { // Nothing diverges from the last save: writing would only // canonicalize hand-authored formatting and churn mtimes. - return $target['mapId']; + return new PreparedSave($label, $expectLoaded); } - $this->assertGridsAgree(); + $this->validateLayerContracts(); // Which members actually changed. The tile and event files compare // against the grids as of the last save, never against the bytes on - // disk, so a file an author keeps in another form -- a tile map - // built by a helper class -- is rewritten only when its grid is. - // A data file the parser cannot hold never reaches the writer unless - // the on-disk member disappeared: its edits were refused, so its - // cached source bytes are its own and are restored unchanged. + // disk. Existing grid sources have already passed the literal-nowdoc + // check, so an edited grid cannot silently flatten authored PHP. + // A data file the parser cannot hold keeps its original bytes; + // source freshness is checked by the transaction before writing. $dataSource = $this->dataDocument === null ? (string) $this->unparsedDataSource : $this->proposedDataSource(); $writeData = ! is_file($this->dataPath) - || ($this->dataDocument !== null && $dataSource !== $this->dataDocument->source); + || $dataSource !== $this->baselineDataSource; $mapPayload = $this->buildMapPayload(); - $writeMap = $mapPayload !== $this->baselineMapPayload || ! is_file($this->mapPath); + $writeMap = $this->isDirty(); $eventPayload = $this->buildEventPayload(); $writeEvent = $eventPayload !== $this->baselineEventPayload || ! is_file($this->eventPath); if (! $moving && ! $writeData && ! $writeMap && ! $writeEvent) { // Dirty by fingerprint but identical in content: a same-value // round trip. Clean without writing. - $this->adoptWritten($dataSource, $mapPayload, $eventPayload); - - return $target['mapId']; - } - - $transaction = new FileSetTransaction($target['directory'], $files ?? new FilesystemFileSetOperations()); - - if ($moving) { - // A move installs the complete triplet at the new path and takes - // the old one away, as one operation. - $transaction->write($target['dataPath'], $dataSource); - $transaction->write($target['mapPath'], $mapPayload); - $transaction->write($target['eventPath'], $eventPayload); - $transaction->remove($this->dataPath); - $transaction->remove($this->mapPath); - $transaction->remove($this->eventPath); - } else { - if ($writeData) { - $transaction->write($target['dataPath'], $dataSource); - } - - if ($writeMap) { - $transaction->write($target['mapPath'], $mapPayload); - } - - if ($writeEvent) { - $transaction->write($target['eventPath'], $eventPayload); - } + return new PreparedSave($label, $expectLoaded, adopt: fn() => $this->adoptWritten($dataSource, $mapPayload, $eventPayload)); } - // Stage everything beside its destination, then prove the staged - // data file evaluates to exactly the map being saved -- run where - // the game would run it, so relative requires resolve. - $staged = $transaction->stage(); - - if ($writeData || $moving) { - try { - $evaluatedFingerprint = null; - $evaluated = $this->evaluateFile($staged[$target['dataPath']] ?? $target['dataPath'], $evaluatedFingerprint); - } catch (\Throwable $throwable) { - $transaction->rollBack(); - - throw new RuntimeException(sprintf( - '%s: the rewritten %s could not be evaluated (%s); nothing was written.', - $this->mapId, - basename($target['dataPath']), - $throwable->getMessage(), - ), previous: $throwable); - } - - if (! is_array($evaluated) || $evaluatedFingerprint !== PhpDataFile::valueFingerprint($this->editableData)) { - $transaction->rollBack(); - - throw new RuntimeException(sprintf( - '%s: the rewritten %s would not read back as the edited map; nothing was written.', - $this->mapId, - basename($target['dataPath']), - )); - } - } + return new PreparedSave( + $label, + function (FileSetTransaction $transaction) use ($expectLoaded, $target, $moving, $writeData, $dataSource): void { + $expectLoaded($transaction); + if ($writeData || $moving) { + $transaction->write($target['dataPath'], $dataSource); + } + $this->layers->stageChanges($transaction, $moving ? $target['directory'] : null, $moving ? basename($target['directory']) : null, $moving); + if ($moving) { + $transaction->remove($this->dataPath); + } + }, + function (array $staged) use ($target, $moving, $writeData): void { + if (! $writeData && ! $moving) { + return; + } - $transaction->commit($backup); + try { + $evaluatedFingerprint = null; + $evaluated = $this->evaluateFile($staged[$target['dataPath']] ?? $target['dataPath'], $evaluatedFingerprint); + } catch (\Throwable $throwable) { + throw new RuntimeException(sprintf( + '%s: the rewritten %s could not be evaluated (%s); nothing was written.', + $this->mapId, + basename($target['dataPath']), + $throwable->getMessage(), + ), previous: $throwable); + } - if ($moving) { - // The transaction removed the triplet; the folder follows only - // when nothing else of the author's lives in it. - if (is_dir($this->directory) && array_diff(scandir($this->directory) ?: [], ['.', '..']) === []) { - @rmdir($this->directory); - } + if (! is_array($evaluated) || $evaluatedFingerprint !== PhpDataFile::valueFingerprint($this->editableData)) { + throw new RuntimeException(sprintf( + '%s: the rewritten %s would not read back as the edited map; nothing was written.', + $this->mapId, + basename($target['dataPath']), + )); + } + }, + function () use ($target, $moving, $dataSource, $mapPayload, $eventPayload): void { + if ($this->layers->legacy && is_dir($target['directory'] . '/layers') + && array_diff(scandir($target['directory'] . '/layers') ?: [], ['.', '..']) === []) { + @rmdir($target['directory'] . '/layers'); + } - self::deleteEmptyParentDirectories(dirname($this->directory), $this->getMapsRoot()); - } + if ($moving) { + // The transaction removed the triplet; the folder follows only + // when nothing else of the author's lives in it. + if (is_dir($this->directory) && array_diff(scandir($this->directory) ?: [], ['.', '..']) === []) { + @rmdir($this->directory); + } - $this->adoptWritten($dataSource, $mapPayload, $eventPayload); + self::deleteEmptyParentDirectories(dirname($this->directory), $this->getMapsRoot()); + } - return $target['mapId']; + $this->adoptWritten($dataSource, $mapPayload, $eventPayload); + }, + ); } /** @@ -1348,10 +2935,13 @@ public function save(?callable $backup = null, ?FileSetOperations $files = null) */ private function adoptWritten(string $dataSource, string $mapPayload, string $eventPayload): void { - $this->adoptDataSource($dataSource); + // Saving moves the checkpoint, not the rewrite basis. History still + // needs removed expressions, references and comments for exact undo. + $this->baselineDataSource = $dataSource; $this->loadedData = $this->editableData; $this->baselineMapPayload = $mapPayload; $this->baselineEventPayload = $eventPayload; + $this->layers->captureBaseline(); $this->captureBaseline(); } @@ -1360,12 +2950,16 @@ private function adoptWritten(string $dataSource, string $mapPayload, string $ev */ private function assertGridsAgree(): void { - if (count($this->eventCells) !== count($this->tileCells)) { + if (! $this->layers->legacy) { + $this->layers->assertDimensions(); + return; + } + if (count($this->layers->getEventGrid()->getSymbols()) !== count($this->layers->getBaseGrid()->cells)) { throw new RuntimeException(sprintf( '%s: the event layer is %d rows but the map is %d; nothing was written.', $this->mapId, - count($this->eventCells), - count($this->tileCells), + count($this->layers->getEventGrid()->getSymbols()), + count($this->layers->getBaseGrid()->cells), )); } } @@ -1417,15 +3011,17 @@ private function evaluateFiles(array $paths, ?array &$fingerprints = null): arra * @param int $height The map height. * @return void */ - public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 48, int $height = 18, ?FileSetOperations $files = null): void + public static function createBlank(string $directory, string $baseName, string $displayName, int $width = 48, int $height = 18, ?FileSetOperations $files = null, ?string $kind = null): void { $blankTileLine = str_repeat(' ', $width); $blankEventLine = str_repeat(' ', $width); $tileText = implode(PHP_EOL, array_fill(0, $height, $blankTileLine)); $eventText = implode(PHP_EOL, array_fill(0, $height, $blankEventLine)); + // A map is born with its kind, the tileset its tiles and pieces come from. $data = [ 'name' => $displayName, 'region' => '', + ...($kind === null ? [] : ['tileset' => $kind]), 'description' => '', 'triggers' => [], 'events' => [], @@ -1442,11 +3038,11 @@ public static function createBlank(string $directory, string $baseName, string $ ); $transaction->write( $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php', - "write( $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php', - "commit(); } @@ -1461,6 +3057,8 @@ public static function createBlank(string $directory, string $baseName, string $ */ public function duplicateTo(string $directory, string $baseName, string $displayName, ?FileSetOperations $files = null): void { + $this->assertEditable(); + $this->assertGridSourcesCanonical(); // The copy keeps everything the original authored -- comments, // expressions, formatting -- with only the display name rewritten. // A source the editor cannot rewrite reversibly refuses the @@ -1480,7 +3078,7 @@ public function duplicateTo(string $directory, string $baseName, string $display $duplicatedData['name'] = $displayName; try { - $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->loadedData, $duplicatedData)->source; + $dataSource = ArraySourceWriter::rewrite($this->dataDocument, $this->sourceData, $duplicatedData)->source; } catch (SourcePreservationRefusal $refusal) { throw new MapSourceRefusal(sprintf( '%s: %s — %s Nothing was duplicated.', @@ -1490,21 +3088,11 @@ public function duplicateTo(string $directory, string $baseName, string $display ), previous: $refusal); } - $transaction = new FileSetTransaction($directory, $files ?? new FilesystemFileSetOperations()); + $transaction = $this->createSourceTransaction($directory, $files); $duplicatedDataPath = $directory . DIRECTORY_SEPARATOR . $baseName . '.data.php'; $transaction->write($duplicatedDataPath, $dataSource); - $transaction->write( - $directory . DIRECTORY_SEPARATOR . $baseName . '.map.php', - $this->buildMapPayload() === $this->baselineMapPayload && is_file($this->mapPath) - ? (string) file_get_contents($this->mapPath) - : $this->buildMapPayload(), - ); - $transaction->write( - $directory . DIRECTORY_SEPARATOR . $baseName . '.event.php', - $this->buildEventPayload() === $this->baselineEventPayload && is_file($this->eventPath) - ? (string) file_get_contents($this->eventPath) - : $this->buildEventPayload(), - ); + $this->validateLayerContracts(); + $this->layers->stageChanges($transaction, $directory, $baseName); // The staged copy must evaluate to exactly the duplicate's content // where it will live, before anything is installed. @@ -1541,11 +3129,11 @@ public function duplicateTo(string $directory, string $baseName, string $display * * @return string[] */ - public function getCharacterPalette(): array + public function getCharacterPalette(?string $layer = null): array { $symbols = []; - foreach ($this->tileCells as $row) { + foreach (($layer === null ? $this->layers->getBaseGrid() : $this->layers->getGrid($layer))->cells as $row) { foreach ($row as $cell) { $symbol = $cell['symbol']; @@ -1557,7 +3145,7 @@ public function getCharacterPalette(): array } } - foreach ($this->eventCells as $row) { + foreach ($this->layers->getEventGrid()->getSymbols() as $row) { foreach ($row as $symbol) { if (trim($symbol) === '') { continue; @@ -1586,76 +3174,12 @@ private static function splitMapText(string $text): array } /** - * Parses formatted tile-map lines into editable cells. - * - * @param string[] $lines The raw tile-map lines. - * @return array> - */ - private static function parseStyledLines(array $lines): array - { - return array_map(static function (string $line): array { - preg_match_all('/<[^>]+>|[^<]+/u', $line, $matches); - $segments = $matches[0] ?? []; - $cells = []; - $activePrefix = ''; - - foreach ($segments as $segment) { - if (preg_match('/^<[^\/][^>]*>$/u', $segment) === 1) { - $activePrefix .= $segment; - continue; - } - - if (preg_match('/^<\/[^>]*>$/u', $segment) === 1 || $segment === '') { - if ($cells !== []) { - $cells[array_key_last($cells)]['suffix'] .= $segment; - } - - $activePrefix = ''; - continue; - } - - foreach (self::toSymbols($segment) as $index => $symbol) { - $cells[] = [ - 'symbol' => $symbol, - 'prefix' => $index === 0 ? $activePrefix : '', - 'suffix' => '', - ]; - } - } - - return $cells; - }, $lines); - } - - /** - * Parses plain event-map lines into editable symbols. - * - * @param string[] $lines The raw event-map lines. - * @return array> - */ - private static function parsePlainLines(array $lines): array - { - return array_map(static fn(string $line): array => self::toSymbols($line), $lines); - } - - /** - * Strips text-formatting tags from a map line. - * - * @param string $line The formatted line. - * @return string - */ - private static function stripFormatting(string $line): string - { - return preg_replace('/<[^>]+>/', '', $line) ?? $line; - } - - /** - * Normalizes input down to a single visible symbol. + * Normalizes input down to the single visible symbol a cell holds. * * @param string $symbol The raw input symbol. * @return string */ - private static function normalizeSymbol(string $symbol): string + public static function normalizeSymbol(string $symbol): string { $symbols = self::toSymbols($symbol); @@ -1679,53 +3203,15 @@ private static function toSymbols(string $line): array return $matches[0] ?? []; } - /** - * Rebuilds a formatted tile row from editable cells. - * - * @param array $cells The tile cells. - * @return string - */ - private function buildStyledLine(array $cells): string - { - $line = ''; - - foreach ($cells as $cell) { - $line .= $cell['prefix'] . $cell['symbol'] . $cell['suffix']; - } - - return $line; - } - - /** - * Rebuilds a plain row from editable symbols. - * - * @param array $cells The plain symbols. - * @return string - */ - private function buildPlainLine(array $cells): string - { - return implode('', $cells); - } - - /** - * The content fingerprint compares canonical exports, deliberately not - * the source writer's output: what makes a map dirty is its content, - * and a map whose authored source cannot even be parsed must still - * fingerprint cleanly so its grids stay saveable. - */ - /** * The tile file the current grid should be saved as. * - * The tile and event files are the editor's own format -- a heredoc - * grid -- so a *changed* grid is written canonically; an untouched grid - * is never written at all, whatever form its author kept it in. + * A changed grid is written as a nowdoc; an untouched canonical source + * keeps its exact bytes. */ private function buildMapPayload(): string { - return "buildStyledLine(...), $this->tileCells)) - . "\nICHILOTO_MAP;\n"; + return $this->layers->getBaseGrid()->getSource(); } /** @@ -1733,9 +3219,16 @@ private function buildMapPayload(): string */ private function buildEventPayload(): string { - return "buildPlainLine(...), $this->eventCells)) - . "\nICHILOTO_EVENT_MAP;\n"; + return $this->layers->getEventGrid()->getSource(); + } + + /** The metadata-derived path proposed by an explicit move, never an ordinary save. */ + public function getProposedMapId(): string + { + $name = self::slugify($this->getDisplayName(), basename($this->directory)); + $region = self::slugify($this->getRegion(), ''); + + return $region !== '' ? $region . '/' . $name : $name; } /** @@ -1747,8 +3240,7 @@ protected function buildPersistedPayload(): string // its old self even when every cell matches. return $this->mapId . "\0" . self::exportPhpValue($this->editableData) - . "\0" . $this->buildMapPayload() - . $this->buildEventPayload(); + . "\0" . serialize($this->layers->getSources()); } /** @@ -1762,12 +3254,16 @@ protected function buildPersistedPayload(): string * event identities that name the old id keep naming it, which is the * caller's warning to give. * - * @param string $newRelativeId The new project-relative map id. + * @param string $newRelativeId The new project-relative map id, a map + * folder path nothing stands at yet ({@see MapFolder}). * @return self The relocated map, freshly loaded from its new path; the * caller replaces this instance with it. + * @throws RuntimeException When the id is not a map folder path, its + * folder is taken, or the move fails (rolled back). */ public function moveTo(string $newRelativeId): self { + $this->assertEditable(); $newRelativeId = trim(str_replace('\\', '/', $newRelativeId), '/ '); if ($newRelativeId === '') { @@ -1778,11 +3274,13 @@ public function moveTo(string $newRelativeId): self return $this; } - $mapsRoot = $this->getMapsRoot(); - $directory = $mapsRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $newRelativeId); + $this->assertGridSourcesCanonical(); - if (is_dir($directory)) { - throw new RuntimeException("Map path {$newRelativeId} already exists."); + $mapsRoot = $this->getMapsRoot(); + try { + $directory = MapFolder::requireFreeFolder($mapsRoot, $newRelativeId); + } catch (InvalidArgumentException $error) { + throw new RuntimeException($error->getMessage(), previous: $error); } $previousDirectory = $this->directory; @@ -1790,7 +3288,7 @@ public function moveTo(string $newRelativeId): self $baseName = basename($directory); try { - $transaction = new FileSetTransaction($directory, reserveFolder: true); + $transaction = $this->createSourceTransaction($directory, reserveFolder: true); // A move carries the map's content as authored: the data file // is the preserved source (unsaved edits rewritten into it, not // a regeneration of the whole array), and untouched grids keep @@ -1802,28 +3300,17 @@ public function moveTo(string $newRelativeId): self $movedDataPath, $this->dataDocument === null ? (string) $this->unparsedDataSource : $this->proposedDataSource(), ); - $transaction->write( - $movedMapPath, - $this->buildMapPayload() === $this->baselineMapPayload && is_file($this->mapPath) - ? (string) file_get_contents($this->mapPath) - : $this->buildMapPayload(), - ); - $transaction->write( - $movedEventPath, - $this->buildEventPayload() === $this->baselineEventPayload && is_file($this->eventPath) - ? (string) file_get_contents($this->eventPath) - : $this->buildEventPayload(), - ); + $this->validateLayerContracts(); + $this->layers->stageChanges($transaction, $directory, $baseName, true); $transaction->remove($this->dataPath); - $transaction->remove($this->mapPath); - $transaction->remove($this->eventPath); - - $destinationPaths = [$movedDataPath, $movedMapPath, $movedEventPath]; - $expectedGrids = [ - $movedMapPath => array_map($this->buildStyledLine(...), $this->tileCells), - $movedEventPath => array_map($this->buildPlainLine(...), $this->eventCells), - ]; + $expectedGrids = $this->layers->getSources($directory, $baseName); $sourceDirectoryMetadata = self::captureDirectoryMetadata($previousDirectory); + $memberDirectoryMetadata = []; + foreach (['layers', MapGraphics::DIRECTORY] as $member) { + if (is_dir($previousDirectory . '/' . $member)) { + $memberDirectoryMetadata[] = self::captureDirectoryMetadata($previousDirectory . '/' . $member); + } + } $removedSourceDirectories = []; $directoryRestorationFailures = []; @@ -1832,49 +3319,37 @@ public function moveTo(string $newRelativeId): self // the state the game will load: no preview copies, temporary // neighbours, or old member can make an invalid move look valid. $transaction->commit(validate: function () use ( - $destinationPaths, $movedDataPath, - $movedMapPath, $movedEventPath, $expectedGrids, $newRelativeId, - $previousDirectory, $mapsRoot, $sourceDirectoryMetadata, + $memberDirectoryMetadata, &$removedSourceDirectories, &$directoryRestorationFailures, ): void { - $removedSourceDirectories = self::removeEmptyDirectoryChain( - $previousDirectory, + $removedSourceDirectories = self::removeEmptySourceDirectories( + $memberDirectoryMetadata, $mapsRoot, $sourceDirectoryMetadata, ); try { try { - $evaluatedFingerprints = []; - [$evaluated, $evaluatedMap, $evaluatedEvent] = $this->evaluateFiles($destinationPaths, $evaluatedFingerprints); + $evaluatedFingerprint = null; + $evaluated = $this->evaluateFile($movedDataPath, $evaluatedFingerprint); } catch (\Throwable $evaluationFailure) { - $failedPath = $movedDataPath; - - if ($evaluationFailure instanceof IsolatedPhpEvaluationFailure) { - $failedIndex = array_search($evaluationFailure->path, $destinationPaths, true); - - if (is_int($failedIndex)) { - $failedPath = $destinationPaths[$failedIndex]; - } - } - throw new RuntimeException(sprintf( - '%s does not evaluate at %s (%s) — an expression written against the old folder depth, such as a relative require, must be adjusted in the file first.', - basename($failedPath), + '%s does not evaluate at %s (%s) - an expression written against the old folder depth, such as a relative require, must be adjusted in the file first.', + basename($movedDataPath), $newRelativeId, $evaluationFailure->getMessage(), ), previous: $evaluationFailure); } if (! is_array($evaluated) - || ($evaluatedFingerprints[0] ?? null) !== PhpDataFile::valueFingerprint($this->editableData) + || $evaluatedFingerprint !== PhpDataFile::valueFingerprint($this->editableData) ) { throw new RuntimeException(sprintf( '%s would not read back as this map at %s.', @@ -1883,22 +3358,20 @@ public function moveTo(string $newRelativeId): self )); } - $evaluatedGrids = [ - $movedMapPath => $evaluatedMap, - $movedEventPath => $evaluatedEvent, - ]; - - foreach ($expectedGrids as $path => $expectedLines) { - $evaluatedGrid = $evaluatedGrids[$path]; - - if (! is_string($evaluatedGrid) || self::splitMapText($evaluatedGrid) !== $expectedLines) { - throw new RuntimeException(sprintf( - '%s would not read back as this map at %s.', - basename($path), - $newRelativeId, - )); + // Terminal grids must read back as literal grids; tile + // layers are carried as bytes, readable or not, so they + // must hold exactly the bytes that were moved. + foreach ($expectedGrids as $path => $expectedSource) { + if (dirname($path) === dirname($movedDataPath) . '/' . MapGraphics::DIRECTORY + ? @file_get_contents($path) !== $expectedSource + : MapGridSource::readFile($path) !== MapGridSource::parseSource($expectedSource, $path)) { + throw new RuntimeException(basename($path) . ' would not read back as this map at ' . $newRelativeId); } } + $set = MapLayerSource::loadFromDirectory(dirname($movedDataPath), $newRelativeId); + if (! $set->legacy) { + $set->assertMatchingGrid(MapLayer::parseGrid(MapGridSource::readFile($movedEventPath)), $movedEventPath); + } } catch (\Throwable $validationFailure) { // The file rollback needs the old directories in place. // Recreate only directories this validation removed; the @@ -1949,16 +3422,12 @@ public function moveTo(string $newRelativeId): self ), previous: $throwable); } - return new self( - mapId: $newRelativeId, - directory: $directory, - dataPath: $movedDataPath, - mapPath: $movedMapPath, - eventPath: $movedEventPath, - data: $this->editableData, - tileLines: $expectedGrids[$movedMapPath], - eventLines: $expectedGrids[$movedEventPath], - dataSource: (string) file_get_contents($movedDataPath), + $set = MapLayerSource::loadFromDirectory($directory, $newRelativeId); + $eventText = MapGridSource::readFile($movedEventPath); + return new self($newRelativeId, $directory, $movedDataPath, $movedMapPath, $movedEventPath, + $this->editableData, self::splitMapText($set->layers[0]->text), self::splitMapText($eventText), + (string) file_get_contents($movedDataPath), + layers: MapLayers::createFromSource($directory, $set, $movedEventPath, $eventText), )->withLoadedBaseline(); } @@ -1990,13 +3459,9 @@ private function resolveSaveTarget(): array } $mapsRoot = $this->getMapsRoot(); - $baseName = self::slugify($this->getDisplayName(), basename($this->directory)); - // An empty region must stay empty — falling back to the default slug - // would silently relocate region-less maps into a "new-map" folder. - $region = self::slugify($this->getRegion(), ''); - $relativePath = $region !== '' ? $region . DIRECTORY_SEPARATOR . $baseName : $baseName; - $directory = $mapsRoot . DIRECTORY_SEPARATOR . $relativePath; - $mapId = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath); + $mapId = $this->getProposedMapId(); + $baseName = basename($mapId); + $directory = $mapsRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $mapId); if (is_dir($directory)) { throw new RuntimeException("Map path {$mapId} already exists."); @@ -2048,6 +3513,33 @@ private static function deleteEmptyParentDirectories(string $directory, string $ } } + /** + * Removes a moved map's emptied member directories (`layers/`, + * `graphics/`), then its source directory and empty parents. + * + * @param list $memberDirectoryMetadata Metadata captured before source members are removed. + * @param array{path: string, mode: int, owner: int, group: int, modifiedAt: int, accessedAt: int, device: int, inode: int} $sourceDirectoryMetadata + * @return list The removed directories, leaf first. + */ + private static function removeEmptySourceDirectories(array $memberDirectoryMetadata, string $mapsRoot, array $sourceDirectoryMetadata): array + { + $removed = []; + + foreach ($memberDirectoryMetadata as $metadata) { + $entries = is_dir($metadata['path']) ? scandir($metadata['path']) : false; + + // A member directory holding anything else is its author's. + if (is_array($entries) && array_diff($entries, ['.', '..']) === [] && @rmdir($metadata['path'])) { + $removed[] = $metadata; + } + } + + return [ + ...$removed, + ...self::removeEmptyDirectoryChain($sourceDirectoryMetadata['path'], $mapsRoot, $sourceDirectoryMetadata), + ]; + } + /** * Removes the empty source directory and its empty parents for a move. * @@ -2268,7 +3760,7 @@ private static function directoryHasContents(string $directory): bool * @param string $fallback The fallback slug. * @return string */ - private static function slugify(string $value, string $fallback = 'new-map'): string + public static function slugify(string $value, string $fallback = 'new-map'): string { $value = strtolower(trim($value)); $value = preg_replace('/[^a-z0-9]+/i', '-', $value) ?? ''; diff --git a/src/ProjectQuest.php b/src/ProjectQuest.php index 6ce9210b..c78d89c4 100644 --- a/src/ProjectQuest.php +++ b/src/ProjectQuest.php @@ -4,66 +4,21 @@ namespace Ichiloto\Editor; -use Ichiloto\Editor\History\TracksPersistedState; - use Ichiloto\Editor\Database\ConditionCodec; use Ichiloto\Engine\Quests\QuestObjectiveType; /** - * One editable quest entry backed by its raw quests.php payload. - * - * The payload array is kept verbatim so untouched keys (optional rewards, - * prerequisites, omitted objective quantities) round-trip losslessly. + * A quest record as validation and the terminal's journal panes read it. The + * record itself is edited through the shared record database (the `quests` + * schema); this only reads it, the way the engine's `Quest::fromArray` does. */ -final class ProjectQuest +final readonly class ProjectQuest { - use TracksPersistedState; - - /** - * @param array $payload The raw quest entry. - * @param bool $isDirty Whether the quest holds unsaved edits. - */ - public function __construct( - private array $payload, - bool $isDirty = false, - ) { - if (! $isDirty) { - $this->captureBaseline(); - } - } - /** - * Creates a blank quest with one placeholder objective. - * - * @param string $id The quest id. - * @param string $name The quest name. - * @return self + * @param array $payload The quest entry as quests.php holds it. */ - public static function createBlank(string $id, string $name = 'New Quest'): self + public function __construct(private array $payload) { - return new self([ - 'id' => $id, - 'name' => $name, - 'description' => '', - 'giver' => '', - 'objectives' => [ - ['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target'], - ], - ], true); - } - - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return serialize($this->payload); - } - - public function markClean(): void - { - $this->captureBaseline(); } public function getId(): string @@ -96,6 +51,16 @@ public function getObjectives(): array return is_array($objectives) ? array_values(array_filter($objectives, is_array(...))) : []; } + /** + * The quest's start waymarks as authored, where it leads the player before it starts. + * + * @return mixed A list of waymarks, or whatever malformed value the file holds, for validation to report. + */ + public function getStartWaymarks(): mixed + { + return $this->payload['startWaymarks'] ?? []; + } + /** * @return array */ @@ -116,89 +81,41 @@ public function getRewardExperience(): int return max(0, intval($this->getRewards()['experience'] ?? 0)); } - /** - * Returns the reward item names as a comma-separated string. - * - * @return string - */ - public function getRewardItemsString(): string - { - return implode(', ', $this->getRewardItems()); - } - - /** - * @return string[] The reward item names, as the engine's store resolves them. - */ - public function getRewardItems(): array + /** The Mastery points (SP) the quest gives every travelling member. */ + public function getRewardSkillPoints(): int { - return array_values(array_filter( - array_map(strval(...), (array) ($this->getRewards()['items'] ?? [])), - static fn(string $item): bool => trim($item) !== '' - )); + return max(0, intval($this->getRewards()['skillPoints'] ?? 0)); } /** - * Stores the reward item list, dropping the key when it empties. - * - * @param string[] $items The item names. - * @return void + * Returns the reward items as a comma-separated string. */ - public function setRewardItems(array $items): void + public function getRewardItemsString(): string { - $items = array_values(array_filter( - array_map(static fn(mixed $item): string => trim(strval($item)), $items), - static fn(string $item): bool => $item !== '' - )); - $rewards = $this->getRewards(); - - if ($items === []) { - unset($rewards['items']); - } else { - $rewards['items'] = $items; - } - - $this->setRewards($rewards); + return implode(', ', $this->getRewardItems()); } /** - * Adds a reward item slot after the given one, or at the end. + * The reward items as the journal lists them: each item's name, with + * its quantity when it gives more than one. The engine reads an item + * either as a bare name or as `['item' => ..., 'quantity' => ...]`. * - * @param int|null $afterIndex The slot to insert after. - * @return int The new slot's index. + * @return string[] */ - public function addRewardItem(?int $afterIndex = null): int + public function getRewardItems(): array { - $items = $this->getRewardItems(); - $position = $afterIndex === null ? count($items) : min(count($items), $afterIndex + 1); - - // A placeholder name, so the slot exists to be picked into; the - // picker replaces it before anything resolves it. - array_splice($items, $position, 0, ['S-Potion']); - $rewards = $this->getRewards(); - $rewards['items'] = $items; - $this->setRewards($rewards); + $items = []; - return $position; - } - - /** - * Removes a reward item slot. - * - * @param int $index The slot. - * @return string|null The removed name, or null when the slot is unknown. - */ - public function removeRewardItem(int $index): ?string - { - $items = $this->getRewardItems(); + foreach ((array) ($this->getRewards()['items'] ?? []) as $item) { + $name = trim(strval(is_array($item) ? ($item['item'] ?? '') : $item)); + $quantity = is_array($item) ? max(1, intval($item['quantity'] ?? 1)) : 1; - if (! array_key_exists($index, $items)) { - return null; + if ($name !== '') { + $items[] = $quantity > 1 ? sprintf('%s x%d', $name, $quantity) : $name; + } } - [$removed] = array_splice($items, $index, 1); - $this->setRewardItems($items); - - return $removed; + return $items; } /** @@ -211,132 +128,6 @@ public function getPrerequisites(): array return is_array($prerequisites) ? array_values(array_filter($prerequisites, is_array(...))) : []; } - /** - * Applies one flat settings-field edit. - * - * Objective fields arrive as - * `objective{index}{Type|Target|Quantity|Description|RevealedDescription|RevealConditions}` - * with a zero-based objective index. - * - * @param string $field The field identifier. - * @param mixed $value The edited value. - * @return void - */ - public function setField(string $field, mixed $value): void - { - if (preg_match('/^objective(\d+)(Type|Target|Quantity|Description|RevealedDescription|RevealConditions)$/', $field, $matches) === 1) { - $this->setObjectiveField(intval($matches[1]), $matches[2], $value); - return; - } - - if (preg_match('/^rewardItem(\d+)$/', $field, $matches) === 1) { - $items = $this->getRewardItems(); - $items[intval($matches[1])] = trim(strval($value)); - $this->setRewardItems($items); - return; - } - - switch ($field) { - case 'id': - $id = trim(strval($value)); - - if ($id !== '') { - $this->payload['id'] = $id; - $this->touchState(); - } - - return; - case 'name': - case 'giver': - case 'description': - $this->payload[$field] = trim(strval($value)); - $this->touchState(); - return; - case 'rewardGold': - $this->setRewardAmount('gold', max(0, intval($value))); - return; - case 'rewardExperience': - $this->setRewardAmount('experience', max(0, intval($value))); - return; - case 'rewardItems': - $items = array_values(array_filter(array_map(trim(...), explode(',', strval($value))), static fn(string $item): bool => $item !== '')); - $rewards = $this->getRewards(); - - if ($items === []) { - unset($rewards['items']); - } else { - $rewards['items'] = $items; - } - - $this->setRewards($rewards); - return; - case 'prerequisites': - $prerequisites = self::decodePrerequisites(strval($value)); - - if ($prerequisites === []) { - unset($this->payload['prerequisites']); - } else { - $this->payload['prerequisites'] = $prerequisites; - } - - $this->touchState(); - return; - } - } - - /** - * Appends a placeholder objective. - * - * @param array|null $objective The objective payload; a placeholder when null. - * @return int The new objective index. - */ - public function addObjective(?array $objective = null): int - { - $objectives = $this->getObjectives(); - $objectives[] = $objective ?? ['type' => QuestObjectiveType::TALK_TO->value, 'target' => 'New Target']; - $this->payload['objectives'] = $objectives; - $this->touchState(); - - return count($objectives) - 1; - } - - /** - * Removes one objective by index. - * - * @param int $index The objective index. - * @return array|null The removed objective payload. - */ - public function removeObjective(int $index): ?array - { - $objectives = $this->getObjectives(); - - if (! array_key_exists($index, $objectives)) { - return null; - } - - [$removed] = array_splice($objectives, $index, 1); - $this->payload['objectives'] = $objectives; - $this->touchState(); - - return $removed; - } - - /** - * Re-inserts an objective at a specific index (undo support). - * - * @param int $index The target objective index. - * @param array $objective The objective payload. - * @return void - */ - public function insertObjective(int $index, array $objective): void - { - $objectives = $this->getObjectives(); - $index = max(0, min(count($objectives), $index)); - array_splice($objectives, $index, 0, [$objective]); - $this->payload['objectives'] = $objectives; - $this->touchState(); - } - /** * Returns journal-style objective summary lines. * @@ -373,24 +164,15 @@ public function getPrerequisiteSummaryLines(): array { $prerequisites = $this->getPrerequisites(); - if ($prerequisites === []) { - return ['None']; - } - - return array_map(static fn(array $prerequisite): string => self::encodePrerequisite($prerequisite), $prerequisites); + return $prerequisites === [] ? ['None'] : array_map(ConditionCodec::encode(...), $prerequisites); } /** - * Encodes the prerequisites into the editable one-line form. - * - * @return string + * Encodes the prerequisites into the one-line condition form. */ public function getPrerequisitesString(): string { - return implode('; ', array_map( - static fn(array $prerequisite): string => self::encodePrerequisite($prerequisite), - $this->getPrerequisites() - )); + return ConditionCodec::encodeAll($this->getPrerequisites()); } /** @@ -400,235 +182,4 @@ public function toArray(): array { return $this->payload; } - - /** - * Sets or removes a numeric reward amount. - * - * @param string $key The reward key. - * @param int $amount The amount; zero removes the key. - * @return void - */ - private function setRewardAmount(string $key, int $amount): void - { - $rewards = $this->getRewards(); - - if ($amount <= 0) { - unset($rewards[$key]); - } else { - $rewards[$key] = $amount; - } - - $this->setRewards($rewards); - } - - /** - * Stores the rewards array, dropping it entirely when empty. - * - * @param array $rewards The rewards payload. - * @return void - */ - private function setRewards(array $rewards): void - { - if ($rewards === []) { - unset($this->payload['rewards']); - } else { - $this->payload['rewards'] = $rewards; - } - - $this->touchState(); - } - - /** - * Applies one objective sub-field edit. - * - * @param int $index The objective index. - * @param string $key The objective key. - * @param mixed $value The edited value. - * @return void - */ - private function setObjectiveField(int $index, string $key, mixed $value): void - { - $objectives = $this->getObjectives(); - - if (! array_key_exists($index, $objectives)) { - return; - } - - $objective = $objectives[$index]; - - switch ($key) { - case 'Type': - $type = QuestObjectiveType::tryFrom(strval($value)); - - if (! $type instanceof QuestObjectiveType) { - return; - } - - $objective['type'] = $type->value; - break; - case 'Target': - $target = trim(strval($value)); - - if ($target === '') { - return; - } - - $objective['target'] = $target; - break; - case 'Quantity': - $quantity = max(1, intval($value)); - - if ($quantity > 1) { - $objective['quantity'] = $quantity; - } else { - unset($objective['quantity']); - } - - break; - case 'Description': - $description = trim(strval($value)); - - if ($description === '') { - unset($objective['description']); - } else { - $objective['description'] = $description; - } - - break; - case 'RevealedDescription': - $description = trim(strval($value)); - - if ($description === '') { - unset($objective['revealedDescription']); - } else { - $objective['revealedDescription'] = $description; - } - - break; - case 'RevealConditions': - $conditions = ConditionCodec::decodeAll(strval($value)); - - if ($conditions === []) { - unset($objective['revealConditions']); - } else { - $objective['revealConditions'] = $conditions; - } - - break; - } - - $objectives[$index] = $objective; - $this->payload['objectives'] = $objectives; - $this->touchState(); - } - - /** - * Encodes one prerequisite as `[!]type:name[:extras]`. - * - * @param array $prerequisite The prerequisite payload. - * @return string - */ - private static function encodePrerequisite(array $prerequisite): string - { - $type = strval($prerequisite['type'] ?? ''); - $name = strval($prerequisite['name'] ?? ''); - $parts = [$type, $name]; - - switch ($type) { - case 'quest': - $parts[] = strval($prerequisite['status'] ?? 'completed'); - break; - case 'switch': - if (($prerequisite['value'] ?? true) === false) { - $parts[] = 'false'; - } - - break; - case 'item': - if (intval($prerequisite['quantity'] ?? 1) > 1) { - $parts[] = strval(intval($prerequisite['quantity'])); - } - - break; - case 'variable': - $parts[] = strval($prerequisite['op'] ?? '=='); - $parts[] = strval($prerequisite['value'] ?? 0); - break; - } - - $negate = ($prerequisite['negate'] ?? false) === true ? '!' : ''; - - return $negate . implode(':', $parts); - } - - /** - * Decodes the one-line prerequisite form back into condition arrays. - * - * Entries are separated by `;`. Each entry is `[!]type:name[:extras]` - * where extras depend on the type (quest status, switch `false`, item - * quantity, or variable `op:value`). - * - * @param string $value The encoded prerequisites. - * @return array> - */ - private static function decodePrerequisites(string $value): array - { - $prerequisites = []; - - foreach (explode(';', $value) as $segment) { - $segment = trim($segment); - - if ($segment === '') { - continue; - } - - $negate = str_starts_with($segment, '!'); - $segment = ltrim($segment, '!'); - $parts = array_map(trim(...), explode(':', $segment)); - $type = strtolower($parts[0] ?? ''); - $name = $parts[1] ?? ''; - - // The runtime owns which world conditions exist, so this asks it - // rather than keeping a copy of the list. A hardcoded copy was - // how key_item came to be dropped here while the condition line - // and the validator both accepted it. - if ($name === '' || ! in_array($type, ConditionCodec::types(), true)) { - continue; - } - - $prerequisite = ['type' => $type, 'name' => $name]; - - switch ($type) { - case 'quest': - $status = strtolower($parts[2] ?? 'completed'); - $prerequisite['status'] = in_array($status, ['completed', 'active'], true) ? $status : 'completed'; - break; - case 'switch': - if (strtolower($parts[2] ?? 'true') === 'false') { - $prerequisite['value'] = false; - } - - break; - case 'item': - if (intval($parts[2] ?? 1) > 1) { - $prerequisite['quantity'] = intval($parts[2]); - } - - break; - case 'variable': - $prerequisite['op'] = $parts[2] ?? '=='; - $rawValue = $parts[3] ?? '0'; - $prerequisite['value'] = is_numeric($rawValue) ? intval($rawValue) : $rawValue; - break; - } - - if ($negate) { - $prerequisite['negate'] = true; - } - - $prerequisites[] = $prerequisite; - } - - return $prerequisites; - } } diff --git a/src/ProjectQuestDatabase.php b/src/ProjectQuestDatabase.php deleted file mode 100644 index 3d2d9d95..00000000 --- a/src/ProjectQuestDatabase.php +++ /dev/null @@ -1,457 +0,0 @@ -captureBaseline(); - } - } - - /** - * Loads the quest database from a project root. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR . 'assets' - . DIRECTORY_SEPARATOR . 'Data' - . DIRECTORY_SEPARATOR . 'quests.php'; - - if (! is_file($path)) { - return new self($path, []); - } - - $source = (string) file_get_contents($path); - $payload = require $path; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - $quests = []; - - foreach (array_values($payload) as $entry) { - if (is_array($entry)) { - $quests[] = new ProjectQuest($entry); - } - } - - return new self( - $path, - $quests, - sourceHeader: PhpDataSource::inspect($source)['header'], - ); - } - - /** - * @return ProjectQuest[] - */ - public function getQuests(): array - { - return array_values($this->quests); - } - - public function getQuestByIndex(int $index): ?ProjectQuest - { - return $this->quests[$index] ?? null; - } - - - - /** - * Appends a blank quest with a unique id. - * - * @param string $name The quest name. - * @return int The new quest index. - */ - public function addQuest(string $name = 'New Quest'): int - { - $this->quests[] = ProjectQuest::createBlank(Slug::unique($name, $this->getQuestIds(), 'quest'), $name); - $this->touchState(); - - return count($this->quests) - 1; - } - - /** - * Returns every quest id in the project. - * - * @return string[] The ids. - */ - public function getQuestIds(): array - { - return array_map(static fn(ProjectQuest $quest): string => $quest->getId(), $this->quests); - } - - /** - * Renames a quest, taking the id with it while that is still safe. - * - * An id is what triggers grant and conditions wait on, and renaming the - * quest does not rewrite those. So the id keeps up with the name until - * something points at it, and holds still afterwards -- which is the - * point at which changing it would break the game rather than tidy it. - * - * @param int $index The quest index. - * @param string $name The new name. - * @param bool $mayChangeId Whether nothing points at the current id. - * @return string|null The new id when it changed, or null when it did not. - */ - public function renameQuest(int $index, string $name, bool $mayChangeId): ?string - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $quest->setField('name', $name); - $this->touchState(); - - if (! $mayChangeId) { - return null; - } - - $taken = array_values(array_filter( - $this->getQuestIds(), - static fn(string $id): bool => $id !== $quest->getId() - )); - $id = Slug::unique($name, $taken, 'quest'); - - if ($id === $quest->getId()) { - return null; - } - - $quest->setField('id', $id); - - return $id; - } - - /** - * Removes the quest at the given index (rewritten to disk on save, so - * re-inserting at the same index is a complete undo). - * - * @param int $index The quest index. - * @return ProjectQuest|null The removed quest, or null when the index is unknown. - */ - public function removeQuest(int $index): ?ProjectQuest - { - $quests = array_values($this->quests); - $quest = $quests[$index] ?? null; - - if (! $quest instanceof ProjectQuest) { - return null; - } - - array_splice($quests, $index, 1); - $this->quests = $quests; - $this->touchState(); - - return $quest; - } - - /** - * Re-inserts a previously removed quest (the undo of removeQuest()). - * - * @param int $index The index to restore the quest at. - * @param ProjectQuest $quest The quest to restore. - * @return void - */ - public function insertQuest(int $index, ProjectQuest $quest): void - { - $quests = array_values($this->quests); - $index = max(0, min(count($quests), $index)); - array_splice($quests, $index, 0, [$quest]); - $this->quests = $quests; - $this->touchState(); - } - - /** - * Applies one flat settings-field edit onto a quest. - * - * @param int $index The quest index. - * @param string $field The field identifier. - * @param mixed $value The edited value. - * @return void - */ - public function setField(int $index, string $field, mixed $value): void - { - $quest = $this->getQuestByIndex($index); - - if ($quest instanceof ProjectQuest) { - $quest->setField($field, $value); - $this->touchState(); - } - } - - /** - * Appends an objective to a quest. - * - * @param int $index The quest index. - * @param array|null $objective The objective payload; a placeholder when null. - * @return int|null The new objective index. - */ - public function addObjective(int $index, ?array $objective = null): ?int - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $this->touchState(); - - return $quest->addObjective($objective); - } - - /** - * Removes an objective from a quest. - * - * @param int $index The quest index. - * @param int $objectiveIndex The objective index. - * @return array|null The removed objective payload. - */ - public function removeObjective(int $index, int $objectiveIndex): ?array - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $removed = $quest->removeObjective($objectiveIndex); - - if ($removed !== null) { - $this->touchState(); - } - - return $removed; - } - - /** - * Re-inserts an objective at a specific index (undo support). - * - * @param int $index The quest index. - * @param int $objectiveIndex The target objective index. - * @param array $objective The objective payload. - * @return void - */ - public function insertObjective(int $index, int $objectiveIndex, array $objective): void - { - $quest = $this->getQuestByIndex($index); - - if ($quest instanceof ProjectQuest) { - $quest->insertObjective($objectiveIndex, $objective); - $this->touchState(); - } - } - - /** - * Adds a reward item slot to a quest. - * - * @param int $index The quest index. - * @param int|null $afterSlot The slot to insert after, or null for the end. - * @return int|null The new slot, or null when the quest is unknown. - */ - public function addRewardItem(int $index, ?int $afterSlot = null): ?int - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $this->touchState(); - - return $quest->addRewardItem($afterSlot); - } - - /** - * Removes a reward item slot from a quest. - * - * @param int $index The quest index. - * @param int $slot The slot. - * @return string|null The removed item name. - */ - public function removeRewardItem(int $index, int $slot): ?string - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return null; - } - - $removed = $quest->removeRewardItem($slot); - - if ($removed !== null) { - $this->touchState(); - } - - return $removed; - } - - /** - * Puts a removed reward item back where it was. - * - * @param int $index The quest index. - * @param int $slot The slot it held. - * @param string $item The item name. - * @return void - */ - public function insertRewardItem(int $index, int $slot, string $item): void - { - $quest = $this->getQuestByIndex($index); - - if (! $quest instanceof ProjectQuest) { - return; - } - - $items = $quest->getRewardItems(); - array_splice($items, min($slot, count($items)), 0, [$item]); - $quest->setRewardItems($items); - $this->touchState(); - } - - /** - * Writes the quest asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - - foreach ($this->quests as $quest) { - $quest->markClean(); - } - - $this->captureBaseline(); - } - - /** - * @inheritDoc - */ - protected function dependencyVersion(): string - { - $versions = []; - - foreach ($this->quests as $quest) { - $versions[] = $quest->stateVersion(); - } - - return count($this->quests) . ':' . implode(',', $versions); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return $this->sourceHeader . 'return ' . self::exportPhpValue(array_map( - static fn(ProjectQuest $quest): array => $quest->toArray(), - $this->getQuests() - )) . ";\n"; - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } -} diff --git a/src/ProjectSettingsFile.php b/src/ProjectSettingsFile.php new file mode 100644 index 00000000..aafc9373 --- /dev/null +++ b/src/ProjectSettingsFile.php @@ -0,0 +1,399 @@ + */ + private array $records = []; + private array $originalValues = []; + private array $fieldIssues = []; + private string $persistedSource = ''; + public readonly string $path; + + public function __construct(protected readonly string $projectRoot, public readonly string $relativePath) + { + $this->path = rtrim($projectRoot, '/') . '/' . $relativePath; + try { + if (! is_file($this->path)) { + throw new RuntimeException(sprintf('%s does not exist in this project.', $relativePath)); + } + $this->persistedSource = (string) file_get_contents($this->path); + $file = PhpDataFile::load($this->path, $projectRoot); + $this->payload = is_array($file->payload) ? $file->payload : []; + $this->document = PhpArraySourceDocument::parse($this->persistedSource); + if (! is_array($file->payload)) { + throw new RuntimeException($file->readOnlyReason ?? sprintf('%s must return an array.', $relativePath)); + } + } catch (Throwable $error) { + $this->issue = $error->getMessage(); + } + } + + /** + * The settings the Engine reads where the file leaves them out, by path, with what their record carries beyond + * its path and value: at least the `default` the Engine reads. Each is offered even where the file is silent. + * + * @return array> + */ + abstract protected function listEngineSettings(): array; + + /** + * Refuses a value the Engine would not accept at a path, before anything changes. + * + * @throws \InvalidArgumentException + */ + public function assertAcceptableValue(string $path, mixed $value, bool $complete = true): void {} + + /** Whether a setting's value is a whole array edited as one setting, rather than a tree of settings. */ + protected function isWholeValue(string $path): bool + { + return false; + } + + /** + * The keys a setting's path walks, root first. A path joins them with dots; a file whose keys hold dots of their + * own (icon roles such as map.player) says where a key begins. + * + * @return list + */ + protected function splitPath(string $path): array + { + return explode('.', $path); + } + + /** Whether choosing nothing for a setting removes its key, rather than writing an empty value the Engine refuses. */ + protected function isRemovedWhenEmpty(string $path): bool + { + return false; + } + + /** + * Keys a whole-value edit renames rather than replaces, so the renamed entries keep their authored comments. + * + * @return list, key: string}> + */ + protected function listKeyRenames(string $path, mixed $old, mixed $new): array + { + return []; + } + + public function getRecord(string $path): ProjectRecord + { + if (! isset($this->records[$path])) { + $value = $this->readValue($this->payload, $path); + $this->originalValues[$path] = $value; + $setting = $this->listEngineSettings()[$path] ?? null; + $this->records[$path] = new ProjectRecord($setting === null + ? ['path' => $path, 'value' => $value] + : ['path' => $path, 'value' => $value, ...$setting]); + } + return $this->records[$path]; + } + + /** + * The settings under these roots: every literal leaf the file holds, then every setting the Engine knows there + * that the file leaves out. + * + * @param list $roots + * @return list + */ + public function getSettingRecords(array $roots): array + { + $records = []; + $visit = function (array $tree, array $segments, bool $ambiguous = false) use (&$visit, &$records): void { + foreach ($tree as $key => $value) { + $keys = [...$segments, (string) $key]; + $path = implode('.', $keys); + // A key the path cannot be split back into is ambiguous with a nested path. + $hasDottedKey = $ambiguous || $this->splitPath($path) !== $keys; + if ($this->isWholeValue($path) && ! $hasDottedKey) { $records[] = $this->getRecord($path); } + elseif (is_array($value)) { $visit($value, $keys, $hasDottedKey); } + elseif (! is_object($value) || $value instanceof \UnitEnum) { + if ($hasDottedKey) { + $this->fieldIssues[$path] = $path . ' contains a dotted literal key, ambiguous with a nested path; it is preserved read-only.'; + $records[] = new ProjectRecord(['path' => $path, 'value' => $value]); + } else { $records[] = $this->getRecord($path); } + } + } + }; + foreach ($roots as $root) { + if (is_array($this->payload[$root] ?? null)) { $visit($this->payload[$root], [$root]); } + } + foreach (array_keys($this->listEngineSettings()) as $path) { + $root = $this->splitPath($path)[0]; + if (in_array($root, $roots, true) && ! in_array($path, array_map(static fn(ProjectRecord $record): mixed => $record->get('path'), $records), true) && $this->issue === null) { + $records[] = $this->getRecord($path); + } + } + return $records; + } + + public function getReadOnlyReason(): ?string + { + return $this->issue; + } + + public function getFieldIssue(string $path): ?string + { + if (isset($this->fieldIssues[$path])) { return $this->fieldIssues[$path]; } + try { + $this->createFieldEdit($this->document, $path, '1', $this->isWholeValue($path)); + return null; + } catch (Throwable $error) { + return $error->getMessage(); + } + } + + /** Whether the file authors a setting at all, as opposed to leaving it to its default. */ + public function holds(string $path): bool + { + return $this->document?->nodeAt($this->splitPath($path)) !== null; + } + + public function isDirty(): bool + { + foreach ($this->records as $record) { + if ($record->isDirty()) { return true; } + } + return false; + } + + /** Rebuild only the changed leaf spans over the original bytes, including after save/undo. */ + public function getSource(): string + { + return $this->composeSource(); + } + + /** Proves a proposed structured value is source-editable before changing its shared record. */ + public function assertSourceAcceptsValue(string $path, mixed $value): void + { + $this->composeSource([$path => $value]); + } + + /** + * The whole file's data as it would be saved: what it holds with every + * pending setting, and these proposed values, written over it. A setting + * set to nothing that the file drops when empty is left out. + * + * @param array $overrides Proposed values by path. + * @return array + */ + protected function readPendingPayload(array $overrides = []): array + { + $values = []; + foreach ($this->records as $path => $record) { $values[$path] = $record->get('value'); } + $payload = $this->payload; + foreach ([...$values, ...$overrides] as $path => $value) { + $segments = $this->splitPath((string) $path); + $last = array_pop($segments); + $node = &$payload; + foreach ($segments as $segment) { + if (! is_array($node[$segment] ?? null)) { $node[$segment] = []; } + $node = &$node[$segment]; + } + if ($value === null && $this->isRemovedWhenEmpty((string) $path)) { unset($node[$last]); } else { $node[$last] = $value; } + unset($node); + } + + return $payload; + } + + /** The value a setting holds now, pending edits included, or what the file holds where none was read. */ + protected function getCurrentValue(string $path): mixed + { + return isset($this->records[$path]) ? $this->records[$path]->get('value') : $this->readValue($this->payload, $path); + } + + private function composeSource(array $overrides = []): string + { + $document = $this->document; + foreach ($this->records as $path => $record) { + $value = array_key_exists($path, $overrides) ? $overrides[$path] : $record->get('value'); + if ($value === $this->originalValues[$path]) { continue; } + if ($this->isWholeValue($path) && is_array($this->originalValues[$path])) { + $this->createFieldEdit($document, $path, '1', true); + $old = $this->payload; + $new = $old; + $next = &$new; + foreach ($this->splitPath($path) as $segment) { $next = &$next[$segment]; } + $next = $value; + unset($next); + $document = ArraySourceWriter::rewrite($document, $old, $new, $this->listKeyRenames($path, $this->originalValues[$path], $value)); + continue; + } + if ($value === null && $this->isRemovedWhenEmpty($path)) { + // An authored key chosen away is removed, keeping its neighbours and their comments. + $this->createFieldEdit($document, $path, '1'); + $document = $document->withEdits([$document->removeEntryEdit($this->splitPath($path))]); + continue; + } + $edit = $this->createFieldEdit($document, $path, PhpValueExporter::export($value)); + $document = $document->withEdits([$edit]); + } + return $document?->source ?? $this->persistedSource; + } + + /** + * Rewrites the file's data structurally, as a migration does, and saves + * it at once through the source-preserving writer: comments and untouched + * entries stay as written. Refused while settings are pending, so the two + * kinds of edit never mix. + * + * @param Closure(array): array $change The data as it should be. + * @return bool Whether anything changed. + * @throws SourcePreservationRefusal + */ + protected function rewritePayload(Closure $change, ?FileSetOperations $files = null): bool + { + if ($this->issue !== null || $this->document === null) { + throw new SourcePreservationRefusal($this->issue ?? $this->relativePath . ' cannot be preserved.'); + } + if ($this->isDirty()) { + throw new SourcePreservationRefusal('Save or discard the pending ' . $this->relativePath . ' settings first.'); + } + $new = $change($this->payload); + if ($new === $this->payload) { return false; } + $document = ArraySourceWriter::rewrite($this->document, $this->payload, $new); + $source = $document->source; + SaveSet::commitOne($this->projectRoot, new PreparedSave( + $this->relativePath, + fn(FileSetTransaction $transaction) => $transaction->write($this->path, $source), + function (array $staged) use ($new): void { + if ((string) file_get_contents($this->path) !== $this->persistedSource) { + throw new SourcePreservationRefusal($this->relativePath . ' changed on disk. Reload before saving.'); + } + if (PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot) !== $new) { + throw new SourcePreservationRefusal('The staged ' . $this->relativePath . ' does not read back as rewritten.'); + } + }, + function () use ($source, $new, $document): void { + $this->persistedSource = $source; + $this->payload = $new; + $this->document = $document; + $this->records = []; + $this->originalValues = []; + }, + ), files: $files); + + return true; + } + + /** Saves all pending settings together, never a second whole-value rewrite. */ + public function save(?FileSetOperations $files = null): void + { + $prepared = $this->prepareSave(); + if ($prepared !== null) { + SaveSet::commitOne($this->projectRoot, $prepared, files: $files); + } + } + + /** + * Works out the settings save without writing it: the file with every + * pending setting composed into it, and the proof that the staged file + * holds each changed setting. Null when nothing is pending. + * + * @throws SourcePreservationRefusal When a setting cannot be written into the file's own source. + */ + public function prepareSave(): ?PreparedSave + { + if (! $this->isDirty()) { return null; } + foreach ($this->records as $path => $record) { + if ($record->isDirty() && $record->get('value') !== $this->originalValues[$path]) { $this->assertAcceptableValue($path, $record->get('value')); } + } + $source = $this->getSource(); + + return new PreparedSave( + $this->relativePath, + fn(FileSetTransaction $transaction) => $transaction->write($this->path, $source), + function (array $staged): void { + if ((string) file_get_contents($this->path) !== $this->persistedSource) { + throw new SourcePreservationRefusal($this->relativePath . ' changed on disk. Reload before saving; pending edits were not written.'); + } + $evaluated = PhpDataFile::evaluateIsolated($staged[$this->path], $this->projectRoot); + foreach ($this->records as $path => $record) { + if ($record->get('value') === $this->originalValues[$path]) { continue; } + if ($this->readValue(is_array($evaluated) ? $evaluated : [], $path) !== $record->get('value')) { + throw new SourcePreservationRefusal('The staged ' . $this->relativePath . ' does not preserve ' . $path . '.'); + } + } + }, + function () use ($source): void { + $this->persistedSource = $source; + foreach ($this->records as $record) { $record->markClean(); } + }, + ); + } + + private function createFieldEdit(?PhpArraySourceDocument $document, string $path, string $literal, bool $allowArray = false): array + { + if ($this->issue !== null || $document === null) { throw new SourcePreservationRefusal($this->issue ?? $this->relativePath . ' cannot be preserved.'); } + $segments = $this->splitPath($path); + $walked = []; + $node = $document->root(); + foreach ($segments as $index => $key) { + if ($node->kind !== SourceNode::ARRAY || $node->hasOpaqueKey || count(array_unique(array_column($node->entries, 'key'), SORT_REGULAR)) !== count($node->entries)) { + throw new SourcePreservationRefusal($path . ' needs literal, uniquely keyed parent arrays; opaque settings are preserved read-only.'); + } + $entry = $node->entryFor($key); + if ($entry === null) { + foreach (array_reverse(array_slice($segments, $index + 1)) as $child) { + $literal = '[' . var_export($child, true) . ' => ' . $literal . ']'; + } + return $document->insertEntryEdit($walked, count($node->entries), $key, $literal); + } + $walked[] = $key; + $node = $entry->value; + } + // An enum case is written as one: replacing it with another case of + // its enum keeps the file reading the same kind of value. + if ($node->kind !== SourceNode::SCALAR && ! ($allowArray && $node->kind === SourceNode::ARRAY) && ! $this->readValue($this->payload, $path) instanceof \UnitEnum) { + throw new SourcePreservationRefusal($path . ' is an authored expression, not an editable literal.'); + } + return $document->replaceValueEdit($walked, $literal); + } + + protected function readValue(array $payload, string $path): mixed + { + $value = $payload; + foreach ($this->splitPath($path) as $key) { $value = is_array($value) ? ($value[$key] ?? null) : null; } + return $value; + } + + /** + * Returns the files a save would overwrite. + * + * @return string[] + */ + public function getBackupPaths(): array + { + return [$this->path]; + } +} diff --git a/src/ProjectSkill.php b/src/ProjectSkill.php deleted file mode 100644 index ff5f39ac..00000000 --- a/src/ProjectSkill.php +++ /dev/null @@ -1,161 +0,0 @@ -captureBaseline(); - } - } - - public static function fromSkill(Skill $skill, int $id): self - { - return new self($id, [ - "type" => match (true) { - $skill instanceof MagicSkill => "magic", - $skill instanceof SpecialSkill => "special", - default => "basic", - }, - "name" => $skill->name, - "description" => $skill->description, - "icon" => $skill->icon, - "cost" => $skill->cost, - "cooldown" => $skill->cooldown, - "occasion" => $skill->occasion->value, - "scope" => [ - "side" => $skill->scope->side->value, - "number" => $skill->scope->number->value, - "status" => $skill->scope->status->value, - "targetCount" => $skill->scope->targetCount, - ], - "invocation" => [ - "message" => $skill->invocation->message, - "speed" => $skill->invocation->speed, - "accuracy" => $skill->invocation->accuracy, - "repeat" => $skill->invocation->repeat, - "apGain" => $skill->invocation->apGain, - ], - "effects" => array_map( - static fn(SkillEffect $effect): array => [ - "class" => $effect::class, - "formula" => $effect->formula, - "element" => $effect->element, - "variance" => $effect->variance, - "isCriticalHit" => $effect->isCriticalHit, - ], - $skill->effects, - ), - "effectType" => $skill instanceof MagicSkill ? $skill->effectType?->value : null, - ]); - } - - public static function createBlank(int $id, string $name = "New Skill"): self - { - return new self($id, [ - "type" => "special", - "name" => $name, - "description" => "", - "icon" => "", - "cost" => 0, - "cooldown" => 0, - "occasion" => Occasion::BATTLE_SCREEN->value, - "scope" => [ - "side" => "Enemy", - "number" => "One", - "status" => "Alive", - "targetCount" => null, - ], - "invocation" => [ - "message" => "$1 uses $2!", - "speed" => 0, - "accuracy" => 0, - "repeat" => 1, - "apGain" => 10, - ], - "effects" => [], - "effectType" => MagicEffectType::DESTRUCTIVE->value, - ], true); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return serialize($this->toArray()); - } - - public function getType(): string { return strval($this->payload["type"] ?? "special"); } - public function getName(): string { return strval($this->payload["name"] ?? "Skill"); } - public function getDescription(): string { return strval($this->payload["description"] ?? ""); } - public function getIcon(): string { return strval($this->payload["icon"] ?? ""); } - public function getCost(): int { return max(0, intval($this->payload["cost"] ?? 0)); } - public function getCooldown(): int { return max(0, intval($this->payload["cooldown"] ?? 0)); } - public function getOccasion(): string { return strval($this->payload["occasion"] ?? Occasion::BATTLE_SCREEN->value); } - public function getScope(): array { return is_array($this->payload["scope"] ?? null) ? $this->payload["scope"] : []; } - public function getInvocation(): array { return is_array($this->payload["invocation"] ?? null) ? $this->payload["invocation"] : []; } - public function getEffects(): array { return is_array($this->payload["effects"] ?? null) ? array_values($this->payload["effects"]) : []; } - public function getEffectType(): ?string { $value = $this->payload["effectType"] ?? null; return is_string($value) && $value !== "" ? $value : null; } - - public function setField(string $field, mixed $value): void - { - if (in_array($field, ["name", "description", "icon", "type", "occasion", "effectType"], true)) { - $this->payload[$field] = is_string($value) ? trim($value) : strval($value); - $this->touchState(); - return; - } - - if (in_array($field, ["cost", "cooldown"], true)) { - $this->payload[$field] = max(0, intval($value)); - $this->touchState(); - return; - } - if (str_starts_with($field, "scope")) { - $key = lcfirst(substr($field, 5)); - $this->payload["scope"][$key] = $field === "scopeTargetCount" ? (trim(strval($value)) === "" ? null : max(0, intval($value))) : strval($value); - $this->touchState(); - return; - } - - if (str_starts_with($field, "invocation")) { - $key = lcfirst(substr($field, 10)); - $this->payload["invocation"][$key] = $key === "message" ? strval($value) : max(0, intval($value)); - $this->touchState(); - } - } - - public function getEffectSummaryLines(): array - { - $lines = []; - foreach ($this->getEffects() as $effect) { - $class = basename(str_replace(chr(92), "/", strval($effect["class"] ?? "Effect"))); - $lines[] = sprintf("%s: %s", $class, strval($effect["formula"] ?? "")); - } - return $lines === [] ? ["No effects configured."] : $lines; - } - - public function toArray(): array - { - return $this->payload; - } -} diff --git a/src/ProjectSkillDatabase.php b/src/ProjectSkillDatabase.php deleted file mode 100644 index 52c87689..00000000 --- a/src/ProjectSkillDatabase.php +++ /dev/null @@ -1,283 +0,0 @@ -captureBaseline(); - } - } - - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR . "assets" - . DIRECTORY_SEPARATOR . "Data" - . DIRECTORY_SEPARATOR . "skills.php"; - if (is_file($path) === false) { - return new self($path, []); - } - $payload = require $path; - if (is_array($payload) === false) { - throw new RuntimeException(sprintf("Unable to parse %s.", $path)); - } - $skills = []; - foreach (array_values($payload) as $index => $skill) { - if ($skill instanceof \Ichiloto\Engine\Entities\Skills\Skill) { - $skills[] = ProjectSkill::fromSkill($skill, $index + 1); - } - } - return new self($path, $skills); - } - - public function getSkills(): array { return array_values($this->skills); } - - public function getSkillByIndex(int $index): ?ProjectSkill - { - return $this->skills[$index] ?? null; - } - - public function addSkill(string $name = "New Skill"): int - { - $nextId = 1; - foreach ($this->skills as $skill) { $nextId = max($nextId, $skill->id + 1); } - $this->skills[] = ProjectSkill::createBlank($nextId, $name); - $this->touchState(); - return count($this->skills) - 1; - } - - public function setField(int $index, string $field, mixed $value): void - { - $skill = $this->getSkillByIndex($index); - if ($skill instanceof ProjectSkill) { $skill->setField($field, $value); $this->touchState(); } - } - - /** - * Removes the skill at the given index (rewritten to disk on save, so - * re-inserting at the same index is a complete undo). - * - * @param int $index The skill index. - * @return ProjectSkill|null The removed skill, or null when the index is unknown. - */ - public function removeSkill(int $index): ?ProjectSkill - { - $skills = array_values($this->skills); - $skill = $skills[$index] ?? null; - - if (! $skill instanceof ProjectSkill) { - return null; - } - - array_splice($skills, $index, 1); - $this->skills = $skills; - $this->touchState(); - - return $skill; - } - - /** - * Re-inserts a previously removed skill (the undo of removeSkill()). - * - * @param int $index The index to restore the skill at. - * @param ProjectSkill $skill The skill to restore. - * @return void - */ - public function insertSkill(int $index, ProjectSkill $skill): void - { - $skills = array_values($this->skills); - $index = max(0, min(count($skills), $index)); - array_splice($skills, $index, 0, [$skill]); - $this->skills = $skills; - $this->touchState(); - } - - public function save(): void - { - $directory = dirname($this->path); - if (is_dir($directory) === false && mkdir($directory, 0777, true) === false && is_dir($directory) === false) { - throw new RuntimeException(sprintf("Unable to create %s.", $directory)); - } - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - $this->captureBaseline(); - } - - /** - * @inheritDoc - */ - protected function dependencyVersion(): string - { - $versions = []; - - foreach ($this->skills as $skill) { - $versions[] = $skill->stateVersion(); - } - - return count($this->skills) . ':' . implode(',', $versions); - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - $imports = $this->getImportLines(); - $definitions = array_map(fn(ProjectSkill $skill): string => $this->exportSkill($skill), $this->getSkills()); - $payload = [self::openTag(), self::emptyString()]; - foreach ($imports as $import) { $payload[] = $import; } - $payload[] = self::emptyString(); - $payload[] = self::returnArray(); - foreach ($definitions as $definition) { $payload[] = $definition . self::comma(); } - $payload[] = self::closingArray(); - $payload[] = self::emptyString(); - - return implode(PHP_EOL, $payload); - } - - private function getImportLines(): array - { - $imports = [ - "use Ichiloto\Engine\Entities\Enumerations\ItemScopeNumber;", - "use Ichiloto\Engine\Entities\Enumerations\ItemScopeSide;", - "use Ichiloto\Engine\Entities\Enumerations\ItemScopeStatus;", - "use Ichiloto\Engine\Entities\Enumerations\Occasion;", - "use Ichiloto\Engine\Entities\ItemScope;", - "use Ichiloto\Engine\Entities\Skills\BasicSkill;", - "use Ichiloto\Engine\Entities\Skills\MagicSkill;", - "use Ichiloto\Engine\Entities\Skills\SkillInvocation;", - "use Ichiloto\Engine\Entities\Skills\SpecialSkill;", - ]; - foreach ($this->getSkills() as $skill) { - if ($skill->getType() === "magic") { $imports[] = "use Ichiloto\Engine\Entities\Magic\MagicEffectType;"; } - foreach ($skill->getEffects() as $effect) { $imports[] = "use " . strval($effect["class"] ?? "") . ";"; } - } - sort($imports); - return array_values(array_unique($imports)); - } - - private function exportSkill(ProjectSkill $skill): string - { - $lines = [ - sprintf(" new %s(", match ($skill->getType()) { - "basic" => "BasicSkill", - "magic" => "MagicSkill", - default => "SpecialSkill", - }), - " " . var_export($skill->getName(), true) . ",", - " " . var_export($skill->getDescription(), true) . ",", - " " . var_export($skill->getIcon(), true) . ",", - " " . $skill->getCost() . ",", - " " . $skill->getCooldown() . ",", - " " . $this->exportScope($skill) . ",", - " " . $this->exportOccasion($skill->getOccasion()) . ",", - " " . $this->exportInvocation($skill) . ",", - " [", - ]; - foreach ($skill->getEffects() as $effect) { $lines[] = " " . $this->exportEffect($effect) . ","; } - $lines[] = " ],"; - if ($skill->getType() === "magic") { - $lines[] = " [],"; - $lines[] = " " . $this->exportMagicEffectType($skill->getEffectType()) . ","; - } - $lines[] = " )"; - return implode(PHP_EOL, $lines); - } - - private function exportScope(ProjectSkill $skill): string - { - $scope = $skill->getScope(); - $parts = [ - "ItemScopeSide::" . $this->enumCaseFromValue(ItemScopeSide::class, strval($scope["side"] ?? "Enemy"), "ENEMY"), - "ItemScopeNumber::" . $this->enumCaseFromValue(ItemScopeNumber::class, strval($scope["number"] ?? "One"), "ONE"), - "ItemScopeStatus::" . $this->enumCaseFromValue(ItemScopeStatus::class, strval($scope["status"] ?? "Alive"), "ALIVE"), - ]; - if (($scope["targetCount"] ?? null) !== null) { $parts[] = strval(max(0, intval($scope["targetCount"]))); } - return "new ItemScope(" . implode(", ", $parts) . ")"; - } - - private function exportOccasion(string $occasion): string - { - return "Occasion::" . $this->enumCaseFromValue(Occasion::class, $occasion, "BATTLE_SCREEN"); - } - - private function exportInvocation(ProjectSkill $skill): string - { - $invocation = $skill->getInvocation(); - return sprintf( - "new SkillInvocation(%s, %d, %d, %d, %d)", - var_export(strval($invocation["message"] ?? ""), true), - max(0, intval($invocation["speed"] ?? 0)), - max(0, intval($invocation["accuracy"] ?? 0)), - max(1, intval($invocation["repeat"] ?? 1)), - max(0, intval($invocation["apGain"] ?? 10)), - ); - } - - private function exportEffect(array $effect): string - { - $class = basename(str_replace(chr(92), "/", strval($effect["class"] ?? "SkillEffect"))); - return sprintf( - "new %s(%s, %s, %s, %s)", - $class, - var_export(strval($effect["formula"] ?? "0"), true), - var_export($effect["element"] ?? null, true), - var_export(floatval($effect["variance"] ?? 0.2), true), - var_export(boolval($effect["isCriticalHit"] ?? false), true), - ); - } - - private function exportMagicEffectType(?string $effectType): string - { - return $effectType === null - ? "null" - : "MagicEffectType::" . $this->enumCaseFromValue(\Ichiloto\Engine\Entities\Magic\MagicEffectType::class, $effectType, "DESTRUCTIVE"); - } - - private function enumCaseFromValue(string $enumClass, string $value, string $fallback): string - { - foreach ($enumClass::cases() as $case) { - if ($case->value === $value) { return $case->name; } - } - return $fallback; - } - - - private static function openTag(): string { return chr(60) . chr(63) . chr(112) . chr(104) . chr(112); } - private static function emptyString(): string { return str_repeat(chr(32), 0); } - private static function returnArray(): string { return chr(114) . chr(101) . chr(116) . chr(117) . chr(114) . chr(110) . chr(32) . chr(91); } - private static function comma(): string { return chr(44); } - private static function closingArray(): string { return chr(93) . chr(59); } - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . ".tmp"; - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException(sprintf("Unable to write temporary file for %s.", $path)); - } - if (rename($temporaryPath, $path) === false) { - @unlink($temporaryPath); - throw new RuntimeException(sprintf("Unable to replace %s.", $path)); - } - } -} diff --git a/src/ProjectSystemDatabase.php b/src/ProjectSystemDatabase.php deleted file mode 100644 index f7ea0a35..00000000 --- a/src/ProjectSystemDatabase.php +++ /dev/null @@ -1,278 +0,0 @@ - $data - */ - public function __construct( - public readonly string $path, - private array $data = [], - bool $isDirty = false, - ) { - if (! $isDirty) { - $this->captureBaseline(); - } - } - - /** - * Loads the system database from the project. - * - * @param string $projectRoot The project root. - * @return self - */ - public static function fromProject(string $projectRoot): self - { - $path = rtrim($projectRoot, DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'assets' - . DIRECTORY_SEPARATOR - . 'Data' - . DIRECTORY_SEPARATOR - . 'system.php'; - - if (! is_file($path)) { - return new self($path, self::normalize([])); - } - - $payload = require $path; - - if (! is_array($payload)) { - throw new RuntimeException("Unable to parse {$path}."); - } - - return new self($path, self::normalize($payload)); - } - - /** - * Returns one top-level field as loaded and normalized. - * - * @param string $field The field name. - * @return mixed The value, or null when the project does not set it. - */ - public function getField(string $field): mixed - { - return $this->data[$field] ?? null; - } - - /** - * @inheritDoc - */ - protected function buildPersistedPayload(): string - { - return "data)) . ";\n"; - } - - /** - * Returns the configured battle engine id. - * - * @return string - */ - public function getBattleEngine(): string - { - return strval($this->data['battle']['engine'] ?? 'traditional'); - } - - /** - * Returns the configured ATB mode. - * - * @return string - */ - public function getAtbMode(): string - { - return strval($this->data['battle']['activeTime']['mode'] ?? 'wait'); - } - - /** - * Returns the configured ATB base fill rate. - * - * @return int - */ - public function getAtbBaseFillRate(): int - { - return max(1, intval($this->data['battle']['activeTime']['baseFillRate'] ?? 35)); - } - - /** - * Returns the configured ATB speed factor percent. - * - * @return int - */ - public function getAtbSpeedFactorPercent(): int - { - return max(0, intval($this->data['battle']['activeTime']['speedFactorPercent'] ?? 35)); - } - - /** - * Applies one editable system field. - * - * @param string $field The field identifier. - * @param mixed $value The replacement value. - * @return void - */ - public function setField(string $field, mixed $value): void - { - $this->data = self::normalize($this->data); - - switch ($field) { - case 'battleEngine': - $this->data['battle']['engine'] = strval($value) === 'active_time' - ? 'active_time' - : 'traditional'; - break; - - case 'atbMode': - $this->data['battle']['activeTime']['mode'] = 'wait'; - break; - - case 'atbBaseFillRate': - $this->data['battle']['activeTime']['baseFillRate'] = max(1, intval($value)); - break; - - case 'atbSpeedFactorPercent': - $this->data['battle']['activeTime']['speedFactorPercent'] = max(0, intval($value)); - break; - - default: - return; - } - - $this->touchState(); - } - - /** - * Writes the system asset back to disk. - * - * @return void - */ - public function save(): void - { - $directory = dirname($this->path); - - if (! is_dir($directory) && ! mkdir($directory, 0777, true) && ! is_dir($directory)) { - throw new RuntimeException("Unable to create {$directory}."); - } - - if (! $this->isDirty() && is_file($this->path)) { - return; - } - - AtomicFile::write($this->path, $this->buildPersistedPayload()); - $this->captureBaseline(); - } - - /** - * Normalizes the loaded system data. - * - * @param array $data The raw system data. - * @return array - */ - private static function normalize(array $data): array - { - $data['title'] ??= basename(dirname(dirname(dirname($data['path'] ?? 'project')))); - $data['currency'] = is_array($data['currency'] ?? null) - ? $data['currency'] - : ['amount' => 0]; - $data['startingParty'] = is_array($data['startingParty'] ?? null) - ? $data['startingParty'] - : []; - $data['startingInventory'] = is_array($data['startingInventory'] ?? null) - ? $data['startingInventory'] - : []; - $data['startingPositions'] = is_array($data['startingPositions'] ?? null) - ? $data['startingPositions'] - : [ - 'player' => [ - 'destinationMap' => 'overworld', - 'spawnPoint' => ['x' => 0, 'y' => 0], - 'spawnSprite' => ['^'], - ], - ]; - $battle = is_array($data['battle'] ?? null) ? $data['battle'] : []; - $activeTime = is_array($battle['activeTime'] ?? null) ? $battle['activeTime'] : []; - $battle['engine'] = strval($battle['engine'] ?? 'traditional') === 'active_time' - ? 'active_time' - : 'traditional'; - $activeTime['mode'] = 'wait'; - $activeTime['baseFillRate'] = max(1, intval($activeTime['baseFillRate'] ?? 35)); - $activeTime['speedFactorPercent'] = max(0, intval($activeTime['speedFactorPercent'] ?? 35)); - $battle['activeTime'] = $activeTime; - $data['battle'] = $battle; - - return $data; - } - - /** - * Writes a file using a temp-file swap. - * - * @param string $path The destination file. - * @param string $contents The file contents. - * @return void - */ - private static function writeFileTransactionally(string $path, string $contents): void - { - $temporaryPath = $path . '.tmp'; - - if (file_put_contents($temporaryPath, $contents) === false) { - throw new RuntimeException("Unable to write temporary file for {$path}."); - } - - if (! rename($temporaryPath, $path)) { - @unlink($temporaryPath); - throw new RuntimeException("Unable to replace {$path}."); - } - } - - /** - * Exports a PHP value using short-array syntax. - * - * @param mixed $value The value to export. - * @param int $indentLevel The indentation depth. - * @return string - */ - private static function exportPhpValue(mixed $value, int $indentLevel = 0): string - { - if (! is_array($value)) { - return var_export($value, true); - } - - if ($value === []) { - return '[]'; - } - - $indent = str_repeat(' ', $indentLevel); - $nextIndent = str_repeat(' ', $indentLevel + 1); - $isList = array_is_list($value); - $lines = ['[']; - - foreach ($value as $key => $item) { - $exportedItem = self::exportPhpValue($item, $indentLevel + 1); - - if ($isList) { - $lines[] = "{$nextIndent}{$exportedItem},"; - continue; - } - - $exportedKey = var_export($key, true); - $lines[] = "{$nextIndent}{$exportedKey} => {$exportedItem},"; - } - - $lines[] = "{$indent}]"; - - return implode("\n", $lines); - } -} diff --git a/src/ProjectWorkspace.php b/src/ProjectWorkspace.php index ef65ac50..8cedb3dc 100644 --- a/src/ProjectWorkspace.php +++ b/src/ProjectWorkspace.php @@ -6,14 +6,34 @@ use FilesystemIterator; use Ichiloto\Editor\Cutscenes\CutsceneLibrary; +use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\Database\EngineDataBootstrap; +use Ichiloto\Editor\Database\ProjectRecord; +use Ichiloto\Editor\Database\PhpDataFile; +use Ichiloto\Editor\Database\ProjectCatalogs; use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\QuestReferences; use Ichiloto\Editor\Database\RecordSchema; use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Database\ReferenceCatalog; +use Ichiloto\Editor\Database\MasteryFields; +use Ichiloto\Editor\Database\SkillReferences; +use Ichiloto\Editor\Events\ProjectScriptCommands; +use Ichiloto\Editor\Events\ScriptCommandUses; +use Ichiloto\Editor\Maps\MapFolder; use Ichiloto\Editor\Storage\FileSetOperations; use Ichiloto\Editor\Storage\FileSetTransaction; use Ichiloto\Editor\Storage\FilesystemFileSetOperations; +use Ichiloto\Engine\Core\ProjectFormat; +use Ichiloto\Engine\Entities\Elements\ElementRegistry; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandReference; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; +use Ichiloto\Engine\Field\MapGraphics; +use Ichiloto\Engine\Field\Reachability\ProjectReachability; use RecursiveDirectoryIterator; +use InvalidArgumentException; +use LogicException; use RecursiveIteratorIterator; use RuntimeException; use Throwable; @@ -23,6 +43,10 @@ */ final readonly class ProjectWorkspace { + /** Preview title and map metadata lines preceding the rendered grid. */ + public const int CANVAS_HEADER_ROWS = 2; + /** The project's catalogues as this workspace holds them, unsaved edits included. */ + public ProjectCatalogs $catalogs; /** * @param ProjectMap[] $maps * @param array $recordDatabases Schema-driven categories, keyed by category key. @@ -36,14 +60,134 @@ public function __construct( public array $maps, public array $mapIds, public ProjectActorDatabase $actorDatabase, - public ProjectClassDatabase $classDatabase, - public ProjectSkillDatabase $skillDatabase, - public ProjectAnimationDatabase $animationDatabase, - public ProjectSystemDatabase $systemDatabase, - public ProjectQuestDatabase $questDatabase, public array $recordDatabases = [], public ?CutsceneLibrary $cutscenes = null, + public ?ProjectConfig $config = null, + public ?MenuPresentationSettings $menuPresentation = null, + public ?ProjectScriptCommands $scriptCommands = null, ) { + // A quest's id follows its name until something refers to it, and + // what refers to a quest lives across the whole project. + $this->getRecordDatabase('quests')?->useIdentityReferences( + fn(string $questId): bool => new QuestReferences($this)->exist($questId), + ); + // An income schedule's id follows its name until a script names it, and stays while one does. + $incomeUses = fn(string $id): array => ScriptCommandUses::findReferenceUses($this, + $this->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [], + ScriptCommandReference::INCOME_SCHEDULE)[$id] ?? []; + $this->getRecordDatabase('income_schedules')?->useIdentityReferences(fn(string $id): bool => $incomeUses($id) !== []); + $this->getRecordDatabase('income_schedules')?->useDeletionReferences($incomeUses); + // Marker roles follow the place categories as their page holds them, unsaved edits included. + $this->menuPresentation?->useCategoryCatalog(fn(): \Ichiloto\Engine\Field\MapCategoryCatalog => new ReferenceCatalog($this)->getMapCategoryCatalog()); + // A place category stays while a landmark names it. + $this->getRecordDatabase(\Ichiloto\Editor\Field\MapCategoryFields::CATEGORY)?->useDeletionReferences( + fn(string $category): array => \Ichiloto\Editor\Field\MapLandmarks::describeCategoryReferences($this->maps, $category), + ); + // A map role stays while a map is of it. + $this->getRecordDatabase(\Ichiloto\Editor\Field\MapRoleFields::CATEGORY)?->useDeletionReferences( + fn(string $role): array => \Ichiloto\Editor\Field\MapRoleFields::describeRoleReferences($this->maps, $role), + ); + // A place type stays while a map is of it or a tileset gives it an icon. + $this->getRecordDatabase(\Ichiloto\Editor\Field\PlaceTypeFields::CATEGORY)?->useDeletionReferences( + fn(string $type): array => \Ichiloto\Editor\Field\PlaceTypeFields::describeTypeReferences($this->maps, array_column(array_map( + static fn(\Ichiloto\Editor\Database\ProjectRecord $record): array => ['id' => $record->recordId, 'data' => (array) $record->toArray()], + $this->getRecordDatabase('tilesets')?->getRecords() ?? []), 'data', 'id'), $type), + ); + // A skill stays while a class, actor, enemy, skill or state names it; skills.retire moves or removes those first. + $this->getRecordDatabase('skills')?->useDeletionReferences(fn(string $skill): array => $this->describeSkillUses($skill)); + $references = new ReferenceCatalog($this); + foreach ($this->recordDatabases as $database) { $database->useAuthoringReferences($references); } + // A reusable script chooses its map-local subjects on the map its callers run it on. + $this->getRecordDatabase('common_events')?->useRecordReferences( + static fn(\Ichiloto\Editor\Database\ProjectRecord $record): ReferenceCatalog => $references->createCommonEventContext($record->recordId)); + // A map names its tileset and field resources as their pages hold them, unsaved edits included. + $this->catalogs = new ProjectCatalogs($this); + foreach ($this->maps as $map) { $map->useCatalogs($this->catalogs); } + $this->cutscenes?->useAuthoringReferences($references); + } + + /** + * One setting of `system.php` as the System record holds it, unsaved + * edits included: the starting party, the starting positions. + */ + public function getSystemField(string $key): mixed + { + return $this->getRecordDatabase('system')?->getRecordByIndex(0)?->get($key); + } + + /** + * The elements the game knows, as the Types record holds them, unsaved + * edits included: the list `system.php` declares, or the Engine's + * defaults when it lists none, read as the Engine's registry reads them. + * + * @return list The element identities, in authored order. + * @throws InvalidArgumentException When the list names an element twice or names an empty one. + */ + public function getElementIdentities(): array + { + $authored = $this->getRecordDatabase('types')?->getRecordByIndex(0)?->get('elements'); + + return ElementRegistry::getCanonicalIdentities(is_array($authored) ? $authored : []); + } + + /** + * The project's quests as validation and the journal panes read them. + * + * @return list + */ + public function getQuests(): array + { + return array_map( + static fn(ProjectRecord $record): ProjectQuest => new ProjectQuest((array) $record->toArray()), + array_values($this->getRecordDatabase('quests')?->getRecords() ?? []), + ); + } + + /** + * What a project data file holds as the workspace stands: the payload + * its loaded databases would write to it now, every unsaved edit folded + * in, including those of an open change set; or null when none holds an + * unsaved edit for it, so the file on disk is current. A file several + * categories share (items, weapons and armors) is read once from disk + * and each dirty category folds its part in, as a save would. Every + * reader of a catalogue or record by its path reads it through here + * ({@see \Ichiloto\Editor\Database\ProjectCatalogs}), so an edit made + * earlier in a session reaches the reads after it before anything is + * written. + * + * @return array{exists: bool, payload: mixed}|null + */ + public function readCurrentData(string $path): ?array + { + $path = ProjectRecordDatabase::canonicalizePath($path); + $sharing = array_values(array_filter($this->recordDatabases, static fn(ProjectRecordDatabase $database): bool => + $database->isDirty() && $database->sharesBackingFile() && ProjectRecordDatabase::canonicalizePath($database->backingFilePath()) === $path)); + if ($sharing !== []) { + $file = PhpDataFile::load($path, $this->projectRoot); + $payload = is_array($file->payload) ? $file->payload : []; + foreach ($sharing as $database) { + $payload = $database->foldInto($payload); + } + + return ['exists' => true, 'payload' => $payload]; + } + foreach ($this->recordDatabases as $database) { + $current = $database->isDirty() ? $database->readCurrentRecordFile($path) : null; + if ($current !== null) { + return $current; + } + } + // A cinematic, summon or effect holds its files as the Cutscenes workspace edits them. + foreach ($this->cutscenes?->dirtyAssets() ?? [] as $asset) { + foreach ($asset->paths() as $assetPath) { + if (ProjectRecordDatabase::canonicalizePath($assetPath) === $path) { + return $asset->isDeleted() ? ['exists' => false, 'payload' => null] + : ['exists' => true, 'payload' => $assetPath === $asset->partnerPath() ? $asset->partner() : $asset->data()]; + } + } + } + + return null; } /** @@ -57,6 +201,56 @@ public function getRecordDatabase(string $categoryKey): ?ProjectRecordDatabase return $this->recordDatabases[$categoryKey] ?? null; } + /** + * Says what names a skill, unsaved edits included: classes, actors, + * enemies, other skills and states, in words. + * + * @return list + */ + public function describeSkillUses(string $skill): array + { + $records = []; + foreach (['classes' => 'Class', 'enemies' => 'Enemy', 'skills' => 'Skill', 'states' => 'State', + MasteryFields::REGIONS => 'Mastery region', MasteryFields::BRANCHES => 'Mastery branch'] as $category => $noun) { + foreach ($this->getRecordDatabase($category)?->getRecords() ?? [] as $record) { + $label = sprintf('%s %s', $noun, strval($record->get('name') ?? $record->get('actor') ?? $record->recordId)); + if ($category === 'skills' && $record->get('name') === $skill) { continue; } + $records[$label] = $record->toArray(); + } + } + foreach ($this->actorDatabase->getActors() as $actor) { + $records['Actor ' . $actor->getName()] = $actor->getData(); + } + + return SkillReferences::describe($records, $skill); + } + + /** + * Reads the project's skill catalogue as the workspace holds it: every + * skill record, unsaved edits included, as the runtime reads them, + * identified and classified as the runtime finds them. + * + * @return SkillCatalog + */ + public function loadSkillCatalog(): SkillCatalog + { + return $this->catalogs->loadSkillCatalog(); + } + + /** + * Returns every skill name a reference may use: the Skills records as + * currently edited, in the order menus list them. + * + * @return string[] + */ + public function getSkillNames(): array + { + return array_values(array_unique(array_filter(array_map( + static fn(ProjectRecord $record): string => trim(strval($record->get('name') ?? '')), + $this->getRecordDatabase('skills')?->getRecords() ?? [], + ), static fn(string $name): bool => $name !== ''))); + } + /** * Returns the maps root for the project. * @@ -73,11 +267,11 @@ public function getMapsRoot(): string * @param string $projectRoot The project root. * @return self */ - public static function fromProject(string $projectRoot): self + public static function fromProject(string $projectRoot, bool $graphical = false): self { return ProjectDirectoryContext::run( $projectRoot, - static fn(string $canonicalRoot): self => self::loadFromProject($canonicalRoot), + static fn(string $canonicalRoot): self => self::loadFromProject($canonicalRoot, $graphical), ); } @@ -87,7 +281,7 @@ public static function fromProject(string $projectRoot): self * @param string $projectRoot Absolute project root. * @return self */ - private static function loadFromProject(string $projectRoot): self + private static function loadFromProject(string $projectRoot, bool $graphical): self { $configPath = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'ichiloto.json'; @@ -101,6 +295,10 @@ private static function loadFromProject(string $projectRoot): self throw new RuntimeException("Unable to parse $configPath."); } + // A project in another format places its contents differently; open + // nothing rather than misplace it. `ichiloto upgrade` converts it. + ProjectFormat::assertSupported($config[ProjectFormat::KEY] ?? null); + $projectName = (string) ($config['name'] ?? basename($projectRoot)); $projectId = trim((string) ($config['id'] ?? '')); $mainFile = (string) ($config['main'] ?? ''); @@ -109,6 +307,15 @@ private static function loadFromProject(string $projectRoot): self // item store; without this the category would report a bootstrap // failure instead of the author's enemies. EngineDataBootstrap::ensure($projectRoot); + $projectConfig = new ProjectConfig($projectRoot); + // Each settings file has one owner, shared by every category over it. + $menuPresentation = new MenuPresentationSettings($projectRoot); + $settingsFiles = ['config.php' => $projectConfig, MenuPresentationSettings::RELATIVE_PATH => $menuPresentation, + FieldPresentationSettings::RELATIVE_PATH => new FieldPresentationSettings($projectRoot)]; + // Before any schema is built: the command editor offers, and Engine + // validation accepts, exactly this project's registered commands. + $scriptCommands = ProjectScriptCommands::fromProject($projectRoot); + $scriptCommands->activate(); return new self( projectRoot: $projectRoot, @@ -118,16 +325,15 @@ private static function loadFromProject(string $projectRoot): self maps: $maps = self::discoverMaps($projectRoot), mapIds: array_map(static fn(ProjectMap $map): string => $map->mapId, $maps), actorDatabase: ProjectActorDatabase::fromProject($projectRoot), - classDatabase: ProjectClassDatabase::fromProject($projectRoot), - skillDatabase: ProjectSkillDatabase::fromProject($projectRoot), - animationDatabase: ProjectAnimationDatabase::fromProject($projectRoot), - systemDatabase: ProjectSystemDatabase::fromProject($projectRoot), - questDatabase: ProjectQuestDatabase::fromProject($projectRoot), recordDatabases: array_map( - static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema), - RecordSchemaCatalog::all(), + static fn(RecordSchema $schema): ProjectRecordDatabase => ProjectRecordDatabase::fromProject($projectRoot, $schema, + $settingsFiles[$schema->relativePath] ?? null, $graphical), + RecordSchemaCatalog::all($graphical), ), cutscenes: CutsceneLibrary::fromProject($projectRoot), + config: $projectConfig, + menuPresentation: $menuPresentation, + scriptCommands: $scriptCommands, ); } @@ -155,9 +361,13 @@ public function getInspectorLines(int $selectedMapIndex = 0): array sprintf('Size: %d x %d', $selectedMap->getWidth(), $selectedMap->getHeight()), sprintf('Events: %d', $selectedMap->getEventDefinitionCount()), sprintf('Triggers: %d', $selectedMap->getTriggerCount()), + ...($selectedMap->getGridSourceIssue() === null ? [] : [ + 'Read-only: map source needs repair.', + $selectedMap->getGridSourceIssue(), + ]), '', basename($selectedMap->dataPath), - basename($selectedMap->mapPath), + $selectedMap->isLegacyMap() ? basename($selectedMap->mapPath) : 'layers/ (' . (count($selectedMap->getLayers()) - 1) . ' layers)', basename($selectedMap->eventPath), ] : [ 'No map selected.', @@ -197,7 +407,8 @@ public function getAssetLines(int $selectedMapIndex = 0, ?array $visibleIndexes $prefix = $index === $selectedMapIndex ? '> ' : ' '; $dirty = $this->maps[$index]->isDirty() ? ' *' : ''; - $lines[] = sprintf('%s%s%s', $prefix, $mapId, $dirty); + $readOnly = $this->maps[$index]->getGridSourceIssue() === null ? '' : ' [read-only]'; + $lines[] = sprintf('%s%s%s%s', $prefix, $mapId, $dirty, $readOnly); } return $lines; @@ -223,14 +434,91 @@ public function hasUnsavedChanges(): bool } return $this->actorDatabase->isDirty() - || $this->classDatabase->isDirty() - || $this->skillDatabase->isDirty() - || $this->animationDatabase->isDirty() - || $this->systemDatabase->isDirty() - || $this->questDatabase->isDirty() + || ($this->config?->isDirty() ?? false) + || ($this->menuPresentation?->isDirty() ?? false) || ($this->cutscenes?->hasUnsavedChanges() ?? false); } + /** + * Analyses where the player can reach as saving would leave the project: + * maps with unsaved changes are read as this workspace holds them. + */ + public function analyzeReachability(): ProjectReachability + { + $unsaved = []; + foreach ($this->maps as $map) { + if ($map->isDirty()) { + $unsaved[$map->mapId] = ['data' => $map->getEditableData(), 'layers' => $map->getLayerSet(), 'eventLayer' => $map->getEventLayerCells()]; + } + } + + return ProjectReachability::analyze($this->projectRoot . DIRECTORY_SEPARATOR . 'assets', $unsaved); + } + + /** + * Names every document holding unsaved changes: maps by id, then + * databases, then cutscenes, so a guard can say what would be lost. + * + * @return list + */ + public function listUnsavedChanges(): array + { + $unsaved = []; + + foreach ($this->maps as $map) { + if ($map->isDirty()) { + $unsaved[] = $map->mapId; + } + } + + foreach ($this->listSaveableDatabases() as $label => $database) { + if ($database->isDirty()) { + $unsaved[] = $label . ' database'; + } + } + + foreach ($this->cutscenes?->dirtyAssets() ?? [] as $asset) { + $unsaved[] = $asset->type->noun() . ' ' . $asset->id; + } + + return $unsaved; + } + + /** + * Returns every database a save can write, keyed by display label. + * Read-only record categories are left out: they hold no edits, and + * asking them to save would raise instead of doing nothing. + * + * @return array + */ + public function listSaveableDatabases(): array + { + $databases = [ + 'Actors' => $this->actorDatabase, + ]; + + if ($this->config !== null) { + $databases['Project configuration'] = $this->config; + } + + if ($this->menuPresentation !== null) { + $databases['Menu theme'] = $this->menuPresentation; + } + + foreach ($this->recordDatabases as $categoryKey => $recordDatabase) { + if ($recordDatabase->isEditable()) { + // By its own label: an embedded category (battle art) has no + // Database list position, and naming it by one replaced the + // Actors database here, hiding it from Save All. + $category = DatabaseCatalog::findByKey($categoryKey) + ?? throw new LogicException(sprintf('The record category %s is not in the Database catalog.', $categoryKey)); + $databases[$category->label] = $recordDatabase; + } + } + + return $databases; + } + /** * Returns a workspace with one map swapped for a fresh instance, keeping * every other loaded object intact (no whole-workspace reload). @@ -246,6 +534,43 @@ public function withReplacedMap(int $index, ProjectMap $map): self { $maps = $this->maps; $maps[$index] = $map; + + return $this->withMaps($maps); + } + + /** + * Returns a workspace that also holds a map just written to disk, such + * as a created or duplicated one, keeping every loaded object and its + * unsaved changes. A map that cannot be read joins read-only, as a full + * rescan would add it. + */ + public function withLoadedMap(string $mapId): self + { + $directory = $this->getMapsRoot() . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $mapId); + try { + $map = ProjectMap::fromDirectory($this->getMapsRoot(), $directory); + } catch (Throwable $error) { + $map = ProjectMap::createReadOnlyFromDirectory($this->getMapsRoot(), $directory, $error->getMessage()); + } + $maps = array_values(array_filter($this->maps, static fn(ProjectMap $loaded): bool => $loaded->mapId !== $map->mapId)); + + return $this->withMaps([...$maps, $map]); + } + + /** Returns a workspace without a deleted map, keeping every other loaded object. */ + public function withoutMap(string $mapId): self + { + return $this->withMaps(array_values(array_filter($this->maps, static fn(ProjectMap $map): bool => $map->mapId !== $mapId))); + } + + /** + * The same project with another map list, in map id order as a full + * rescan lists them. + * + * @param list $maps + */ + private function withMaps(array $maps): self + { usort($maps, static fn(ProjectMap $left, ProjectMap $right): int => strcmp($left->mapId, $right->mapId)); return new self( @@ -256,13 +581,11 @@ public function withReplacedMap(int $index, ProjectMap $map): self maps: $maps, mapIds: array_map(static fn(ProjectMap $workspaceMap): string => $workspaceMap->mapId, $maps), actorDatabase: $this->actorDatabase, - classDatabase: $this->classDatabase, - skillDatabase: $this->skillDatabase, - animationDatabase: $this->animationDatabase, - systemDatabase: $this->systemDatabase, - questDatabase: $this->questDatabase, recordDatabases: $this->recordDatabases, cutscenes: $this->cutscenes, + config: $this->config, + menuPresentation: $this->menuPresentation, + scriptCommands: $this->scriptCommands, ); } @@ -275,6 +598,7 @@ classDatabase: $this->classDatabase, * @param int $offsetX The horizontal preview offset. * @param int $offsetY The vertical preview offset. * @param bool $showEventOverlay Whether event markers should be rendered. + * @param array> $stampPreview A stamp's footprint, as ProjectMap::renderPreview() takes it. * @return string[] */ public function getCanvasLines( @@ -288,6 +612,11 @@ public function getCanvasLines( ?int $selectedNpcIndex = null, ?string $selectedNpcSprite = null, ?array $cursor = null, + array $layerVisibility = [], + ?string $activeLayer = null, + bool $terminalPreview = false, + bool $dimInactive = false, + array $stampPreview = [], ): array { $selectedMap = $this->getMapByIndex($selectedMapIndex); @@ -300,8 +629,16 @@ public function getCanvasLines( ]; } - $previewHeight = max(0, $height - 2); - $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite); + if ($selectedMap->getGridSourceIssue() !== null) { + return [ + sprintf('Preview: %s', $selectedMap->mapId), + 'Read-only: repair this map before editing.', + $selectedMap->getGridSourceIssue(), + ]; + } + + $previewHeight = max(0, $height - self::CANVAS_HEADER_ROWS); + $previewLines = $selectedMap->renderPreview($width, $previewHeight, $offsetX, $offsetY, $showEventOverlay, $showNpcOverlay, $selectedNpcIndex, $selectedNpcSprite, $layerVisibility, $activeLayer, $terminalPreview, $dimInactive, $stampPreview); return [ sprintf('Preview: %s', $selectedMap->mapId), @@ -336,23 +673,48 @@ public function getMapByIndex(int $index): ?ProjectMap * @param string|null $baseName The preferred base name. * @return string The created map id. */ - public function createMap(?string $baseName = null, ?FileSetOperations $files = null): string + public function createMap(?string $baseName = null, ?FileSetOperations $files = null, ?string $kind = null, int $width = 48, + int $height = 18): string { - $mapsRoot = $this->getMapsRoot(); $baseName = $this->getNextAvailableBaseName($baseName ?? 'new-map'); - $directory = $mapsRoot . DIRECTORY_SEPARATOR . $baseName; - ProjectMap::createBlank($directory, $baseName, self::humanizeBaseName($baseName), files: $files); - return $baseName; + return $this->createMapAt($baseName, null, $files, $kind, $width, $height); } /** - * Duplicates the selected map with a smart sibling name. + * Creates a new blank map at the id an author chose: a folder path under the maps root, nested inside another + * map's folder or any other folder ({@see MapFolder}). + * + * @param string $mapId The new map's id. + * @param string|null $displayName Its name; null names it after the id's last segment. + * @return string The created map id. + * @throws MapSourceRefusal When the size is no size, the id is not a map folder path or its folder is taken, or + * the files cannot be written. + */ + public function createMapAt(string $mapId, ?string $displayName = null, ?FileSetOperations $files = null, ?string $kind = null, + int $width = 48, int $height = 18): string + { + if ($width < 1 || $height < 1) { + throw new MapSourceRefusal(sprintf('A map is at least 1 x 1 cells, not %d x %d.', $width, $height)); + } + $directory = self::requireFreeMapFolder($this->getMapsRoot(), $mapId); + $baseName = basename($directory); + ProjectMap::createBlank($directory, $baseName, $displayName ?? self::humanizeBaseName($baseName), $width, $height, $files, $kind); + + return $mapId; + } + + /** + * Duplicates the selected map with a smart sibling name, or at the id an author chose ({@see MapFolder}). The + * copy is named after its id's last segment. * * @param int $selectedMapIndex The selected map index. + * @param string|null $mapId The copy's id; null takes the next free `-copy` sibling. * @return string|null The duplicated map id. + * @throws MapSourceRefusal When the map is read-only, the chosen id is not a map folder path or its folder is + * taken, or the copy cannot be written. */ - public function duplicateMap(int $selectedMapIndex, ?FileSetOperations $files = null): ?string + public function duplicateMap(int $selectedMapIndex, ?FileSetOperations $files = null, ?string $mapId = null): ?string { $selectedMap = $this->getMapByIndex($selectedMapIndex); @@ -360,13 +722,21 @@ public function duplicateMap(int $selectedMapIndex, ?FileSetOperations $files = return null; } - $parentDirectory = dirname($selectedMap->directory); - $originalBaseName = basename($selectedMap->directory); - $baseName = $this->getNextAvailableSiblingBaseName($parentDirectory, $originalBaseName . '-copy'); - $directory = $parentDirectory . DIRECTORY_SEPARATOR . $baseName; - $selectedMap->duplicateTo($directory, $baseName, self::humanizeBaseName($baseName), $files); + if ($selectedMap->getGridSourceIssue() !== null) { + throw new MapSourceRefusal("{$selectedMap->mapId} is read-only: {$selectedMap->getGridSourceIssue()}"); + } $mapsRoot = $this->getMapsRoot(); + if ($mapId === null) { + $parentDirectory = dirname($selectedMap->directory); + $directory = $parentDirectory . DIRECTORY_SEPARATOR + . $this->getNextAvailableSiblingBaseName($parentDirectory, basename($selectedMap->directory) . '-copy'); + } else { + $directory = self::requireFreeMapFolder($mapsRoot, $mapId); + } + $baseName = basename($directory); + $selectedMap->duplicateTo($directory, $baseName, self::humanizeBaseName($baseName), $files); + $relativeDirectory = substr($directory, strlen($mapsRoot) + 1); return str_replace(DIRECTORY_SEPARATOR, '/', $relativeDirectory); @@ -386,20 +756,54 @@ public function deleteMap(int $selectedMapIndex, ?FileSetOperations $files = nul return null; } - // Only the split triplet is the map's; deleting a map must not take - // an author's own notes or assets in the same folder with it. The - // three members go as one transaction -- a failure on any of them - // puts the removed ones back, bytes and modification times -- and - // the folder goes only once it is empty. + if ($selectedMap->getGridSourceIssue() !== null) { + throw new MapSourceRefusal("{$selectedMap->mapId} is read-only: {$selectedMap->getGridSourceIssue()}"); + } + + // Only the split members are the map's -- its data, grid layers and + // graphics/ tile layers; deleting a map must not take an author's + // own notes or assets in the same folder with it. The members go as + // one transaction -- a failure on any of them puts the removed ones + // back, bytes and modification times -- and each folder goes only + // once it is empty. $transaction = new FileSetTransaction($selectedMap->directory, $files ?? new FilesystemFileSetOperations()); $transaction->remove($selectedMap->dataPath); - $transaction->remove($selectedMap->mapPath); - $transaction->remove($selectedMap->eventPath); + foreach ($selectedMap->getStoredGridPaths() as $path) { + $transaction->remove($path); + } $transaction->commit(); + foreach ([$selectedMap->directory . '/layers', $selectedMap->directory . '/' . MapGraphics::DIRECTORY] as $directory) { + if (is_dir($directory) && array_diff(scandir($directory) ?: [], ['.', '..']) === []) { + @rmdir($directory); + } + } + // A nested map's folders up to the maps root go with it while they are empty, as a move leaves them. + $mapsRoot = $this->getMapsRoot(); + for ($directory = $selectedMap->directory; str_starts_with($directory, $mapsRoot . DIRECTORY_SEPARATOR); $directory = dirname($directory)) { + if (is_dir($directory) && (array_diff(scandir($directory) ?: [], ['.', '..']) !== [] || ! @rmdir($directory))) { + break; + } + } + return $selectedMap->mapId; } + /** + * The folder a map with a chosen id is written to, refused as the map transaction refuses a source it cannot + * take. + * + * @throws MapSourceRefusal When the id is not a map folder path or its folder is taken. + */ + private static function requireFreeMapFolder(string $mapsRoot, string $mapId): string + { + try { + return MapFolder::requireFreeFolder($mapsRoot, $mapId); + } catch (InvalidArgumentException $error) { + throw new MapSourceRefusal($error->getMessage(), previous: $error); + } + } + /** * Discovers maps from the folder-per-map layout. * @@ -443,10 +847,16 @@ private static function discoverMaps(string $projectRoot): array ksort($mapDirectories); - return array_values(array_map( - static fn(string $directory): ProjectMap => ProjectMap::fromDirectory($mapsRoot, $directory), - $mapDirectories - )); + $maps = []; + foreach ($mapDirectories as $directory) { + try { + $maps[] = ProjectMap::fromDirectory($mapsRoot, $directory); + } catch (Throwable $error) { + $maps[] = ProjectMap::createReadOnlyFromDirectory($mapsRoot, $directory, $error->getMessage()); + } + } + + return $maps; } /** @@ -511,7 +921,7 @@ private static function humanizeBaseName(string $baseName): string * * @return string[] */ - public function getCollisionGlyphs(): array + public function getCollisionGlyphs(?string $layerName = null): array { $dictionaryFile = rtrim($this->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'Maps' . DIRECTORY_SEPARATOR . 'collisions.php'; @@ -532,7 +942,13 @@ public function getCollisionGlyphs(): array $glyphs = []; - foreach (array_keys($dictionary) as $key) { + $entries = array_filter($dictionary, static fn(mixed $value): bool => ! is_array($value)); + foreach ($dictionary as $name => $section) { + if (is_array($section) && ($layerName === null || $name === $layerName)) { + $entries = array_replace($entries, $section); + } + } + foreach (array_keys($entries) as $key) { // PHP normalizes digit-only string keys such as "8" to integers. $glyph = (string) $key; diff --git a/src/Review/FieldStill.php b/src/Review/FieldStill.php new file mode 100644 index 00000000..2c135310 --- /dev/null +++ b/src/Review/FieldStill.php @@ -0,0 +1,33 @@ + $sprites In paint order ({@see PresentationSprite::orderedList()}), on map cells. + * @param array{x: int, y: int, width: int, height: int} $region The rectangle of the map it shows, in cells. + */ + public function __construct( + public PresentationWorld $world, + public array $sprites, + public array $region, + public int $tileFrame = 0, + ) { + if ($tileFrame < 0) { + throw new InvalidArgumentException('A still shows a tile frame of 0 or more.'); + } + } +} diff --git a/src/Review/FieldStillRaster.php b/src/Review/FieldStillRaster.php new file mode 100644 index 00000000..5b63902b --- /dev/null +++ b/src/Review/FieldStillRaster.php @@ -0,0 +1,427 @@ + What the still could not draw, once each. */ + public private(set) array $diagnostics = []; + + private int $cell = 48; + /** @var list */ + private array $layers = []; + /** @var array> */ + private array $cells = []; + /** @var array>> Each tile-drawing layer's tile index by row and column. */ + private array $tiles = []; + /** @var array{tileSize: int, sheets: list, tiles: list>}|null */ + private ?array $tileset = null; + /** @var array */ + private array $sheets = []; + /** @var array> Each tile's composed frames. */ + private array $composed = []; + /** @var array>> The cells tiles cover, by covered layer; '' covers every layer. */ + private array $covered = []; + /** @var array */ + private array $images = []; + private ?float $fontSize = null; + + public function __construct(private readonly string $assetRoot, private readonly ?string $fontPath = null) + { + if (! extension_loaded('gd')) { + throw new RuntimeException('Drawing a graphical still needs the PHP gd extension.'); + } + } + + /** Whether the raster draws a renderer feature. */ + public static function supports(string $capability): bool + { + return in_array($capability, self::CAPABILITIES, true); + } + + /** The first monospaced system font found, the graphical renderer's own field font first. */ + public static function findSystemFont(): ?string + { + return array_find(self::SYSTEM_FONTS, static fn(string $path): bool => is_file($path)); + } + + /** + * Draws a still's region at a scale: 1 is native size, one field cell + * {@see \Ichiloto\Engine\Rendering\FieldViewport::TILE_SIZE} logical + * pixels square; a quarter or a half shows an overview. + * + * @throws InvalidArgumentException When the scale is not in (0, 1], or a native image would be too large. + */ + public function rasterize(FieldStill $still, float $scale = 1.0): GdImage + { + if (! is_finite($scale) || $scale <= 0 || $scale > 1) { + throw new InvalidArgumentException('A still is drawn at a scale above 0 and at most 1.'); + } + $this->prepare($still); + ['x' => $x, 'y' => $y, 'width' => $width, 'height' => $height] = $still->region; + $pixelWidth = $width * $this->cell; + $pixelHeight = $height * $this->cell; + if ($scale === 1.0) { + if ($pixelWidth * $pixelHeight > self::MAX_NATIVE_PIXELS) { + throw new InvalidArgumentException(sprintf('A native %dx%d still is too large to draw; choose a region or a smaller scale.', + $pixelWidth, $pixelHeight)); + } + $image = self::createCanvas($pixelWidth, $pixelHeight); + $this->drawInto($image, $still, $x * $this->cell, $y * $this->cell, $pixelWidth, $pixelHeight); + + return $image; + } + $output = self::createCanvas(max(1, (int) round($pixelWidth * $scale)), max(1, (int) round($pixelHeight * $scale))); + for ($blockY = 0; $blockY < $height; $blockY += self::BLOCK_CELLS) { + for ($blockX = 0; $blockX < $width; $blockX += self::BLOCK_CELLS) { + $cellsAcross = min(self::BLOCK_CELLS, $width - $blockX); + $cellsDown = min(self::BLOCK_CELLS, $height - $blockY); + $block = self::createCanvas($cellsAcross * $this->cell, $cellsDown * $this->cell); + $this->drawInto($block, $still, ($x + $blockX) * $this->cell, ($y + $blockY) * $this->cell, + $cellsAcross * $this->cell, $cellsDown * $this->cell); + $left = (int) round($blockX * $this->cell * $scale); + $top = (int) round($blockY * $this->cell * $scale); + imagecopyresampled($output, $block, $left, $top, 0, 0, + (int) round(($blockX + $cellsAcross) * $this->cell * $scale) - $left, + (int) round(($blockY + $cellsDown) * $this->cell * $scale) - $top, + $cellsAcross * $this->cell, $cellsDown * $this->cell); + } + } + + return $output; + } + + /** Reads the world's upload: its layers, glyph cells, tiles and the cells those tiles cover. */ + private function prepare(FieldStill $still): void + { + $operations = $still->world->getOperations(true, true); + $definition = $operations[0]['value'] ?? throw new InvalidArgumentException('A still needs its world\'s definition.'); + $this->cell = (int) $definition['cellWidth']; + $this->tileset = $definition['tileset'] ?? null; + $this->layers = $this->cells = $this->tiles = $this->covered = $this->sheets = $this->composed = []; + foreach ($definition['layers'] as $index => $layer) { + $this->layers[] = ['id' => $layer['id'], 'layer' => (int) $layer['layer'], 'kind' => $layer['kind'], + 'covers' => $layer['coversLayerId'] ?? null, 'index' => $index]; + } + // Layers of equal number paint in the order listed. + usort($this->layers, static fn(array $a, array $b): int => [$a['layer'], $a['index']] <=> [$b['layer'], $b['index']]); + foreach (array_slice($operations, 1) as $operation) { + foreach ($operation['rows'] ?? [] as $row) { + foreach ($row['cells'] as $column => $cell) { + if ($operation['op'] === 'worldRows') { + $this->cells[$row['row']][$column] = $cell; + } elseif ($operation['op'] === 'worldTiles') { + $this->tiles[$operation['layerId']][$row['row']][$cell['column']] = $cell['tile']; + } + } + } + } + if ($this->tileset === null) { + return; + } + foreach ($this->tileset['sheets'] as $index => $path) { + $this->sheets[$index] = $this->loadImage($path, 'tileset sheet'); + } + foreach ($this->layers as $layer) { + if ($layer['kind'] !== 'tiles') { + continue; + } + foreach ($this->tiles[$layer['id']] ?? [] as $y => $columns) { + foreach ($columns as $x => $tile) { + if ($this->getTileFrame($tile, 0) !== null) { + $this->covered[$layer['covers'] ?? ''][$y][$x] = true; + } + } + } + } + } + + /** + * Draws everything that reaches a rectangle of world pixels into an image + * of its size, in paint order. + */ + private function drawInto(GdImage $image, FieldStill $still, int $left, int $top, int $width, int $height): void + { + $sprites = $still->sprites; + $next = 0; + foreach ($this->layers as $layer) { + // A world layer paints before a sprite of the same layer. + for (; $next < count($sprites) && $sprites[$next]->layer < $layer['layer']; $next++) { + $this->drawSprite($image, $sprites[$next], $left, $top); + } + if ($layer['kind'] === 'tiles' || $layer['kind'] === 'shadows') { + $this->drawTileLayer($image, $layer['id'], $still->tileFrame, $left, $top, $width, $height); + } else { + $this->drawGlyphLayer($image, $layer['id'], $left, $top, $width, $height); + } + } + for (; $next < count($sprites); $next++) { + $this->drawSprite($image, $sprites[$next], $left, $top); + } + } + + private function drawTileLayer(GdImage $image, string $layerId, int $tileFrame, int $left, int $top, int $width, int $height): void + { + $size = (int) ($this->tileset['tileSize'] ?? $this->cell); + $ratio = $this->cell / $size; + // A tile reaches at most one tile beyond its cell. + [$firstColumn, $lastColumn, $firstRow, $lastRow] = $this->findCellRange($left, $top, $width, $height, 1); + foreach ($this->tiles[$layerId] ?? [] as $y => $columns) { + if ($y < $firstRow || $y > $lastRow) { + continue; + } + foreach ($columns as $x => $tile) { + if ($x < $firstColumn || $x > $lastColumn || ($frame = $this->getTileFrame($tile, $tileFrame)) === null) { + continue; + } + $definition = $this->tileset['tiles'][$tile]; + $tileWidth = (int) ($definition['width'] ?? $size); + imagecopyresampled($image, $frame, + (int) round($x * $this->cell + ($definition['left'] ?? 0) * $ratio) - $left, + (int) round($y * $this->cell + ($definition['top'] ?? 0) * $ratio) - $top, + 0, 0, (int) round($tileWidth * $ratio), $this->cell, $tileWidth, $size); + } + } + } + + private function drawGlyphLayer(GdImage $image, string $layerId, int $left, int $top, int $width, int $height): void + { + [$firstColumn, $lastColumn, $firstRow, $lastRow] = $this->findCellRange($left, $top, $width, $height, 0); + for ($y = max(0, $firstRow); $y <= $lastRow; $y++) { + for ($x = max(0, $firstColumn); $x <= $lastColumn; $x++) { + $cell = $this->cells[$y][$x] ?? null; + if ($cell === null || $cell['ownerLayerId'] !== $layerId + || ($cell['background'] === null && trim($cell['glyph']) === '') + || isset($this->covered[''][$y][$x]) || isset($this->covered[$layerId][$y][$x])) { + continue; + } + $cellLeft = $x * $this->cell - $left; + $cellTop = $y * $this->cell - $top; + imagefilledrectangle($image, $cellLeft, $cellTop, $cellLeft + $this->cell - 1, $cellTop + $this->cell - 1, + self::allocate($image, TerminalPalette::resolveDescriptor($cell['background'], TerminalPalette::DEFAULT_BACKGROUND))); + if (trim($cell['glyph']) !== '') { + $this->drawGlyph($image, $cell['glyph'], + TerminalPalette::resolveDescriptor($cell['foreground'], TerminalPalette::DEFAULT_FOREGROUND), $cellLeft, $cellTop); + } + } + } + } + + private function drawGlyph(GdImage $image, string $glyph, int $rgb, int $cellLeft, int $cellTop): void + { + $font = $this->fontPath ?? self::findSystemFont(); + if ($font === null || ! is_file($font)) { + $this->diagnose('No monospaced font was found, so glyph cells show their colours without their glyphs; give one with --font.'); + + return; + } + $size = $this->fontSize ??= $this->fitFont($font); + $box = imagettfbbox($size, 0, $font, $glyph); + $reference = imagettfbbox($size, 0, $font, 'Mg'); + if ($box === false || $reference === false) { + $this->diagnose(sprintf('The font %s cannot draw glyphs, so glyph cells show their colours alone.', $font)); + + return; + } + $glyphWidth = $box[2] - $box[0]; + // Centred across the cell, on a baseline that centres the font's capital and descender height. + $baseline = $cellTop + (int) round(($this->cell + (-$reference[5]) - $reference[1]) / 2); + imagettftext($image, $size, 0, $cellLeft + (int) round(($this->cell - $glyphWidth) / 2) - $box[0], $baseline, + self::allocate($image, $rgb), $font, $glyph); + } + + /** The font size whose character advance and line fit the cell, as the renderer fits its field font. */ + private function fitFont(string $font): float + { + $box = imagettfbbox(100, 0, $font, '0000000000'); + $line = imagettfbbox(100, 0, $font, 'Mg'); + if ($box === false || $line === false) { + return $this->cell * 0.6; + } + $advance = ($box[2] - $box[0]) / 10 / 100; + $height = ($line[1] - $line[5]) / 100 * 1.2; + + return min($this->cell * 0.95 / max($advance, 0.01), $this->cell * 0.95 / max($height, 0.01)); + } + + private function drawSprite(GdImage $image, PresentationSprite $sprite, int $left, int $top): void + { + $source = $this->loadImage($sprite->asset, 'sprite'); + if ($source === null) { + return; + } + $rect = $sprite->sourceRect; + [$sourceX, $sourceY, $sourceWidth, $sourceHeight] = $rect === null + ? [0, 0, imagesx($source), imagesy($source)] : [$rect->x, $rect->y, $rect->width, $rect->height]; + if ($sourceX + $sourceWidth > imagesx($source) || $sourceY + $sourceHeight > imagesy($source)) { + $this->diagnose(sprintf('%s is smaller than the crop sprite %s draws from it; the sprite is left out.', $sprite->asset, $sprite->id)); + + return; + } + if ($sprite->quarterTurns > 0) { + $crop = self::createCanvas($sourceWidth, $sourceHeight, transparent: true); + imagecopy($crop, $source, 0, 0, $sourceX, $sourceY, $sourceWidth, $sourceHeight); + // The renderer turns clockwise; imagerotate turns the other way. + $source = imagerotate($crop, (360 - 90 * $sprite->quarterTurns) % 360, (int) imagecolorallocatealpha($crop, 0, 0, 0, 127)); + if ($source === false) { + return; + } + imagesavealpha($source, true); + [$sourceX, $sourceY, $sourceWidth, $sourceHeight] = [0, 0, imagesx($source), imagesy($source)]; + } + $pivotX = $sprite->pivot?->x ?? 0.5; + $pivotY = $sprite->pivot?->y ?? 1.0; + $spriteLeft = ($sprite->x + 0.5) * $this->cell - $sprite->width / 2 + (0.5 - $pivotX) * $sprite->width; + $spriteTop = ($sprite->y + 1) * $this->cell - $sprite->height + (1 - $pivotY) * $sprite->height - $sprite->lift; + [$drawLeft, $drawTop, $drawWidth, $drawHeight] = [$spriteLeft, $spriteTop, (float) $sprite->width, (float) $sprite->height]; + if ($rect === null) { + // A whole image is contained in the sprite's size, centred. + $fit = min($sprite->width / $sourceWidth, $sprite->height / $sourceHeight); + $drawWidth = $sourceWidth * $fit; + $drawHeight = $sourceHeight * $fit; + $drawLeft += ($sprite->width - $drawWidth) / 2; + $drawTop += ($sprite->height - $drawHeight) / 2; + } + imagecopyresampled($image, $source, (int) round($drawLeft) - $left, (int) round($drawTop) - $top, $sourceX, $sourceY, + max(1, (int) round($drawWidth)), max(1, (int) round($drawHeight)), $sourceWidth, $sourceHeight); + } + + /** A tile's composed frame at an animation counter, or null when its art cannot be read. */ + private function getTileFrame(int $tile, int $tileFrame): ?GdImage + { + $definition = $this->tileset['tiles'][$tile] ?? null; + if ($definition === null || $definition['frames'] === []) { + return null; + } + $index = $tileFrame % count($definition['frames']); + if (array_key_exists($index, $this->composed[$tile] ?? [])) { + return $this->composed[$tile][$index]; + } + $size = (int) $this->tileset['tileSize']; + // A tile whose pieces cannot all be read shows no frame at all, so its cell keeps its glyph. + foreach ($definition['frames'] as $pieces) { + foreach ($pieces as $piece) { + $sheet = isset($piece['sheet']) ? ($this->sheets[$piece['sheet']] ?? null) : null; + if (isset($piece['sheet']) && ($sheet === null || $piece['x'] + $piece['width'] > imagesx($sheet) + || $piece['y'] + $piece['height'] > imagesy($sheet))) { + return $this->composed[$tile][$index] = null; + } + } + } + $frame = self::createCanvas((int) ($definition['width'] ?? $size), $size, transparent: true); + foreach ($definition['frames'][$index] as $piece) { + if (isset($piece['fill'])) { + [$red, $green, $blue, $alpha] = $piece['fill']; + imagefilledrectangle($frame, $piece['left'], $piece['top'], $piece['left'] + $piece['width'] - 1, $piece['top'] + $piece['height'] - 1, + (int) imagecolorallocatealpha($frame, $red, $green, $blue, 127 - intdiv($alpha * 127, 255))); + continue; + } + imagecopy($frame, $this->sheets[$piece['sheet']], $piece['left'], $piece['top'], $piece['x'], $piece['y'], + $piece['width'], $piece['height']); + } + + return $this->composed[$tile][$index] = $frame; + } + + /** @return array{int, int, int, int} The first and last columns and rows a pixel rectangle reaches, widened by cells. */ + private function findCellRange(int $left, int $top, int $width, int $height, int $margin): array + { + return [intdiv($left, $this->cell) - $margin, intdiv($left + $width - 1, $this->cell) + $margin, + intdiv($top, $this->cell) - $margin, intdiv($top + $height - 1, $this->cell) + $margin]; + } + + /** An asset-root image, read once, or null and a diagnostic when it cannot be read. */ + private function loadImage(string $path, string $role): ?GdImage + { + if (array_key_exists($path, $this->images)) { + return $this->images[$path]; + } + $root = realpath($this->assetRoot); + $file = $root === false ? false : realpath($root . DIRECTORY_SEPARATOR . $path); + $bytes = $file !== false && str_starts_with($file, $root . DIRECTORY_SEPARATOR) ? @file_get_contents($file) : false; + $image = $bytes === false ? false : @imagecreatefromstring($bytes); + if ($image === false) { + $this->diagnose(sprintf('The %s %s cannot be read from the project\'s assets; what it draws is left out.', $role, $path)); + + return $this->images[$path] = null; + } + if (! imageistruecolor($image)) { + imagepalettetotruecolor($image); + } + imagesavealpha($image, true); + + return $this->images[$path] = $image; + } + + private function diagnose(string $message): void + { + if (! in_array($message, $this->diagnostics, true)) { + $this->diagnostics[] = $message; + } + } + + /** A true-colour image, filled with the field's background or left transparent. */ + private static function createCanvas(int $width, int $height, bool $transparent = false): GdImage + { + $image = imagecreatetruecolor($width, $height); + imagealphablending($image, false); + imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $transparent + ? (int) imagecolorallocatealpha($image, 0, 0, 0, 127) : self::allocate($image, TerminalPalette::DEFAULT_BACKGROUND)); + imagealphablending($image, true); + imagesavealpha($image, true); + + return $image; + } + + private static function allocate(GdImage $image, int $rgb): int + { + return (int) imagecolorallocate($image, ($rgb >> 16) & 255, ($rgb >> 8) & 255, $rgb & 255); + } +} diff --git a/src/Review/MapRenderSubject.php b/src/Review/MapRenderSubject.php new file mode 100644 index 00000000..43a9808c --- /dev/null +++ b/src/Review/MapRenderSubject.php @@ -0,0 +1,46 @@ +value, trim($name)) === 0) { + return $heading; + } + } + + throw new InvalidArgumentException(sprintf('"%s" is no heading: use north, east, south or west.', $name)); + } +} diff --git a/src/Review/MapRenderView.php b/src/Review/MapRenderView.php new file mode 100644 index 00000000..75c2fd9d --- /dev/null +++ b/src/Review/MapRenderView.php @@ -0,0 +1,96 @@ + self::MAX_EXTENT || $region['height'] > self::MAX_EXTENT) { + throw new InvalidArgumentException('A region starts at a cell of the map and spans at least one cell each way.'); + } + } + $this->label = $label !== null && trim($label) !== '' ? trim($label) : self::describe($mapId, $region); + } + + /** + * Reads a view written `MAP` or `MAP@X,Y,WIDTH,HEIGHT`. + * + * @throws InvalidArgumentException When it is neither. + */ + public static function readSpecification(string $specification, ?MapRenderSubject $subject = null): self + { + [$mapId, $region] = array_pad(explode('@', trim($specification), 2), 2, null); + + return new self((string) $mapId, $region === null ? null : self::readRegion($region), $subject); + } + + /** + * Reads a region written `X,Y,WIDTH,HEIGHT` in whole cells. + * + * @return array{x: int, y: int, width: int, height: int} + * @throws InvalidArgumentException When it is not four whole numbers. + */ + public static function readRegion(string $region): array + { + $parts = array_map(trim(...), explode(',', $region)); + if (count($parts) !== 4 || array_any($parts, static fn(string $part): bool => preg_match('/\A\d+\z/', $part) !== 1)) { + throw new InvalidArgumentException(sprintf('"%s" is no region: write X,Y,WIDTH,HEIGHT in whole cells.', $region)); + } + + return ['x' => (int) $parts[0], 'y' => (int) $parts[1], 'width' => (int) $parts[2], 'height' => (int) $parts[3]]; + } + + /** + * A file-name stem for the view's pictures, unique among views of + * different maps and regions: the map id and region, in safe characters. + */ + public function createFileStem(): string + { + $stem = str_replace('/', '--', $this->mapId); + if ($this->region !== null) { + $stem .= sprintf('@%d,%d,%d,%d', $this->region['x'], $this->region['y'], $this->region['width'], $this->region['height']); + } + + return preg_replace('/[^A-Za-z0-9@,._-]/', '_', $stem) ?? $stem; + } + + /** @param array{x: int, y: int, width: int, height: int}|null $region */ + private static function describe(string $mapId, ?array $region): string + { + return $region === null ? $mapId + : sprintf('%s, x %d, y %d, %d by %d', $mapId, $region['x'], $region['y'], $region['width'], $region['height']); + } +} diff --git a/src/Review/MapRenderer.php b/src/Review/MapRenderer.php new file mode 100644 index 00000000..9b3cf98e --- /dev/null +++ b/src/Review/MapRenderer.php @@ -0,0 +1,185 @@ +workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === $view->mapId) + ?? throw new InvalidArgumentException(sprintf('There is no map %s.', $view->mapId)); + if (($issue = $map->getGridSourceIssue()) !== null) { + throw new RuntimeException(sprintf('Map %s cannot be read: %s', $map->mapId, $issue)); + } + $region = $this->resolveRegion($view, $map); + $subject = $view->subject; + if ($subject !== null && ($subject->x >= $map->getWidth() || $subject->y >= $map->getHeight())) { + throw new InvalidArgumentException(sprintf('x %d, y %d is off map %s, which is %d by %d.', + $subject->x, $subject->y, $map->mapId, $map->getWidth(), $map->getHeight())); + } + $diagnostics = []; + $appearance = $subject === null ? null : $this->resolveAppearance($subject, $diagnostics); + $field = PreviewField::openProjectMap($this->workspace->projectRoot, $map, + $subject === null ? null : new Vector2($subject->x, $subject->y), + min($region['width'], self::BLOCK_COLUMNS), min($region['height'], self::BLOCK_ROWS), static fn(): float => 0.0, + $subject->heading ?? MovementHeading::NONE, $appearance); + try { + if ($field->mapFailure !== null) { + throw new RuntimeException(sprintf('Map %s cannot be loaded: %s', $map->mapId, $field->mapFailure)); + } + + return $this->capture($field, $view, $region, $diagnostics); + } finally { + $field->dispose(); + } + } + + /** + * Takes the field a block at a time across the region: each block's + * Terminal rows and graphical composition, the sprites placed on map cells. + * + * @param array{x: int, y: int, width: int, height: int} $region + * @param list $diagnostics + */ + private function capture(PreviewField $field, MapRenderView $view, array $region, array $diagnostics): MapRendering + { + [$columns, $rows] = $field->getScreenSize(); + $cells = array_fill(0, $region['height'], array_fill(0, $region['width'], ' ')); + $world = null; + $tileFrame = 0; + /** @var array $sprites */ + $sprites = []; + for ($top = $region['y']; $top < $region['y'] + $region['height']; $top += $rows) { + for ($left = $region['x']; $left < $region['x'] + $region['width']; $left += $columns) { + $field->holdCameraAt($left, $top); + $origin = $field->getCameraOrigin(); + $screen = TerminalPicture::readRows($field->frame()); + for ($y = $top; $y < min($top + $rows, $region['y'] + $region['height']); $y++) { + for ($x = $left; $x < min($left + $columns, $region['x'] + $region['width']); $x++) { + $cells[$y - $region['y']][$x - $region['x']] = $screen[$y - $origin['y']][$x - $origin['x']] ?? ' '; + } + } + $composition = $field->composeStill(FieldStillRaster::supports(...)); + $world ??= $composition->world; + $tileFrame = $composition->viewport?->tileFrame ?? $tileFrame; + // The composer places sprites on screen cells; the camera's origin puts them back on the map's. + foreach ($composition->sprites as $sprite) { + $sprites[$sprite->id] ??= self::placeOnMap($sprite, $origin['x'], $origin['y']); + } + } + } + if (! $world instanceof PresentationWorld) { + throw new RuntimeException(sprintf('Map %s has no graphical field to draw: it exceeds the retained world\'s limits.', $view->mapId)); + } + foreach ($cells as &$row) { + // A wide symbol cut by the region's right edge keeps its column; its other half is outside. + if (end($row) === NormalizedRow::CONTINUATION) { + $row[array_key_last($row)] = ' '; + } + } + unset($row); + + return new MapRendering($view, $region, new TerminalPicture($region['width'], $region['height'], $cells), + new FieldStill($world, PresentationSprite::orderedList(array_values($sprites)), $region, $tileFrame), + [...$diagnostics, ...$field->getDiagnostics()]); + } + + /** @return array{x: int, y: int, width: int, height: int} */ + private function resolveRegion(MapRenderView $view, ProjectMap $map): array + { + $region = $view->region ?? ['x' => 0, 'y' => 0, 'width' => $map->getWidth(), 'height' => $map->getHeight()]; + if ($region['x'] + $region['width'] > $map->getWidth() || $region['y'] + $region['height'] > $map->getHeight()) { + throw new InvalidArgumentException(sprintf('The region x %d, y %d, %d by %d runs off map %s, which is %d by %d.', + $region['x'], $region['y'], $region['width'], $region['height'], $map->mapId, $map->getWidth(), $map->getHeight())); + } + + return $region; + } + + /** + * The graphical art the subject shows, as a new game gives the player + * theirs: the project's fixed player art, or the first member of the + * starting party when the leader's art shows the player; a named actor + * shows their own. + * + * @param list $diagnostics + */ + private function resolveAppearance(MapRenderSubject $subject, array &$diagnostics): ?CharacterSheet + { + return ProjectDirectoryContext::run($this->workspace->projectRoot, function () use ($subject, &$diagnostics): ?CharacterSheet { + $actorId = $subject->actorId; + if ($actorId === null) { + $presentation = PlayerPresentationConfig::load(); + if ($presentation->graphicalSubject !== PlayerGraphicalSubject::PARTY_LEADER) { + return $presentation->graphical; + } + $party = (array) ($this->workspace->getSystemField('startingParty') ?? []); + $actorId = is_string($party[0] ?? null) ? $party[0] : null; + if ($actorId === null) { + $diagnostics[] = 'The player shows the party leader\'s art, and the starting party is empty, so the graphical still shows no one there.'; + + return null; + } + } + $store = $this->workspace->actorDatabase->createActorStore(); + $actor = $subject->actorId === null ? $store->requireStartingPartyActor($actorId) : $store->get($actorId) + ?? throw new InvalidArgumentException(sprintf('There is no actor %s; name one by its id.', $actorId)); + $sheet = $actor->getGraphicalCharacterSheet(); + if ($sheet === null) { + $diagnostics[] = sprintf('Actor %s has no graphical field art, so the graphical still shows no one there.', $actor->id); + } + + return $sheet; + }); + } + + private static function placeOnMap(PresentationSprite $sprite, int $originX, int $originY): PresentationSprite + { + return new PresentationSprite($sprite->id, $sprite->asset, $sprite->x + $originX, $sprite->y + $originY, $sprite->width, + $sprite->height, $sprite->anchor, $sprite->layer, $sprite->sourceRect, lift: $sprite->lift, + quarterTurns: $sprite->quarterTurns, pivot: $sprite->pivot); + } +} diff --git a/src/Review/MapRendering.php b/src/Review/MapRendering.php new file mode 100644 index 00000000..df305509 --- /dev/null +++ b/src/Review/MapRendering.php @@ -0,0 +1,26 @@ + $diagnostics + */ + public function __construct( + public MapRenderView $view, + public array $region, + public TerminalPicture $terminal, + public FieldStill $still, + public array $diagnostics = [], + ) { + } +} diff --git a/src/Review/MapReviewWriter.php b/src/Review/MapReviewWriter.php new file mode 100644 index 00000000..8ee49d5f --- /dev/null +++ b/src/Review/MapReviewWriter.php @@ -0,0 +1,154 @@ +, diagnostics: list}> */ + private array $written = []; + + /** + * @param float|null $scale The scale of the extra overview still, in (0, 1); null writes the native still alone. + */ + public function __construct( + private readonly string $directory, + private readonly FieldStillRaster $raster, + private readonly ?float $scale = null, + private readonly bool $nativeStill = true, + ) { + if ($scale !== null && (! is_finite($scale) || $scale <= 0 || $scale >= 1)) { + throw new InvalidArgumentException('An overview scale is above 0 and below 1, such as 0.5 or 0.25.'); + } + if (! is_dir($directory) && ! @mkdir($directory, 0777, true) && ! is_dir($directory)) { + throw new RuntimeException(sprintf('%s cannot be made.', $directory)); + } + } + + /** + * Writes one view's pictures. + * + * @return array{files: array, diagnostics: list} Each file written, by kind (terminal-ans, + * terminal-html, png and scaled-png), and what the view could not show. + */ + public function writeRendering(MapRendering $rendering): array + { + $stem = $this->claimStem($rendering->view->createFileStem()); + $files = [ + 'terminal-ans' => $stem . '.terminal.ans', + 'terminal-html' => $stem . '.terminal.html', + ]; + $this->writeFile($files['terminal-ans'], $rendering->terminal->renderAnsi()); + $this->writeFile($files['terminal-html'], $rendering->terminal->renderPage($rendering->view->label)); + $before = count($this->raster->diagnostics); + if ($this->nativeStill) { + $files['png'] = $stem . '.png'; + $this->writePng($files['png'], $this->raster->rasterize($rendering->still)); + } + if ($this->scale !== null) { + $files['scaled-png'] = sprintf('%s@%sx.png', $stem, rtrim(rtrim(sprintf('%.3F', $this->scale), '0'), '.')); + $this->writePng($files['scaled-png'], $this->raster->rasterize($rendering->still, $this->scale)); + } + $diagnostics = [...$rendering->diagnostics, ...array_slice($this->raster->diagnostics, $before)]; + $this->written[] = ['rendering' => $rendering, 'files' => $files, 'diagnostics' => $diagnostics]; + + return ['files' => array_map(fn(string $file): string => $this->directory . DIRECTORY_SEPARATOR . $file, $files), + 'diagnostics' => $diagnostics]; + } + + /** + * Writes the review page: every view written so far, its Terminal picture + * beside its graphical still, the still shown at the overview scale when + * there is one and linked to the native one. + * + * @return string The page's path. + */ + public function writeReviewPage(string $title): string + { + $escape = static fn(string $text): string => htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $sections = ''; + foreach ($this->written as ['rendering' => $rendering, 'files' => $files, 'diagnostics' => $diagnostics]) { + $region = $rendering->region; + $subject = $rendering->view->subject; + $facts = sprintf('Cells x %d to %d, y %d to %d.', $region['x'], $region['x'] + $region['width'] - 1, + $region['y'], $region['y'] + $region['height'] - 1); + if ($subject !== null) { + $facts .= sprintf(' %s at x %d, y %d facing %s.', $subject->actorId === null ? 'The player' : 'Actor ' . $subject->actorId, + $subject->x, $subject->y, strtolower($subject->heading->value)); + } + $shown = $files['scaled-png'] ?? $files['png'] ?? null; + $still = $shown === null ? '

No graphical still was written.

' + : sprintf('%s, graphical', $escape($files['png'] ?? $shown), + $this->createImageSource($shown), $escape($rendering->view->label)); + $notes = $diagnostics === [] ? '' : '
    ' . implode('', array_map( + static fn(string $note): string => '
  • ' . $escape($note) . '
  • ', $diagnostics)) . '
'; + $sections .= sprintf("
\n

%s

\n

%s Terminal page · ANSI

\n%s" + . "
\n
Terminal
%s
\n
Graphical
%s
\n
\n
\n", + $escape($rendering->view->label), $escape($facts), $escape($files['terminal-html']), $escape($files['terminal-ans']), $notes, + $rendering->terminal->renderHtml(), $still); + } + $page = "\n\n\n\n" + . "\n{$escape($title)}\n\n\n\n

{$escape($title)}

\n{$sections}\n\n"; + $this->writeFile(self::REVIEW_PAGE, $page); + + return $this->directory . DIRECTORY_SEPARATOR . self::REVIEW_PAGE; + } + + /** The still the review page shows, inside the page itself as a PNG data URI. */ + private function createImageSource(string $name): string + { + $path = $this->directory . DIRECTORY_SEPARATOR . $name; + $bytes = file_get_contents($path); + if ($bytes === false) { + throw new RuntimeException(sprintf('%s cannot be read back for the review page.', $path)); + } + + return 'data:image/png;base64,' . base64_encode($bytes); + } + + /** A view's stem, numbered when an earlier view of this writer took it. */ + private function claimStem(string $stem): string + { + $taken = array_map(static fn(array $entry): string => basename($entry['files']['terminal-ans'], '.terminal.ans'), $this->written); + $claimed = $stem; + for ($number = 2; in_array($claimed, $taken, true); $number++) { + $claimed = $stem . '-' . $number; + } + + return $claimed; + } + + private function writeFile(string $name, string $contents): void + { + if (file_put_contents($this->directory . DIRECTORY_SEPARATOR . $name, $contents) === false) { + throw new RuntimeException(sprintf('%s cannot be written.', $this->directory . DIRECTORY_SEPARATOR . $name)); + } + } + + private function writePng(string $name, \GdImage $image): void + { + if (! imagepng($image, $this->directory . DIRECTORY_SEPARATOR . $name)) { + throw new RuntimeException(sprintf('%s cannot be written.', $this->directory . DIRECTORY_SEPARATOR . $name)); + } + } +} diff --git a/src/Review/TerminalPalette.php b/src/Review/TerminalPalette.php new file mode 100644 index 00000000..1d9897b1 --- /dev/null +++ b/src/Review/TerminalPalette.php @@ -0,0 +1,69 @@ +toArray(), $default); + } + + /** + * A wire colour descriptor ({@see PresentationColor::toArray()}) as 0xRRGGBB. + * + * @param array|null $descriptor + */ + public static function resolveDescriptor(?array $descriptor, int $default): int + { + if ($descriptor === null) { + return $default; + } + $index = (int) ($descriptor['index'] ?? 0); + + return match ($descriptor['kind'] ?? null) { + 'rgb' => ((int) $descriptor['r'] << 16) | ((int) $descriptor['g'] << 8) | (int) $descriptor['b'], + 'ansi16' => self::ANSI16[$index] ?? $default, + 'ansi256' => match (true) { + $index < 16 => self::ANSI16[$index], + $index < 232 => self::resolveCube($index - 16), + default => (8 + ($index - 232) * 10) * 0x010101, + }, + default => $default, + }; + } + + /** A colour as a CSS hex value. */ + public static function formatCss(int $rgb): string + { + return sprintf('#%06x', $rgb & 0xffffff); + } + + private static function resolveCube(int $index): int + { + $levels = [0, 95, 135, 175, 215, 255]; + + return ($levels[intdiv($index, 36)] << 16) | ($levels[intdiv($index, 6) % 6] << 8) | $levels[$index % 6]; + } +} diff --git a/src/Review/TerminalPicture.php b/src/Review/TerminalPicture.php new file mode 100644 index 00000000..09db7467 --- /dev/null +++ b/src/Review/TerminalPicture.php @@ -0,0 +1,148 @@ +> $cells Each row's styled symbols, `width` of them. + */ + public function __construct(public int $width, public int $height, public array $cells) + { + if ($width < 1 || $height < 1 || count($cells) !== $height + || array_any($cells, static fn(array $row): bool => count($row) !== $width)) { + throw new InvalidArgumentException('A Terminal picture holds height rows of width cells.'); + } + } + + /** + * Splits styled Terminal rows, as a Console capture composes them, into + * their columns: each symbol keeps the style it is drawn in, and a wide + * symbol takes two columns. + * + * @param list $rows + * @return list> + */ + public static function readRows(array $rows): array + { + $cells = []; + foreach ($rows as $row) { + $columns = []; + foreach (TerminalText::visibleSymbols($row) as $symbol) { + $columns[] = $symbol; + for ($extra = TerminalText::getSymbolWidth($symbol); $extra > 1; $extra--) { + $columns[] = NormalizedRow::CONTINUATION; + } + } + $cells[] = $columns; + } + + return $cells; + } + + /** The picture as ANSI text: each row's styled symbols, the style reset at its end. */ + public function renderAnsi(): string + { + $text = ''; + foreach ($this->cells as $row) { + $text .= implode('', array_filter($row, static fn(string $cell): bool => $cell !== NormalizedRow::CONTINUATION)) . "\e[0m\n"; + } + + return $text; + } + + /** The picture's characters alone, one line per row. */ + public function renderPlainText(): string + { + $text = ''; + foreach ($this->cells as $row) { + foreach ($row as $cell) { + $text .= $cell === NormalizedRow::CONTINUATION ? '' : TerminalText::stripAnsi($cell); + } + $text .= "\n"; + } + + return $text; + } + + /** + * The picture as an HTML `
` of coloured runs in the review palette:
+     * cells of one foreground and background share a span, and a cell that
+     * names no colour shows the palette's own.
+     */
+    public function renderHtml(): string
+    {
+        $html = '
';
+        foreach ($this->cells as $y => $row) {
+            $run = '';
+            $style = null;
+            foreach ($row as $cell) {
+                if ($cell === NormalizedRow::CONTINUATION) {
+                    continue;
+                }
+                $colors = SgrColorParser::parse($cell);
+                $next = sprintf('color:%s;background:%s',
+                    TerminalPalette::formatCss(TerminalPalette::resolveRgb($colors['foreground'], TerminalPalette::DEFAULT_FOREGROUND)),
+                    TerminalPalette::formatCss(TerminalPalette::resolveRgb($colors['background'], TerminalPalette::DEFAULT_BACKGROUND)));
+                if ($style !== null && $next !== $style) {
+                    $html .= self::renderSpan($style, $run);
+                    $run = '';
+                }
+                $style = $next;
+                $run .= TerminalText::stripAnsi($cell);
+            }
+            if ($style !== null) {
+                $html .= self::renderSpan($style, $run);
+            }
+            $html .= $y < $this->height - 1 ? "\n" : '';
+        }
+
+        return $html . '
'; + } + + /** + * The picture as a page of its own: dark, monospaced, every colour kept, + * needing nothing beside it. + */ + public function renderPage(string $title): string + { + $escaped = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return "\n\n\n\n" + . "\n{$escaped}\n" + . '\n\n\n

{$escaped}

\n" + . $this->renderHtml() . "\n\n\n"; + } + + /** The page styles a Terminal picture is shown with, shared by the review page. */ + public static function renderStyles(): string + { + $background = TerminalPalette::formatCss(TerminalPalette::DEFAULT_BACKGROUND); + $foreground = TerminalPalette::formatCss(TerminalPalette::DEFAULT_FOREGROUND); + + return "body{margin:0;padding:16px;background:{$background};color:{$foreground};" + . 'font:14px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif}' + . 'h1{font-size:16px;font-weight:600;margin:0 0 12px}' + . "pre.terminal{margin:0;display:inline-block;padding:8px;background:{$background};" + . 'font:14px/1.15 Menlo,"SF Mono",Consolas,"DejaVu Sans Mono",monospace;white-space:pre;overflow:auto;max-width:100%}'; + } + + private static function renderSpan(string $style, string $text): string + { + return '' . htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + } +} diff --git a/src/Session/ChangeBatch.php b/src/Session/ChangeBatch.php new file mode 100644 index 00000000..7209895e --- /dev/null +++ b/src/Session/ChangeBatch.php @@ -0,0 +1,197 @@ +> + * @throws InvalidRequest When the file is not a batch. + */ + public static function readFile(string $path): array + { + $json = is_file($path) ? file_get_contents($path) : false; + if ($json === false) { + throw new InvalidRequest(sprintf('%s cannot be read.', $path)); + } + try { + $batch = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new InvalidRequest(sprintf('%s is not JSON: %s', $path, $error->getMessage()), previous: $error); + } + $changes = is_array($batch) && array_key_exists('changes', $batch) ? $batch['changes'] : $batch; + if (! is_array($changes) || ! array_is_list($changes)) { + throw new InvalidRequest(sprintf('%s must hold a list of changes, or {"changes": [...]}.', $path)); + } + + return $changes; + } + + /** + * Applies changes to a project and commits them when the result is valid. + * + * @param list $changes The session requests, in order. + * @param bool $relocateSaves Whether saves standing where a changed map is now solid may move to open floor. + * @param bool $checkOnly Whether to only report what committing would find, writing nothing. + * @return array The commit's outcome ({@see EditorSession::commitChanges()}), or + * `{status: 'failed', step, method, message}` when a change was refused and nothing was written. + */ + public static function apply(string $projectRoot, array $changes, bool $relocateSaves = false, bool $checkOnly = false): array + { + return self::run($projectRoot, $changes, + static fn(self $batch): array => $batch->request('changes.commit', ['relocateSaves' => $relocateSaves, 'check' => $checkOnly])); + } + + /** + * Applies changes to a project as {@see apply()} does, shows the session + * they leave, unsaved, to `$inspect`, and then undoes them: nothing is + * written. A script sees what a batch would make before it applies it. + * + * @param list $changes The session requests, in order. + * @param Closure(EditorSession): mixed $inspect + * @return array `{status: 'previewed', result}` with what `$inspect` answered, or + * `{status: 'failed', step, method, message}` when a change was refused. + */ + public static function preview(string $projectRoot, array $changes, Closure $inspect): array + { + return self::run($projectRoot, $changes, static function (self $batch) use ($inspect): array { + try { + return ['status' => 'previewed', 'result' => $inspect($batch->host->session + ?? throw new SessionRefusal('The batch has no session open.'))]; + } finally { + $batch->request('changes.cancel'); + } + }); + } + + /** + * Opens the project, applies the changes in a change set, and finishes it + * with `$finish`, or cancels it at the first refused change. + * + * @param list $changes + * @param Closure(self): array $finish + * @return array + */ + private static function run(string $projectRoot, array $changes, Closure $finish): array + { + $memory = fopen('php://memory', 'r+'); + $batch = new self(new SessionHost($memory, $memory, STDERR)); + + try { + $batch->request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $projectRoot]); + $batch->request('changes.begin'); + foreach ($changes as $index => $change) { + $refusal = $batch->applyChange($change); + if ($refusal !== null) { + $batch->request('changes.cancel'); + + return ['status' => 'failed', 'step' => $index + 1, ...$refusal]; + } + } + + return $finish($batch); + } catch (SessionRefusal $refusal) { + return ['status' => 'failed', 'step' => null, 'method' => null, 'message' => $refusal->getMessage()]; + } finally { + $batch->host->close(); + } + } + + /** + * Applies one change. + * + * @return array{method: ?string, message: string}|null Why it was refused; null once applied. + */ + private function applyChange(mixed $change): ?array + { + $method = is_array($change) && is_string($change['method'] ?? null) ? $change['method'] : null; + $params = is_array($change) ? ($change['params'] ?? []) : null; + if ($method === null || ! is_array($params)) { + return ['method' => $method, 'message' => 'A change is {"method": "...", "params": {...}}.']; + } + if (in_array($method, self::RESERVED, true)) { + return ['method' => $method, 'message' => sprintf('%s is made by the batch itself, never as one of its changes.', $method)]; + } + if (is_string($params['map'] ?? null) && ! array_key_exists('revision', $params)) { + $map = array_find($this->request('maps.list'), static fn(array $map): bool => $map['id'] === $params['map']); + if ($map !== null) { + $params['revision'] = $map['revision']; + } + } + + try { + $result = $this->request($method, $params); + if (($result['status'] ?? null) === 'question' && is_string($result['glyph'] ?? null)) { + // A glyph several pieces draw is answered in the change's own params, as an editor answers it. + return ['method' => $method, 'message' => sprintf('Glyph %s could be several pieces; give the change "choices": {"%s": key}, one of %s.', + $result['glyph'], $result['glyph'], implode(', ', array_map(static fn(array $role): string => "{$role['key']} ({$role['label']})", + (array) ($result['roles'] ?? []))))]; + } + if (($result['status'] ?? null) === 'question') { + $answer = is_array($change) ? ($change['answer'] ?? null) : null; + if (! is_string($answer)) { + return ['method' => $method, 'message' => sprintf('It asks "%s"; give the change an "answer".', + is_string($result['question'] ?? null) ? $result['question'] : 'a question')]; + } + $result = $this->request($method, [...$params, 'answer' => $answer, + ...(is_string($result['confirm'] ?? null) ? ['confirm' => $result['confirm']] : [])]); + if (($result['status'] ?? null) === 'question') { + return ['method' => $method, 'message' => sprintf('"%s" did not answer its question.', $answer)]; + } + } + } catch (SessionRefusal $refusal) { + return ['method' => $method, 'message' => $refusal->getMessage()]; + } + + return null; + } + + /** + * Makes one request of the host, as a client would. + * + * @param array $params + * @return array + * @throws SessionRefusal When the host refuses it or cannot make it. + */ + private function request(string $method, array $params = []): array + { + $response = $this->host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => (object) $params], JSON_THROW_ON_ERROR)); + if (isset($response['error'])) { + throw new SessionRefusal(sprintf('%s: %s', $response['error']['kind'], $response['error']['message'])); + } + + return $response['result']; + } +} diff --git a/src/Session/EditorSession.php b/src/Session/EditorSession.php new file mode 100644 index 00000000..b88c94ea --- /dev/null +++ b/src/Session/EditorSession.php @@ -0,0 +1,7339 @@ + Each open map's editing canvas, by map id. */ + private array $authoringCanvases = []; + + /** The playtest running in the background, or the last one, to report how it ended. */ + private ?PlaytestRun $playtest = null; + /** Where that playtest began. */ + private PlaytestStart $playtestStart = PlaytestStart::CELL; + /** The battle test running in its own window, and the troop it fights. */ + private ?PlaytestRun $battleTestRun = null; + + /** The block an author last copied or cut, for pasting on its layer. */ + private ?Clipboard $clipboard = null; + + /** The music or sound an author is hearing from a field, once one has been played. */ + private ?AudioAudition $audition = null; + private ?string $battleTestTroop = null; + /** + * The summon battle previews last built, by presentation, and what each was built from, so seeking reuses them until either changes. + * + * @var array + */ + private array $summonBattlePreviews = []; + /** + * The effect battle previews last built, by presentation, and what each was built from. + * + * @var array + */ + private array $effectBattlePreviews = []; + /** Projects terminal pictures into renderer runs, keeping its parsed styles between frames. */ + private ?TerminalPresentationComposer $terminalComposer = null; + /** The cinematic previewed as the terminal plays it, and which one it is. */ + private ?CinematicPreviewSession $cinematicPreview = null; + private ?string $cinematicPreviewId = null; + /** The cinematic as it stood when its preview started, so an unchanged one keeps playing. */ + private ?string $cinematicPreviewFingerprint = null; + /** + * The field an effect is previewed on, where it stands and at what size, + * and the effect's playhead in seconds. It and the cinematic preview are + * never open together: each installs the preview's Engine configuration. + */ + private ?PreviewField $effectField = null; + private ?string $effectFieldKey = null; + private float $effectFieldSeconds = 0.0; + + /** How actors are authored, with what this editor's actor panes show. */ + private readonly ActorAuthoring $actorAuthoring; + + /** How a troop's members are composed, as the terminal editor composes them. */ + private readonly TroopAuthoring $troopAuthoring; + private readonly MasteryAuthoring $masteryAuthoring; + + /** + * Where each map's revisions count from, with the last revision given + * and what the map held then. A map read afresh, when a file set written + * at once reloads the project or its undo puts the earlier one back, + * keeps that revision while it holds the same content, so a file set + * that never wrote a map leaves it unchanged; one that holds anything + * else counts on from it, so one revision never names two states of a + * map. + * + * @var array, base: int, last: int, content: string}> + */ + private array $mapRevisions = []; + + /** + * The open change set ({@see beginChanges()}): what validation reported + * when it began, each map's revision then, and how many undo steps came + * before it. Null when none is open. + * + * Requests that write the save manifest at once note what they wrote: + * `migrationRegistered`, and each map retirement by its step and map. + * + * @var array{baseline: list, revisions: array, steps: int, dropped: int, migrationRegistered?: bool, + * retirements?: list}|null + */ + private ?array $changeSet = null; + + private function __construct( + ProjectWorkspace $workspace, + private readonly CommandHistory $history, + private readonly BackupWriter $backups, + ) { + $this->workspace = $workspace; + $this->actorAuthoring = new ActorAuthoring(graphical: true); + $this->troopAuthoring = new TroopAuthoring(); + $this->masteryAuthoring = new MasteryAuthoring(); + } + + /** Opens the project at a root, as the terminal editor opens it. */ + public static function open(string $projectRoot): self + { + $workspace = ProjectWorkspace::fromProject($projectRoot, graphical: true); + + return new self($workspace, new CommandHistory(), + new BackupWriter(BackupSettings::fromProject($workspace->projectRoot), $workspace->projectRoot)); + } + + /** + * The project's name, its maps and its database categories. + * + * @return array{name: string, root: string, maps: list>, databases: list} + */ + public function describeProject(): array + { + return [ + 'name' => $this->workspace->projectName, + 'root' => $this->workspace->projectRoot, + 'maps' => $this->describeMaps(), + 'databases' => array_map(static fn($category): array => [ + 'key' => $category->key, + 'label' => $category->label, + 'description' => $category->description, + 'implemented' => $category->isImplemented, + 'group' => 'Database', + ], DatabaseCatalog::getGraphicalCategories()), + // Cutscene types, edited through the same record RPCs under their record category keys. + 'cutscenes' => array_map(static fn(CutsceneType $type): array => [ + 'key' => $type->getRecordCategory(), + 'label' => $type->label(), + 'description' => $type->describeCategory(), + 'implemented' => true, + 'group' => 'Cutscenes', + ], CutsceneType::cases()), + ]; + } + + /** + * Finds what the project names a text by: maps by id or name, events by + * marker, type or any value they hold (a chest's loot, a destination, a + * script), and NPCs by id or name, unsaved edits included. Case is + * ignored; a query of one character is an event marker, matched exactly, + * rather than every text that contains it. + * + * @return list + */ + public function searchProject(string $query, int $limit = 100): array + { + $query = trim($query); + if ($query === '') { + return []; + } + $contains = static fn(string $text): bool => mb_stripos($text, $query) !== false; + $marker = mb_strlen($query) === 1; + $results = []; + + foreach ($this->getMapsById() as $mapId => $map) { + $mapId = (string) $mapId; + $name = $map->getDisplayName(); + if (! $marker && ($contains($mapId) || $contains($name))) { + $results[] = ['kind' => 'map', 'map' => $mapId, 'label' => $name, 'detail' => $mapId]; + } + + foreach (array_unique([...$map->getEventMarkers(), ...$map->getPlacedEventMarkers()]) as $eventMarker) { + $definition = $map->getEventDefinition($eventMarker); + $type = EventTypeCatalog::describeClass(is_string($definition['class'] ?? null) ? $definition['class'] : null); + $values = []; + // What the event holds, not the class that plays it. + $data = is_array($definition['data'] ?? null) ? $definition['data'] : []; + array_walk_recursive($data, static function (mixed $value) use (&$values): void { + if (is_scalar($value)) { + $values[] = (string) $value; + } + }); + $found = $marker + ? $eventMarker === $query + : $contains($type) || array_find($values, $contains) !== null; + if (! $found) { + continue; + } + $cell = $map->getEventArea($eventMarker)?->cells[0] ?? null; + $results[] = ['kind' => 'event', 'map' => $mapId, 'label' => sprintf('%s %s', $eventMarker, $type), + 'detail' => sprintf('%s · %s', $name, $marker ? 'marker' : (array_find($values, $contains) ?? $type)), + 'marker' => $eventMarker, ...($cell === null ? [] : ['x' => (int) $cell[0], 'y' => (int) $cell[1]])]; + } + + foreach ($map->getNpcs()->all() as $index => $npc) { + if (! $marker && $contains($npc->getId()) || $contains($npc->getName())) { + $results[] = ['kind' => 'npc', 'map' => $mapId, 'label' => $npc->getName(), 'detail' => sprintf('%s · %s', $name, $npc->getId()), + 'index' => (int) $index, 'x' => $npc->getX(), 'y' => $npc->getY()]; + } + } + + if (count($results) >= $limit) { + return array_slice($results, 0, $limit); + } + } + + return $results; + } + + /** + * Every map, by stable id, with what a list shows about it. + * + * @return list + */ + public function describeMaps(): array + { + return array_map(fn(ProjectMap $map): array => [ + 'id' => $map->mapId, + 'name' => $map->getDisplayName(), + // Maps are listed under their region, so two maps with one name (an inn in each town) stay apart. + 'region' => $map->getAuthoredRegion(), + 'dirty' => $map->isDirty(), + 'readOnly' => $map->getGridSourceIssue(), + 'revision' => $this->getMapRevision($map), + ], $this->workspace->maps); + } + + /** + * The kinds a new map can have: the project's tilesets, which its tiles + * and pieces come from. + * + * @return list + */ + public function listMapKinds(): array + { + $kinds = new ReferenceCatalog($this->workspace)->labelsFor('tilesets'); + + return array_map(static fn(string $id, string $name): array => ['value' => $id, 'label' => $name], array_keys($kinds), $kinds); + } + + /** + * Creates a blank map, written to disk at once as the terminal editor + * creates one. Without `$mapId` it goes at the maps root, named after + * `$name` (`new-map` when empty) with a numbered suffix when that is + * taken. With one it goes at exactly that folder path, which may nest + * inside another map's folder ({@see MapFolder}), named `$name` or after + * the id's last segment. Every open map keeps its unsaved changes and the + * undo history stays. + * + * @param string|null $mapId The new map's id; null derives one from the name. + * @return array{map: string, maps: list>} + * @throws SessionRefusal When the kind is unknown, the size is not a size, the id is not a free map folder path, or the files cannot be written. + */ + public function createMap(?string $name, ?string $kind, int $width, int $height, ?string $mapId = null): array + { + if ($kind !== null && ! in_array($kind, array_column($this->listMapKinds(), 'value'), true)) { + throw new SessionRefusal(sprintf('There is no map kind %s.', $kind)); + } + $baseName = $name === null || trim($name) === '' ? null : ProjectMap::slugify($name, ''); + if ($mapId === null && $baseName === '') { + throw new SessionRefusal(sprintf('"%s" has no letters or digits to name a map with.', $name)); + } + try { + $mapId = $mapId === null + ? $this->workspace->createMap($baseName, kind: $kind, width: $width, height: $height) + : $this->workspace->createMapAt($mapId, $name === null || trim($name) === '' ? null : trim($name), kind: $kind, width: $width, height: $height); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('The map was not created: %s', $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withLoadedMap($mapId); + + return ['map' => $mapId, 'maps' => $this->describeMaps()]; + } + + /** + * Copies the current authored map through the workspace's transactional + * duplicate operation, beside it under the next free `-copy` name or at + * the folder path `$to` names ({@see MapFolder}). Unsaved source edits + * are included in the copy; the original and every other loaded document + * and undo step remain. + * + * @param string|null $to The copy's id; null takes the next free sibling. + * @return array{map: string, maps: list>} + * @throws SessionRefusal When the revision is stale, the id is not a free map folder path, or source cannot be copied safely. + */ + public function duplicateMap(string $mapId, int $revision, ?string $to = null): array + { + $this->requireCurrentMap($mapId, $revision); + try { + $created = $this->workspace->duplicateMap((int) array_search($mapId, $this->workspace->mapIds, true), mapId: $to) + ?? throw new RuntimeException('The map is no longer in the workspace.'); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('%s was not duplicated: %s', $mapId, $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withLoadedMap($created); + + return ['map' => $created, 'maps' => $this->describeMaps()]; + } + + /** + * Explicitly moves a map after the author reviews that exact + * destination: its metadata-derived path, or the folder path `$to` names + * ({@see MapFolder}), which may nest inside another map's folder. The + * first call asks, naming the destination and its references; the + * confirming call repeats `$to` and gives the reviewed `$destination`. + * As in the TUI, references are not migrated and a successful move + * clears undo history naming retired maps. Ordinary saves never call + * this operation. + * + * @param string|null $destination The destination the author reviewed, repeated to confirm. + * @param string|null $to The id to move to; null proposes the metadata-derived path. + * @return array + * @throws SessionRefusal When the revision or reviewed path changed, the id is not a free map folder path, or source cannot move safely. + */ + public function moveMap(string $mapId, int $revision, ?string $destination = null, bool $confirmed = false, ?string $to = null): array + { + $this->refuseDuringChangeSet('Moving a map clears the undo history'); + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $proposed = $to ?? $map->getProposedMapId(); + if ($destination !== null && $destination !== $proposed) { + throw new SessionRefusal($to === null + ? 'The derived destination changed; review the proposed move again.' + : 'The reviewed destination is not the id to move to; review the move again.'); + } + if ($proposed === $mapId) { + return ['status' => 'unchanged', 'map' => $mapId, 'destination' => $proposed, 'maps' => $this->describeMaps()]; + } + if ($to !== null) { + // Refused before the question, so an author never reviews a move that cannot happen. + try { + MapFolder::requireFreeFolder($this->workspace->getMapsRoot(), $to); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal(sprintf('%s was not moved: %s', $mapId, $error->getMessage()), previous: $error); + } + } + if (! $confirmed) { + return [ + 'status' => 'question', 'map' => $mapId, 'revision' => $revision, 'destination' => $proposed, + ...($to === null ? [] : ['to' => $to]), + 'references' => new MapReferences($this->workspace)->describe($mapId), + 'warning' => 'References are not migrated: doors, quests, saves and one-shot events naming the old id keep naming it. Moving clears the undo history. The original map folder is removed only after the new map validates.', + ]; + } + if ($destination === null) { + throw new SessionRefusal('Review the proposed destination before confirming the move.'); + } + try { + $moved = $map->moveTo($proposed); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('%s was not moved: %s', $mapId, $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withReplacedMap((int) array_search($mapId, $this->workspace->mapIds, true), $moved); + $this->history->clear(); + + return ['status' => 'moved', 'previousMap' => $mapId, 'map' => $moved->mapId, 'maps' => $this->describeMaps()]; + } + + /** + * Reviews a project-wide row or column insertion before writing it as + * one reversible source set. The plan owns coordinate rewrites and save + * shifts; both editor interfaces use the same planner and transaction. + * + * @return array + * @throws SessionRefusal When the map or review is stale, edits are pending, or the plan cannot be applied safely. + */ + public function insertMapLines(string $mapId, int $revision, string $axis, int $at, int $count, ?string $answer = null, ?string $confirm = null): array + { + return $this->editMapLines($mapId, $revision, $answer, $confirm, 'inserted', 'inserting map lines', + fn(): MapLinePlan => MapLinePlanner::planInsertion($this->workspace->projectRoot, $mapId, $axis, $at, $count)); + } + + /** + * Reviews a project-wide row or column removal before writing it as one + * reversible source set, as {@see insertMapLines()} does. Anything + * authored on a removed line refuses the removal. The review names where + * saves on the removed lines land; confirming repeats that landing. + * + * @param array{int, int}|null $landing Where saves on the removed lines land, after the removal; null proposes one. + * @return array + * @throws SessionRefusal When the map or review is stale, edits are pending, or the removal cannot be applied safely. + */ + public function removeMapLines(string $mapId, int $revision, string $axis, int $at, int $count, ?array $landing = null, ?string $answer = null, ?string $confirm = null): array + { + return $this->editMapLines($mapId, $revision, $answer, $confirm, 'removed', 'removing map lines', + fn(): MapLinePlan => MapLinePlanner::planRemoval($this->workspace->projectRoot, $mapId, $axis, $at, $count, $landing)); + } + + /** + * @param callable(): MapLinePlan $plan Plans the edit from the files on disk. + * @return array + * @throws SessionRefusal + */ + private function editMapLines(string $mapId, int $revision, ?string $answer, ?string $confirm, string $done, string $doing, callable $plan): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if ($answer === 'cancel') { + return ['status' => 'cancelled', 'map' => $mapId, 'revision' => $revision, 'changed' => false, 'maps' => $this->describeMaps()]; + } + if ($this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal(sprintf('Save or undo pending edits before %s. No files were changed.', $doing)); + } + try { + $plan = $plan(); + } catch (InvalidArgumentException|RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + $edit = $plan->edit; + $paths = array_map(fn(string $path): string => substr($path, strlen($this->workspace->projectRoot) + 1), $plan->getChangedPaths()); + $required = new SourceSetRequired($plan->getSourceSet(), $edit->verb . ' ' . $edit->noun, + 'this map line ' . ($done === 'removed' ? 'removal' : 'insertion'), $edit->verb . ' ' . $edit->description . '?', $paths); + $result = $this->writeSourceSet($required, $answer, static fn(): array => [], $confirm); + if (($result['status'] ?? null) === 'question') { + return [...$result, 'map' => $mapId, 'revision' => $revision, 'axis' => $edit->axis, 'at' => $edit->at, 'count' => $edit->count, + ...($edit instanceof LineRemoval ? ['landing' => $edit->landing] : []), + 'paths' => $paths, 'handEdits' => $plan->handEdits, 'notes' => $plan->notes, + 'width' => $plan->width, 'height' => $plan->height]; + } + + return ['status' => $done, 'map' => $mapId, 'revision' => $this->getMapRevision($this->requireMap($mapId)), + 'changed' => true, 'maps' => $this->describeMaps()]; + } + + /** + * Deletes a map's files from disk at once, as the terminal editor does. + * While something still sends the player there ({@see MapReferences}), + * nothing is deleted until the author confirms: the answer is a question + * listing them. The undo history is cleared, since its steps may name + * the deleted map; every other map keeps its unsaved changes. + * + * @return array{status: 'deleted', map: string, maps: list>}|array{status: 'question', map: string, references: list} + * @throws SessionRefusal When the map is unknown or read-only, or its files cannot be removed. + */ + public function deleteMap(string $mapId, bool $confirmed = false): array + { + $this->refuseDuringChangeSet('Deleting a map clears the undo history'); + $this->requireMap($mapId); + $references = new MapReferences($this->workspace)->describe($mapId); + if ($references !== [] && ! $confirmed) { + return ['status' => 'question', 'map' => $mapId, 'references' => $references]; + } + try { + $this->workspace->deleteMap((int) array_search($mapId, $this->workspace->mapIds, true)); + } catch (MapSourceRefusal|RuntimeException $error) { + throw new SessionRefusal(sprintf('%s was not deleted: %s', $mapId, $error->getMessage()), previous: $error); + } + $this->workspace = $this->workspace->withoutMap($mapId); + $this->history->clear(); + + return ['status' => 'deleted', 'map' => $mapId, 'maps' => $this->describeMaps()]; + } + + + /** + * A map as an interface draws it: its layers' glyphs and colours, its + * events and its NPCs, at its current revision, unsaved changes included. + * + * @return array + * @throws SessionRefusal When no map has the id. + */ + public function readMap(string $mapId): array + { + $map = $this->requireMap($mapId); + $width = $map->getWidth(); + $height = $map->getHeight(); + $layers = []; + + foreach ($map->getLayers() as $layer) { + $rows = []; + $colors = []; + for ($y = 0; $y < $height; $y++) { + $row = []; + for ($x = 0; $x < $width; $x++) { + $row[] = $map->getLayerSymbol($layer['id'], $x, $y); + $color = $map->getLayerColor($layer['id'], $x, $y); + if ($color !== null) { + $colors[] = [$x, $y, $color]; + } + } + $rows[] = $row; + } + $layers[] = [ + 'id' => $layer['id'], + 'name' => $layer['name'], + 'label' => MapLayers::formatLabel((string) $layer['name']), + 'order' => $layer['order'], + 'decoration' => (bool) $layer['decoration'], + 'event' => $layer['id'] === MapLayers::EVENT, + 'rows' => $rows, + 'colors' => $colors, + ]; + } + + // Every event the map holds: defined ones in their data order, then + // markers painted without a definition, which the engine refuses to + // load until they are given a type or cleared. + $events = []; + foreach (array_unique([...$map->getEventMarkers(), ...$map->getPlacedEventMarkers()]) as $marker) { + $definition = $map->getEventDefinition($marker); + $area = $map->getEventArea($marker); + $events[] = [ + 'marker' => $marker, + 'type' => EventTypeCatalog::describeClass(is_string($definition['class'] ?? null) ? $definition['class'] : null), + 'defined' => $definition !== null, + 'cells' => $area === null ? [] : array_map(static fn(array $cell): array => [$cell[0], $cell[1]], $area->cells), + ]; + } + + $npcs = []; + foreach ($map->getNpcs()->all() as $index => $npc) { + $npcs[] = [ + 'index' => $index, + 'id' => $npc->getId(), + 'name' => $npc->getName(), + 'x' => $npc->getX(), + 'y' => $npc->getY(), + 'sprite' => $npc->getVisibleSprite(), + ]; + } + + return [ + 'id' => $map->mapId, + 'name' => $map->getDisplayName(), + 'width' => $width, + 'height' => $height, + 'revision' => $this->getMapRevision($map), + 'dirty' => $map->isDirty(), + 'readOnly' => $map->getGridSourceIssue(), + 'baseLayer' => $map->getBaseLayerId(), + 'layers' => $layers, + 'events' => $events, + 'npcs' => $npcs, + 'worldObjects' => WorldObjectAuthoring::readEntries($map), + ...$this->describeMapAreas($map), + 'tileLayers' => $map->describeTileLayers(), + 'physicalOccupancy' => $map->hasMapDataField([MapPhysicalOccupancy::DATA_KEY]), + 'occupancy' => PhysicalOccupancyEditor::readOccupancy($map), + ]; + } + + /** + * The map's world as the game uploads it to a graphical renderer: its + * glyph rows and, when its graphics load, its tileset and tile layers, + * unsaved edits included. `layerIds` maps each glyph layer's id to its + * world layer id. Graphics never decide whether a map shows: when the + * game would refuse them, the world holds glyphs only and + * `graphicsIssue` says why. Wall shadows come only to an interface whose + * renderer paints them and asks with `$tileShadows`, as a game renderer + * negotiates `tile_shadows`; any other receives the world without them. + * + * @return array{map: string, revision: int, assetRoot: string, operations: list>, layerIds: array, animated: bool, graphicsIssue: ?string} + * @throws SessionRefusal When the map is unknown or its layers cannot be presented. + */ + public function readWorld(string $mapId, bool $tileShadows = false): array + { + $map = $this->requireMap($mapId); + $issue = null; + try { + $graphics = $map->loadGraphics(); + } catch (InvalidArgumentException|MapSourceRefusal $error) { + $graphics = null; + $issue = $error->getMessage(); + } + try { + $layerSet = $map->getLayerSet(); + $world = PresentationWorld::getFromLayers($layerSet, 'map', $graphics, $map->getAssetRoot()); + } catch (InvalidArgumentException|MapSourceRefusal $error) { + throw new SessionRefusal(sprintf('%s cannot be drawn: %s', $mapId, $error->getMessage()), previous: $error); + } + $layerIds = $map->getPresentationLayerIds(); + + return [ + 'map' => $map->mapId, + 'revision' => $this->getMapRevision($map), + 'assetRoot' => $map->getAssetRoot(), + 'operations' => $world->getOperations(true, $tileShadows), + 'layerIds' => $layerIds, + 'animated' => $world->animated, + 'graphicsIssue' => $issue, + ]; + } + /** + * The tile palette of the map's tileset ({@see TilePalette}): each tab's + * grid of tile identities and the world that draws it. + * + * @return array{tileset: string, name: string, assetRoot: string, tabs: list>, operations: list>}>} + * @throws SessionRefusal When the map is unknown or has no usable tileset. + */ + public function readTilePalette(string $mapId): array + { + $map = $this->requireMap($mapId); + try { + $tileset = $map->loadTileset() ?? throw new SessionRefusal(sprintf('%s names no tileset; choose one before placing tiles.', $mapId)); + $tabs = []; + foreach (TilePalette::getTabs($tileset) as $tab) { + $world = TilePalette::buildWorld($tileset, $tab['ids'], $map->getAssetRoot(), 'palette:' . $tab['name']); + $tabs[] = [...$tab, 'operations' => $world->operations]; + } + } catch (InvalidArgumentException $error) { + throw new SessionRefusal(sprintf('%s tileset cannot be used: %s', $mapId, $error->getMessage()), previous: $error); + } + + return ['tileset' => $tileset->id, 'name' => $tileset->name, 'assetRoot' => $map->getAssetRoot(), 'tabs' => $tabs]; + } + /** + * A tileset record as an author sees it, unsaved edits included: its + * sheets as the tile palette lays them out ({@see TilePalette}), the tiles + * it marks (drawn above characters, tables, the missing-art tile) and + * those that can be tables ({@see toggleTilesetMark()}), and its + * pieces, each with its glyph picture and its tiles drawn. A tileset the + * Engine would refuse still shows the sheets it can, and `issue` says + * what is wrong; a sheet that cannot be drawn is left out and named. + * + * @return array + * @throws SessionRefusal When the record is unknown. + */ + public function readTilesetPreview(int $index): array + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + $assetRoot = rtrim($this->workspace->projectRoot, '/') . '/assets'; + $data = $record->toArray(); + $data = is_array($data) ? $data : (array) $data; + $issue = null; + try { + $tileset = Tileset::fromArray($record->recordId, $data); + } catch (InvalidArgumentException $error) { + $issue = $error->getMessage(); + // The sheets alone, so what can be seen still is. + $sheets = array_filter(is_array($data['sheets'] ?? null) ? $data['sheets'] : [], static fn(mixed $asset, mixed $sheet): bool => is_string($asset) + && TilesetSheet::tryFrom((string) $sheet) !== null && strtolower(pathinfo($asset, PATHINFO_EXTENSION)) === 'png', ARRAY_FILTER_USE_BOTH); + $tileset = new Tileset($record->recordId, trim((string) ($data['name'] ?? '')) ?: $record->recordId, $sheets); + } + $tabs = []; + $sheetIssues = \Ichiloto\Editor\Maps\TilesetAuthoring::getSheetIssues($tileset, $assetRoot); + $unreadable = array_values($sheetIssues); + // An upper sheet is shown as its image lays it out, sixteen cells across, so a piece as wide as the sheet can + // be selected in one rectangle and copied cells land where the image has them. + $images = array_column(\Ichiloto\Editor\Assets\TilesetSheetCells::describeSheets($tileset), 'ids', 'sheet'); + foreach (array_map(static fn(array $tab): array => in_array($tab['name'], ['B', 'C', 'D', 'E'], true) + ? [...$tab, 'ids' => $images[$tab['name']]] : $tab, TilePalette::getTabs($tileset)) as $tab) { + try { + $world = TilePalette::buildWorld($tileset, $tab['ids'], $assetRoot, 'tileset-preview:' . $tab['name']); + $tabs[] = [...$tab, 'operations' => $world->operations]; + } catch (\Throwable $error) { + $unreadable[] = sprintf('Tab %s cannot be drawn: %s', $tab['name'], $error->getMessage()); + } + } + $pieces = []; + foreach ($tileset->pieces as $piece) { + try { + $world = TilePalette::buildPieceWorld($tileset, $piece, $assetRoot, 'tileset-piece:' . $piece->id); + } catch (\Throwable $error) { + $world = null; + $unreadable[] = sprintf('%s\'s tiles cannot be drawn: %s', $piece->name, $error->getMessage()); + } + $pieces[] = [ + 'id' => $piece->id, + 'name' => $piece->name, + 'layerLabel' => MapLayers::formatLabel($piece->layer), + 'connected' => $piece->connects !== null, + 'width' => $piece->width, + 'height' => $piece->height, + 'tileLayers' => $tileset->getPieceTileLayers($piece), + 'picture' => PiecePlacer::buildPicture($piece), + 'operations' => $world?->operations, + 'connections' => \Ichiloto\Editor\Maps\ConnectedPiecePreview::describe($tileset, $piece, $assetRoot, $sheetIssues), + ]; + } + + return [ + 'palette' => ['tileset' => $tileset->id, 'name' => $tileset->name, 'assetRoot' => $assetRoot, 'tabs' => $tabs], + 'missingArt' => $tileset->missingArt, + 'above' => $tileset->above, + 'tables' => $tileset->tables, + // What each tile shown reads as at ground level, for validation, and where that comes from. + 'appearance' => (object) \Ichiloto\Editor\Maps\TilesetAuthoring::describeAppearance($tileset, + array_merge(...array_map(static fn(array $tab): array => array_merge(...$tab['ids']), $tabs) ?: [[]])), + 'appearanceSheets' => (object) array_map(static fn(TileAppearance $class): string => $class->value, $tileset->appearance->sheets), + // The tiles shown that can be tables, so an interface offers the mark only where it holds. + 'tableTiles' => array_values(array_filter(array_merge(...array_map(static fn(array $tab): array => array_merge(...$tab['ids']), $tabs) ?: [[]]), + static fn(int $tile): bool => TileId::getSheet($tile) === TilesetSheet::A2)), + 'pieces' => $pieces, + 'authoring' => \Ichiloto\Editor\Maps\TilesetAuthoring::describePieces($data, $this->readDatabaseRecord('tilesets', $index)['rows']), + // The names a new piece's glyph and tile layers take: those the tileset's pieces and the maps drawn with it use. + 'layerNames' => \Ichiloto\Editor\Maps\TilesetAuthoring::listLayerNames($tileset, $this->listMapsOfTileset($tileset->id)), + 'connectionShapes' => TilesetPiece::LINE_SHAPES, + 'collisionTypes' => PhysicalOccupancyEditor::getCollisionChoices(), + 'issue' => $issue, + 'unreadable' => $unreadable, + ]; + } + + /** + * The maps whose data names a tileset, unsaved edits included. + * + * @return list + */ + private function listMapsOfTileset(string $tilesetId): array + { + $maps = []; + for ($index = 0; ($map = $this->workspace->getMapByIndex($index)) !== null; $index++) { + if ($map->getMapDataField(['tileset']) === $tilesetId) { + $maps[] = $map; + } + } + + return $maps; + } + + /** + * Sets or removes one explicitly selected piece recipe through its current + * record field, preserving source and shared undo. Wire cells are integers + * or null; a null value removes the recipe, never authors an explicit null. + * + * @param list>|null $expected The recipe the caller last read. + * @param list>|null $value The new recipe, or null to remove it. + */ + public function setTilesetOccupancy(int $index, string $tilesetId, string $pieceId, ?array $expected, ?array $value): array + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + if ($record->recordId !== $tilesetId) { + throw new SessionRefusal('That tileset record changed; read the tileset list again.'); + } + $data = (array) $record->toArray(); + $piece = is_array($data['pieces'] ?? null) ? ($data['pieces'][$pieceId] ?? null) : null; + if (! is_array($piece)) { + throw new SessionRefusal(sprintf('%s is no longer in tileset %s; choose a piece again.', $pieceId, $tilesetId)); + } + try { + if ($expected !== null) { \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decodeRows($expected); } + $current = array_key_exists('occupancy', $piece) + ? \Ichiloto\Editor\Maps\PhysicalFootprintCodec::exportRows($piece['occupancy']) : null; + } catch (InvalidArgumentException $error) { + throw new SessionRefusal('The current or expected recipe cannot be compared safely. Repair its Physical Footprint record row first: ' + . $error->getMessage(), previous: $error); + } + if ($current !== $expected) { + throw new SessionRefusal('That physical footprint changed; read the piece again before editing it.'); + } + try { + $declaration = $value === null ? null : \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decodeRows($value); + // Graphical rows, and the effect and doors found by them, do not own logical footprint geometry. + unset($piece['tiles'], $piece['effect'], $piece['occupancy'], $piece['doorway']); + if ($declaration !== null) { $piece['occupancy'] = $declaration; } + TilesetPiece::fromArray($pieceId, $piece, 'Tileset occupancy authoring'); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $described = array_find(\Ichiloto\Editor\Maps\TilesetAuthoring::describePieces($data, + $this->readDatabaseRecord('tilesets', $index)['rows']), static fn(array $entry): bool => $entry['id'] === $pieceId); + $key = $described['occupancyKey'] ?? null; + if (! is_array($key)) { + throw new SessionRefusal('That physical footprint row cannot be edited; read the record again.'); + } + $result = $this->applyDatabaseRecord('tilesets', $index, $key, + \Ichiloto\Editor\Maps\PhysicalFootprintCodec::encode($declaration)); + if (($result['status'] ?? null) === 'question' + || (($result['changed'] ?? false) !== true && $current !== $value)) { + throw new SessionRefusal('The physical footprint was not applied. Resolve its source edit through the Physical Footprint record row first.'); + } + + return [...$result, 'occupancy' => $value]; + } + + /** + * Marks one tile of a tileset record as drawn above characters or as a + * table, or takes the mark away when it has it, as one undo step through + * the record's own Above Characters or Tables field, so every view of + * the record shows the same list. An autotile is marked by its kind, + * whatever shape it is, as the Engine reads the list; only A2 autotiles + * can be tables. + * + * @param 'above'|'tables' $mark + * @return array{changed: bool, marked: bool, records: list} + * @throws SessionRefusal When the record or mark is unknown, or the tile cannot take the mark. + */ + public function toggleTilesetMark(int $index, string $mark, int $tile): array + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + $this->assertTileMarkable($mark, $tile); + $flag = TileId::getFlagId($tile); + $listed = array_values(array_filter(is_array($record->get($mark)) ? $record->get($mark) : [], is_int(...))); + $marked = ! array_any($listed, static fn(int $listedTile): bool => TileId::getFlagId($listedTile) === $flag); + + return [...$this->setTilesetMarks($index, $mark, [$tile], $marked), 'marked' => $marked]; + } + + /** + * Gives several tiles of a tileset record a mark, or takes it from them, + * as one undo step through the record's Above Characters or Tables + * field: what a drag across a building's roof does. Tiles already as + * asked stay as they are. + * + * @param 'above'|'tables' $mark + * @param list $tiles + * @return array{changed: bool, records: list, tiles: int} + * @throws SessionRefusal When the record or mark is unknown, or a tile cannot take the mark. + */ + public function setTilesetMarks(int $index, string $mark, array $tiles, bool $marked): array + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + if ($tiles === []) { + throw new SessionRefusal('Choose tiles to mark first.'); + } + $flags = []; + foreach ($tiles as $tile) { + $this->assertTileMarkable($mark, $tile); + $flags[TileId::getFlagId($tile)] = true; + } + $listed = array_values(array_filter(is_array($record->get($mark)) ? $record->get($mark) : [], is_int(...))); + $present = array_map(TileId::getFlagId(...), $listed); + // Tiles marked already keep their place in the list, and new ones follow. + $next = $marked + ? [...$listed, ...array_values(array_diff(array_keys($flags), $present))] + : array_values(array_filter($listed, static fn(int $listedTile): bool => ! isset($flags[TileId::getFlagId($listedTile)]))); + $result = $this->applyDatabaseRecord('tilesets', $index, ['field' => $mark, 'frame' => []], implode(', ', $next)); + + return [...$result, 'tiles' => count($flags)]; + } + + /** @throws SessionRefusal When the mark is not one, or the tile cannot take it. */ + private function assertTileMarkable(string $mark, int $tile): void + { + if (! in_array($mark, ['above', 'tables'], true)) { + throw new SessionRefusal(sprintf('%s is not a tile mark; a tile is marked above characters or as a table.', $mark)); + } + if ($tile === TileId::EMPTY || ! TileId::isValid($tile)) { + throw new SessionRefusal(sprintf('%d is not a tile, so it cannot be marked.', $tile)); + } + if ($mark === 'tables' && TileId::getSheet($tile) !== TilesetSheet::A2) { + throw new SessionRefusal(sprintf('Only A2 autotiles can be tables; tile %d is on sheet %s.', $tile, TileId::getSheet($tile)?->value ?? '?')); + } + } + + /** + * Sets what tiles or whole sheets of a tileset record show at ground + * level ({@see \Ichiloto\Engine\Rendering\Tilesets\TilesetAppearance}), or + * with null takes their own class away so they read by their sheet again, + * as one undo step through the record's Appearance rows. Validation alone + * reads the classes; passage never changes. A selection the Engine would + * refuse, such as one sheet named for two classes, changes nothing. + * + * @param list $selectors Tile identities and sheet names. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the record changed, a selector or class is unknown, or the result would be refused. + */ + public function setTileAppearance(int $index, string $tilesetId, array $selectors, ?string $appearance): array + { + $class = $appearance === null ? null : (TileAppearance::tryFrom($appearance) + ?? throw new SessionRefusal(sprintf('%s is not a tile appearance; choose %s.', $appearance, + implode(', ', array_column(TileAppearance::cases(), 'value'))))); + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + if ($record->recordId !== $tilesetId) { + throw new SessionRefusal('That tileset record changed; read the tileset list again.'); + } + if ($selectors === []) { + throw new SessionRefusal('Choose tiles or a sheet first.'); + } + $data = (array) $record->toArray(); + try { + $fields = \Ichiloto\Editor\Maps\TilesetAuthoring::planAppearance($data, $selectors, $class); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($fields === []) { + return ['changed' => false, 'records' => $this->requireCategory('tilesets')->getRecordLabels()]; + } + + return $this->applyDatabaseRecordValues('tilesets', $index, array_map(static fn(string $field, string $value): array + => ['key' => ['field' => $field, 'frame' => []], 'value' => $value], array_keys($fields), $fields), + $class === null ? 'Clear tile appearance' : sprintf('Tile appearance: %s', $class->value)); + } + + /** + * The cells of a map where what it shows disagrees with what its + * collision lets the player do ({@see PassageAgreementCheck}), unsaved + * edits included, each with its rule: what a collision view marks. + * `available` is false for a map without graphics, or whose graphics + * cannot be read. + * + * @return array{map: string, revision: int, available: bool, cells: list} + * @throws SessionRefusal When the map is unknown. + */ + public function readPassageAgreement(string $mapId): array + { + $map = $this->requireMap($mapId); + $cells = PassageAgreementCheck::listFlaggedCells($map); + + return [ + 'map' => $map->mapId, + 'revision' => $this->getMapRevision($map), + 'available' => $cells !== null, + 'cells' => array_map(static fn(array $cell): array => [$cell['x'], $cell['y'], $cell['rule']], $cells ?? []), + ]; + } + + /** + * Measures a walk on a map, unsaved edits included ({@see MapWalkMeasurement}): + * the shortest walk from the first point through each waypoint to the last, + * its steps, walking and dashing seconds, the GPUI field screens it crosses + * and the encounters expected on the way. Read-only: nothing changes. + * + * Screens are counted in the field a GPUI session shows at the game's + * default graphical screen, the battle footprint in GPUI text cells + * ({@see \Ichiloto\Engine\Core\Game::resolveScreenSize()}), at the + * project's field zoom, as GameScene builds its field view. + * + * @param list $points + * @return array + * @throws SessionRefusal When the map is unknown or cannot be read as the game reads it. + */ + public function measureWalk(string $mapId, array $points): array + { + $map = $this->requireMap($mapId); + $zoom = $this->workspace->config?->getRecord(ProjectConfig::FIELD_ZOOM)->get('value'); + // The field falls back to its default zoom for a value it cannot use, and warns there. + $zoom = (is_int($zoom) || is_float($zoom)) && is_finite((float) $zoom) + && $zoom >= FieldViewport::MIN_ZOOM && $zoom <= FieldViewport::MAX_ZOOM ? (float) $zoom : FieldViewport::DEFAULT_ZOOM; + $view = new FieldViewport(new RendererGridConfig(BattleScreen::WIDTH, BattleScreen::HEIGHT, + RendererRuntimeConfig::GPUI_CELL_WIDTH, RendererRuntimeConfig::GPUI_CELL_HEIGHT), $zoom); + [$dashSpeed, $dashUnavailable] = $this->readDashSpeed($map); + try { + $measurement = new MapWalkMeasurement($map, $view, dashSpeedMultiplier: $dashSpeed, dashUnavailable: $dashUnavailable) + ->measure($points); + } catch (InvalidArgumentException|MapSourceRefusal $refusal) { + throw new SessionRefusal(sprintf('%s cannot be measured: %s', $mapId, $refusal->getMessage()), previous: $refusal); + } + + return ['map' => $map->mapId, 'revision' => $this->getMapRevision($map), ...$measurement]; + } + + /** + * How many times faster than walking the player dashes on a map, by the Engine's own reading of the game's + * System dash and the map's Disable Dashing, unsaved edits included; or null and why the player cannot dash there. + * + * @return array{0: ?float, 1: ?string} + */ + private function readDashSpeed(ProjectMap $map): array + { + $system = $this->workspace->getRecordDatabase('system')?->getRecordByIndex(0)?->toArray() ?? []; + try { + $dash = DashSettings::fromSystemData($system); + if (! $dash->enabled) { + // The measurement's own wording for a game without a dash. + return [null, null]; + } + if (DashSettings::readMapDisablesDashing($map->getEditableData())) { + return [null, 'This map turns dashing off, so only walking time is shown.']; + } + } catch (InvalidArgumentException $invalid) { + return [null, sprintf('The game cannot read its dash (%s), so only walking time is shown.', $invalid->getMessage())]; + } + + return [$dash->speedMultiplier, null]; + } + + /** + * Each tile layer's tile identities by row, unsaved edits included, in + * drawing order: what a tile picker reads. A layer that cannot be read + * says why instead. + * + * @return array{map: string, revision: int, layers: list>, issue: ?string}>} + * @throws SessionRefusal When the map is unknown. + */ + public function readTiles(string $mapId): array + { + $map = $this->requireMap($mapId); + $layers = []; + foreach ($map->getTileLayerNames() as $name) { + try { + $rows = $map->readTileEntries([$name], 0, 0, $map->getWidth(), $map->getHeight())[$name] ?? []; + $layers[] = ['name' => $name, 'rows' => array_map(static fn(array $row): array => array_map(intval(...), $row), $rows), + 'issue' => null]; + } catch (MapSourceRefusal $refusal) { + $layers[] = ['name' => $name, 'rows' => [], 'issue' => $refusal->getMessage()]; + } + } + + return ['map' => $map->mapId, 'revision' => $this->getMapRevision($map), 'layers' => $layers]; + } + + /** + * Sets one tile in cells of a tile layer, `0` erasing, as one undo step, + * by {@see stampTiles()}. + * + * @param list $cells The cells, as [x, y]. + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{status: 'applied', changed: int, glyphs: int, revision: int}|array{status: 'question', tile: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or refuses the tile. + */ + public function paintTiles(string $mapId, int $revision, string $layerName, array $cells, int $tile, string $label = 'Place tiles', + array $choices = []): array + { + return $this->stampTiles($mapId, $revision, $layerName, array_map(static fn(array $cell): array => [$cell[0], $cell[1], $tile], $cells), + $label, $choices); + } + + /** + * Sets each cell of a tile layer to its own tile as one undo step + * ({@see CanvasEditor::stampTiles()}): a block chosen in the palette or + * picked from the map, stamped where the author drags, or an erase and a + * stamp together that move it. A layer the map does not have yet is + * created. A tile that stands for a glyph brings or takes that glyph and + * its collision with it; a tile that could stand for several is asked + * about, and nothing changes until the edit is made again with the answer. + * + * @param list $cells Each cell as [x, y, tile]. + * @param array $choices The role key chosen for a tile that could stand for several glyphs, by tile entry. + * @return array{status: 'applied', changed: int, glyphs: int, revision: int}|array{status: 'question', tile: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or refuses a tile. + */ + public function stampTiles(string $mapId, int $revision, string $layerName, array $cells, string $label = 'Place tiles', + array $choices = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + try { + $applied = CanvasEditor::stampTiles($map, $layerName, $cells, $label, $choices); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied['unresolved'] !== []) { + $tile = (string) array_key_first($applied['unresolved']); + + return ['status' => 'question', 'tile' => $tile, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $applied['unresolved'][$tile], + )]; + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['status' => 'applied', 'changed' => $applied['changed'], 'glyphs' => $applied['glyphs'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * The cells a canvas tool paints, the one way every interface works them + * out ({@see CanvasTool::getShapeCells()}): a brush stroke's path widened + * by the brush, or a line or rectangle between two corners. Cells off the + * map are left out, so an interface previews and paints exactly these. + * + * @param array{0: int, 1: int} $from The anchor. + * @param array{0: int, 1: int} $to The cursor. + * @param list $path A brush stroke's cells, in the order dragged; the cursor alone when empty. + * @return array{cells: list} + * @throws SessionRefusal When the map, the tool or the brush width is unknown. + */ + public function getToolShape(string $mapId, string $tool, array $from, array $to, int $brushSize, array $path = []): array + { + $map = $this->requireMap($mapId); + $canvasTool = CanvasTool::tryFrom($tool); + if ($canvasTool === null || $canvasTool === CanvasTool::SELECT) { + throw new SessionRefusal(sprintf('%s is not a painting tool.', $tool)); + } + if (! in_array($brushSize, CanvasTool::BRUSH_SIZES, true)) { + throw new SessionRefusal(sprintf('The brush is %s cells wide.', implode(', ', CanvasTool::BRUSH_SIZES))); + } + [$anchor, $cursor] = [['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]]]; + $cells = $canvasTool === CanvasTool::BRUSH && $path !== [] + ? ToolGeometry::expandByBrush(array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1]], $path), $brushSize) + : $canvasTool->getShapeCells($anchor, $cursor, $brushSize); + + return ['cells' => $this->listCellsOnMap($map, $cells)]; + } + + /** + * The region a flood fill paints from a cell: the cells joined to it + * across and down that hold the same glyph on a glyph layer, or the same + * tile on a tile layer, an autotile counting as its kind whatever shape + * its neighbours give it. A tile layer the map does not have yet is empty + * everywhere. + * + * @return array{cells: list} + * @throws SessionRefusal When the map or the glyph layer is unknown, or not exactly one layer is named. + */ + public function getFillRegion(string $mapId, int $x, int $y, ?string $layerId, ?string $tileLayer): array + { + $map = $this->requireMap($mapId); + if (($layerId === null) === ($tileLayer === null)) { + throw new SessionRefusal('Name the glyph layer or the tile layer to fill.'); + } + if ($layerId !== null) { + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $identityAt = static fn(int $x, int $y): string => $map->getLayerSymbol($layerId, $x, $y); + } else { + try { + $rows = in_array($tileLayer, $map->getTileLayerNames(), true) + ? $map->readTileEntries([$tileLayer], 0, 0, $map->getWidth(), $map->getHeight())[$tileLayer] ?? [] + : []; + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $identityAt = static function (int $x, int $y) use ($rows): string { + $tile = (int) ($rows[$y][$x] ?? TileId::EMPTY); + + return TileId::isAutotile($tile) ? 'kind:' . TileId::getKind($tile) : (string) $tile; + }; + } + + return ['cells' => $this->listCellsOnMap($map, ToolGeometry::floodFill($identityAt, $map->getWidth(), $map->getHeight(), $x, $y))]; + } + + /** + * @param array $cells + * @return list + */ + private function listCellsOnMap(ProjectMap $map, array $cells): array + { + $listed = []; + foreach ($cells as ['x' => $x, 'y' => $y]) { + if ($x >= 0 && $y >= 0 && $x < $map->getWidth() && $y < $map->getHeight()) { + $listed[] = [$x, $y]; + } + } + + return $listed; + } + + /** + * The pieces of the map's tileset an author places ({@see PiecePlacer}), + * in authored order: each with the gameplay layer its glyphs go on and + * whether the map has it, whether it is drawn as connected cells rather + * than stamped whole, its size, a picture of what it draws, its physical + * recipe and the doors its art paints. A map + * whose tileset offers none says why in `issue`. + * + * @return array{map: string, pieces: list>, issue: ?string} + * @throws SessionRefusal When the map is unknown. + */ + public function listPieces(string $mapId): array + { + $map = $this->requireMap($mapId); + try { + $pieces = PiecePlacer::loadPieces($map); + } catch (MapSourceRefusal $refusal) { + return ['map' => $map->mapId, 'pieces' => [], 'issue' => $refusal->getMessage()]; + } + // Loading the pieces loaded the tileset, so it reads here. + $tileset = $map->loadTileset(); + $described = []; + foreach ($pieces as $piece) { + try { + PiecePlacer::findLayer($map, $piece); + $layerIssue = null; + } catch (MapSourceRefusal $refusal) { + $layerIssue = $refusal->getMessage(); + } + $described[] = [ + 'id' => $piece->id, + 'name' => $piece->name, + 'layer' => $piece->layer, + 'layerLabel' => MapLayers::formatLabel($piece->layer), + 'layerIssue' => $layerIssue, + 'connected' => $piece->connects !== null, + 'width' => $piece->width, + 'height' => $piece->height, + 'tileLayers' => $tileset?->getPieceTileLayers($piece) ?? [], + 'picture' => PiecePlacer::buildPicture($piece), + 'occupancy' => PhysicalOccupancyEditor::exportRecipe($piece), + // Where its art paints doors, from its top-left cell; each map's entrances must meet them. + 'doorway' => \Ichiloto\Engine\Field\Doorway::exportCells($piece->doorway), + ]; + } + + return ['map' => $map->mapId, 'tileset' => $map->getMapDataField(['tileset']), 'pieces' => $described, 'issue' => null]; + } + + /** + * What placing a piece between two corners would write, for a preview + * ({@see PiecePlacer::getPreviewCells()}): each cell the piece writes, as + * [x, y, glyph, colour], cells off the map left out. + * + * @param array{0: int, 1: int} $from + * @param array{0: int, 1: int} $to + * @param string|null $color The colour of cells the piece leaves unstyled. + * @return array{cells: list} + * @throws SessionRefusal When the map or the piece is unknown. + */ + public function previewPiece(string $mapId, string $pieceId, array $from, array $to, ?string $color = null): array + { + $map = $this->requireMap($mapId); + $piece = $this->requirePiece($map, $pieceId); + $cells = []; + foreach (PiecePlacer::getPreviewCells($map, $piece, ['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]], $color) as $y => $row) { + foreach ($row as $x => $cell) { + if ($cell !== null && $x >= 0 && $y >= 0 && $x < $map->getWidth() && $y < $map->getHeight()) { + $cells[] = [$x, $y, $cell['symbol'], $cell['color']]; + } + } + } + + return ['cells' => $cells]; + } + + /** + * Places a piece between two corners as one undo step: an item piece + * stamped as whole copies across the area, a connected piece drawn along + * its outline ({@see PiecePlacer}). An area that cannot be placed whole + * changes nothing. With `$footprint`, each stamped copy also sets the + * collision its physical footprint gives, in the same step, on a map with + * its own collision ({@see PlacedPieces::addFootprint()}); without it, or + * for a piece with no footprint, collision is left as it is. + * + * @param array{0: int, 1: int} $from + * @param array{0: int, 1: int} $to + * @param string|null $color The colour of cells the piece leaves unstyled; null keeps each cell's own. + * @return array{status: 'applied', changed: bool, copies: int, cells: int, footprint: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, the piece is unknown, or the placement is refused. + */ + public function placePiece(string $mapId, int $revision, string $pieceId, array $from, array $to, ?string $color = null, + bool $footprint = false): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $piece = $this->requirePiece($map, $pieceId); + [$from, $to] = [['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]]]; + try { + $stamped = 0; + if ($piece->connects === null) { + ['command' => $command, 'origins' => $origins] = PiecePlacer::stampArea($map, $piece, $from, $to, $color); + [$copies, $cells] = [count($origins), 0]; + if ($footprint) { + ['command' => $command, 'cells' => $stamped] = PlacedPieces::addFootprint($map, $piece, $command, $origins, + sprintf('Place %s', $piece->name)); + } + } else { + $drawn = PiecePlacer::getConnectedDrawCells($from, $to); + $command = PiecePlacer::drawConnected($map, $piece, $drawn, [], $color); + [$copies, $cells] = [0, count($drawn)]; + } + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['status' => 'applied', 'changed' => $command !== null, 'copies' => $copies, 'cells' => $cells, 'footprint' => $stamped, + 'revision' => $this->getMapRevision($map)]; + } + + /** + * Stamps whole copies of a piece with their top-left cells at the listed + * origins, as one undo step: scattered copies, such as the peaks of a + * range, placed by a script in one request rather than one per copy. + * Copies follow the rules of {@see placePiece()}; a connected piece is + * drawn, not stamped, and is refused. + * + * @param list $origins + * @return array{status: 'applied', changed: bool, copies: int, cells: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, the piece is unknown or connected, or a copy falls off the map. + */ + public function placePieceCopies(string $mapId, int $revision, string $pieceId, array $origins, ?string $color = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $piece = $this->requirePiece($map, $pieceId); + try { + $command = PiecePlacer::stampOrigins($map, $piece, array_map(static fn(array $cell): array => ['x' => $cell[0], 'y' => $cell[1]], $origins), $color); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['status' => 'applied', 'changed' => $command !== null, 'copies' => count($origins), 'cells' => 0, 'revision' => $this->getMapRevision($map)]; + } + + /** @throws SessionRefusal When the map's tileset has no piece with the id. */ + private function requirePiece(ProjectMap $map, string $pieceId): TilesetPiece + { + try { + $pieces = PiecePlacer::loadPieces($map); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return $pieces[$pieceId] ?? throw new SessionRefusal(sprintf("%s is no longer in the map's tileset. Choose a piece again.", $pieceId)); + } + + /** + * The terrain kinds a map's tileset declares, for a kind palette: each + * kind's glyph in its colour, passage, encounter terrain, ground and + * atlas role, and whether its art is still to be drawn. `painted` says + * whether the map is painted in kinds; until it is, `issue` says how to + * convert it, and no kind can be painted. + * + * @return array{map: string, tileset: mixed, painted: bool, kinds: list>, issue: ?string} + * @throws SessionRefusal When the map is unknown. + */ + public function listKinds(string $mapId): array + { + $map = $this->requireMap($mapId); + try { + $kinds = TerrainKindPainter::readTilesetKinds($map); + } catch (MapSourceRefusal $refusal) { + return ['map' => $map->mapId, 'tileset' => $map->getMapDataField(['tileset']), 'painted' => false, 'kinds' => [], + 'issue' => $refusal->getMessage()]; + } + $issue = null; + try { + TerrainKindPainter::loadKinds($map); + } catch (MapSourceRefusal $refusal) { + $issue = $refusal->getMessage(); + } + $described = []; + foreach ($kinds as $kind) { + try { + PiecePlacer::findLayer($map, $kind->piece); + $layerIssue = null; + } catch (MapSourceRefusal $refusal) { + $layerIssue = $refusal->getMessage(); + } + $picture = PiecePlacer::buildPicture($kind->piece); + $described[] = [ + 'id' => $kind->id, + 'name' => $kind->name, + 'layer' => $kind->layer, + 'layerLabel' => MapLayers::formatLabel($kind->layer), + 'layerIssue' => $layerIssue, + 'glyph' => $kind->symbol, + 'color' => $picture[0][0]['color'] ?? null, + 'passage' => $kind->passage->value, + 'encounters' => $kind->encounters, + 'ground' => $kind->ground === null ? null : $kinds[$kind->ground]->name, + 'atlas' => $kind->atlas, + 'awaitingArt' => $kind->awaitingArt, + 'tileLayers' => array_keys($kind->piece->tiles), + ]; + } + + return ['map' => $map->mapId, 'tileset' => $map->getMapDataField(['tileset']), 'painted' => $issue === null, + 'kinds' => $described, 'issue' => $issue]; + } + + /** + * What painting a kind between two corners would write, for a preview: + * each cell as [x, y, glyph, colour] ({@see PiecePlacer::getPreviewCells()}). + * + * @param array{0: int, 1: int} $from + * @param array{0: int, 1: int} $to + * @return array{cells: list} + * @throws SessionRefusal When the map or the kind is unknown, or the map is not painted in kinds. + */ + public function previewKind(string $mapId, string $kindId, array $from, array $to): array + { + $map = $this->requireMap($mapId); + $kind = $this->requireKind($map, $kindId); + $cells = []; + foreach (PiecePlacer::getPreviewCells($map, $kind->piece, ['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]]) as $y => $row) { + foreach ($row as $x => $cell) { + if ($cell !== null && $x >= 0 && $y >= 0 && $x < $map->getWidth() && $y < $map->getHeight()) { + $cells[] = [$x, $y, $cell['symbol'], $cell['color']]; + } + } + } + + return ['cells' => $cells]; + } + + /** + * Paints a kind over the area between two corners as one undo step: its + * glyph and its tiles, and on a map with declared occupancy its collision + * ({@see TerrainKindPainter::paintArea()}). + * + * @param array{0: int, 1: int} $from + * @param array{0: int, 1: int} $to + * @return array{status: 'applied', changed: bool, cells: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, the kind is unknown, or the paint is refused. + */ + public function paintKind(string $mapId, int $revision, string $kindId, array $from, array $to): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $kind = $this->requireKind($map, $kindId); + try { + ['command' => $command, 'cells' => $cells] = TerrainKindPainter::paintArea($map, $kind, + ['x' => $from[0], 'y' => $from[1]], ['x' => $to[0], 'y' => $to[1]], 'Paint ' . $kind->name); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['status' => 'applied', 'changed' => $command !== null, 'cells' => $cells, 'revision' => $this->getMapRevision($map)]; + } + + /** + * Paints a kind on the listed cells as one undo step, as {@see paintKind()} paints an area: for terrain + * that follows no rectangle, such as a coast or a range, painted by a script in one request per kind. + * + * @param list $cells + * @return array{status: 'applied', changed: bool, cells: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, the kind is unknown, a cell is off the map, or the paint is refused. + */ + public function paintKindCells(string $mapId, int $revision, string $kindId, array $cells): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $kind = $this->requireKind($map, $kindId); + $unique = []; + foreach ($cells as [$x, $y]) { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new SessionRefusal(sprintf('x %d, y %d is off %s, which is %d by %d cells.', $x, $y, $mapId, $map->getWidth(), $map->getHeight())); + } + $unique["{$x},{$y}"] = ['x' => $x, 'y' => $y]; + } + try { + $command = TerrainKindPainter::paintCells($map, $kind, array_values($unique), 'Paint ' . $kind->name); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['status' => 'applied', 'changed' => $command !== null, 'cells' => count($unique), 'revision' => $this->getMapRevision($map)]; + } + + /** + * The kind a cell shows in both presentations, for picking it up: on the + * topmost gameplay layer that shows one there; null when none does. + * + * @return array{kind: ?string} + * @throws SessionRefusal When the map is unknown. + */ + public function readKindAt(string $mapId, int $x, int $y): array + { + $map = $this->requireMap($mapId); + foreach (array_reverse($map->getLayers()) as $layer) { + if ($layer['id'] === MapLayers::EVENT || $layer['decoration']) { + continue; + } + $kind = TerrainKindPainter::findKindAt($map, $layer['id'], $x, $y); + if ($kind !== null) { + return ['kind' => $kind->id]; + } + } + + return ['kind' => null]; + } + + /** + * What converting a map to terrain kinds would do, changing nothing + * ({@see TerrainKindConversion::preview()}). + * + * @param array $mapping Kind ids by old plain glyph. + * @return array + * @throws SessionRefusal When the map is unknown or the mapping is refused. + */ + public function previewKindConversion(string $mapId, array $mapping): array + { + try { + return TerrainKindConversion::preview($this->requireMap($mapId), $mapping); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Converts a map to terrain kinds as one undo step ({@see TerrainKindConversion::apply()}). + * + * @param array $mapping Kind ids by old plain glyph. + * @return array{status: 'applied', changed: bool, cells: int, revision: int} + * @throws SessionRefusal When the map is unknown or stale, or the conversion is refused. + */ + public function convertToKinds(string $mapId, int $revision, array $mapping): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + ['command' => $command, 'cells' => $cells] = TerrainKindConversion::apply($map, $mapping); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['status' => 'applied', 'changed' => $command !== null, 'cells' => $cells, 'revision' => $this->getMapRevision($map)]; + } + + /** @throws SessionRefusal When the map cannot be painted in kinds, or has no kind with the id. */ + private function requireKind(ProjectMap $map, string $kindId): TerrainKind + { + try { + return TerrainKindPainter::requireKind($map, $kindId); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * The cells of a map whose glyph still shows in the graphical field, by + * the Engine's glyph fallback rule ({@see MapGraphicsValidator::listShownGlyphCells()}), + * unsaved edits included: what is left to tile. `available` is false for + * a map without a kind, or whose kind or tile layers cannot be read. + * + * @return array{map: string, revision: int, available: bool, cells: list} + * @throws SessionRefusal When the map is unknown. + */ + public function readCoverage(string $mapId): array + { + $map = $this->requireMap($mapId); + $cells = MapGraphicsValidator::listShownGlyphCells($map); + + return [ + 'map' => $map->mapId, + 'revision' => $this->getMapRevision($map), + 'available' => $cells !== null, + 'cells' => array_map(static fn(array $cell): array => [$cell['x'], $cell['y'], $cell['glyph'], $cell['layer']], $cells ?? []), + ]; + } + + /** + * The piece a layer's glyph plays at a cell ({@see CanvasEditor::findPlayedRole()}): + * what an eyedropper picks up with the glyph, so painting it again draws + * the same piece. Null role when no piece draws it or its tiles do not tell. + * + * @return array{glyph: string, role: ?array{key: string, label: string}} + * @throws SessionRefusal When the map or layer is unknown. + */ + public function readPieceAt(string $mapId, string $layerId, int $x, int $y): array + { + $map = $this->requireMap($mapId); + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $role = CanvasEditor::findPlayedRole($map, $layerId, $x, $y); + + return [ + 'glyph' => $map->hasLayerCell($layerId, $x, $y) ? $map->getLayerSymbol($layerId, $x, $y) : ' ', + 'role' => $role === null ? null : ['key' => $role->key, 'label' => $role->label], + ]; + } + + /** + * Copies a rectangle of a layer into the session's clipboard: its glyphs + * and styles, and the tiles that move with that layer ({@see CanvasClipboard::copy()}). + * + * @return array{layer: string, width: int, height: int} + * @throws SessionRefusal When the map, layer or rectangle is unknown, or a tile layer cannot be read. + */ + public function copySelection(string $mapId, string $layerId, int $x, int $y, int $width, int $height): array + { + $map = $this->requireMap($mapId); + $this->requireSelection($map, $layerId, $x, $y, $width, $height); + try { + CanvasClipboard::copy($map, $layerId, $x, $y, $width, $height, $this->clipboard ??= new Clipboard()); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return $this->describeClipboard(); + } + + /** + * Copies a rectangle of a layer into the session's clipboard and clears + * it, glyphs and the tiles that went with them, as one undo step. + * + * @return array{layer: string, width: int, height: int, changed: int, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the layer or rectangle is unknown. + */ + public function cutSelection(string $mapId, int $revision, string $layerId, int $x, int $y, int $width, int $height): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $this->requireSelection($map, $layerId, $x, $y, $width, $height); + try { + $cut = CanvasClipboard::cut($map, $layerId, $x, $y, $width, $height, $this->clipboard ??= new Clipboard()); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($cut['command'] !== null) { + $this->history->record($cut['command']); + } + + return [...$this->describeClipboard(), 'changed' => $cut['changed'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * Stamps the session's clipboard with its top-left cell at a map cell as + * one undo step. A glyph that could be several pieces is asked about, and + * nothing changes until the paste is made again with the answer. + * + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the clipboard is empty or from another layer. + */ + public function pasteSelection(string $mapId, int $revision, string $layerId, int $x, int $y, array $choices = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + try { + $pasted = CanvasClipboard::paste($map, $layerId, $this->clipboard ??= new Clipboard(), $x, $y, $choices); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($pasted['unresolved'] !== []) { + $glyph = (string) array_key_first($pasted['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $pasted['unresolved'][$glyph], + )]; + } + if ($pasted['command'] !== null) { + $this->history->record($pasted['command']); + } + + return ['status' => 'applied', 'changed' => $pasted['changed'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * What the session's clipboard holds: the layer it came from and its size; an empty layer when it holds nothing. + * + * @return array{layer: string, width: int, height: int} + */ + public function describeClipboard(): array + { + $clipboard = $this->clipboard ?? new Clipboard(); + + return ['layer' => $clipboard->layer, 'width' => $clipboard->getWidth(), 'height' => $clipboard->getHeight()]; + } + + /** @throws SessionRefusal When the layer is not the map's, or the rectangle is empty or leaves the map. */ + private function requireSelection(ProjectMap $map, string $layerId, int $x, int $y, int $width, int $height): void + { + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $map->mapId, $layerId)); + } + if ($width < 1 || $height < 1 || $x < 0 || $y < 0 || $x + $width > $map->getWidth() || $y + $height > $map->getHeight()) { + throw new SessionRefusal(sprintf('A selection of %d x %d at (%d, %d) does not fit %s.', $width, $height, $x, $y, $map->mapId)); + } + } + + /** + * Draws the tiles of the glyphs already on a layer as one undo step + * ({@see CanvasEditor::drawTilesForGlyphs()}): every glyph a piece of the + * map's kind draws gets that piece's tiles. A glyph that could be several + * pieces is asked about, and nothing changes until the edit is made again + * with the answer in `$choices` (a role key, or null for no tiles). + * + * @param array $choices + * @return array{status: 'applied', cells: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the layer is unknown or no piece draws its glyphs. + */ + public function drawLayerTiles(string $mapId, int $revision, string $layerId, array $choices = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId) + ?? throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + try { + $drawn = CanvasEditor::drawTilesForGlyphs($map, $layerId, $choices); + } catch (MapSourceRefusal | InvalidArgumentException | \RuntimeException $refusal) { + throw new SessionRefusal('Tiles were not drawn: ' . $refusal->getMessage(), previous: $refusal); + } + if ($drawn === null) { + throw new SessionRefusal(sprintf('The %s layer has no pieces in this map\'s kind to draw tiles for.', MapLayers::formatLabel((string) $layer['name']))); + } + if ($drawn['unresolved'] !== []) { + $glyph = (string) array_key_first($drawn['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $drawn['unresolved'][$glyph], + )]; + } + if ($drawn['command'] !== null) { + $this->history->record($drawn['command']); + } + + return ['status' => 'applied', 'cells' => $drawn['cells'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * Paints one glyph over cells of a layer, with the tiles that follow it, + * as one undo step. A glyph that could be several pieces is asked about: + * nothing changes until the edit is made again with the answer in + * `$choices` (a role key, or null for no tiles). + * + * @param list $cells The cells, as [x, y]. + * @param string|null $color A colour name or `#rrggbb`; null keeps each cell's colour, '' paints none. + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or refuses the edit. + */ + public function paint(string $mapId, int $revision, string $layerId, array $cells, string $symbol, ?string $color = null, + array $choices = [], string $label = 'Paint'): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $writes = array_map(static fn(array $cell): array => ['x' => (int) $cell[0], 'y' => (int) $cell[1], + 'symbol' => $symbol, 'color' => $layerId === MapLayers::EVENT ? '' : $color], $cells); + + return $this->paintWrites($map, $layerId, $writes, $choices, $label); + } + + /** + * Paints a rectangle of glyphs from its rows, one terminal cell per + * character with its top-left at x, y, and the tiles that follow each + * glyph, as one undo step: a room's rows rewritten in one edit. A space + * paints a blank cell. Questions and colours are answered as by + * {@see paint()}, `$choices` keyed by glyph. + * + * @param list $rows The glyph rows, top to bottom. + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or refuses the edit, or a row leaves the map. + */ + public function paintRegion(string $mapId, int $revision, string $layerId, int $x, int $y, array $rows, ?string $color = null, + array $choices = [], string $label = 'Paint region'): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if (! array_any($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $layerId)) { + throw new SessionRefusal(sprintf('%s has no layer %s.', $mapId, $layerId)); + } + $writes = []; + foreach (array_values($rows) as $dy => $row) { + foreach (mb_str_split($row) as $dx => $symbol) { + [$cellX, $cellY] = [$x + $dx, $y + $dy]; + if ($cellX < 0 || $cellY < 0 || $cellX >= $map->getWidth() || $cellY >= $map->getHeight()) { + throw new SessionRefusal(sprintf('Row %d reaches %d, %d, outside %s (%d x %d); nothing was painted.', + $dy + 1, $cellX, $cellY, $mapId, $map->getWidth(), $map->getHeight())); + } + $writes[] = ['x' => $cellX, 'y' => $cellY, 'symbol' => $symbol, 'color' => $layerId === MapLayers::EVENT ? '' : $color]; + } + } + + return $this->paintWrites($map, $layerId, $writes, $choices, $label); + } + + /** + * Paints glyph writes on a layer with the tiles that follow them, as one undo step. + * + * @param list $writes + * @param array $choices + * @return array{status: 'applied', changed: int, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map refuses the edit. + */ + private function paintWrites(ProjectMap $map, string $layerId, array $writes, array $choices, string $label): array + { + try { + $plan = CanvasEditor::plan($map, $layerId, $writes, $choices, true); + if ($plan !== null && $plan['unresolved'] !== []) { + $glyph = (string) array_key_first($plan['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $plan['unresolved'][$glyph], + )]; + } + $applied = CanvasEditor::apply($map, $layerId, $plan['writes'] ?? $writes, $label, $plan['tiles'] ?? []); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * Adds an empty glyph layer, gameplay or decoration, at the next order, + * as one undo step. Layer edits answer as {@see editLayers()} describes. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the layer. + */ + public function createLayer(string $mapId, int $revision, string $name, bool $decoration = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::createLayer($map, $name, $decoration)); + } + + /** Explicit source-preserving conversion, recorded as one shared undo step. */ + public function migratePhysicalOccupancy(string $mapId, int $revision): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::migratePhysicalOccupancy($map)); + } + + /** + * @param list $cells + * @return array{status: 'applied', changed: int, revision: int} + */ + public function paintOccupancy(string $mapId, int $revision, array $cells, int $collision, string $label = 'Paint collision'): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $applied = PhysicalOccupancyEditor::applyPaint($map, $cells, $collision, $label); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * The project's tree types for the tree brush, by id. + * + * @return array{map: string, types: array} + * @throws SessionRefusal When the map is unknown or the field resources cannot be read. + */ + public function listTreeTypes(string $mapId): array + { + try { + return ['map' => $mapId, 'types' => TreeBrush::listTypes($this->requireMap($mapId))]; + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Draws the chosen tree types over a region, or erases the trees in it, + * as one undo step ({@see TreeBrush}). + * + * @param list $cells + * @param list $types + * @return array{status: 'applied', planted: int, removed: int, revision: int} + * @throws SessionRefusal When the map is stale or read-only, has no collision of its own, or the drawing does not fit. + */ + public function drawTrees(string $mapId, int $revision, array $cells, array $types, bool $erase = false): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $result = $erase ? TreeBrush::erase($map, $cells) : TreeBrush::draw($map, $types, $cells); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($result['command'] !== null) { + $this->history->record($result['command']); + } + + return ['status' => 'applied', 'planted' => $result['planted'], 'removed' => $result['removed'], 'revision' => $this->getMapRevision($map)]; + } + + /** @return array{cells: list, map: string, revision: int} */ + public function getOccupancyFillRegion(string $mapId, int $x, int $y, int $revision): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $cells = PhysicalOccupancyEditor::getFillRegion($map, $x, $y); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return ['cells' => $cells, 'map' => $mapId, 'revision' => $this->getMapRevision($map)]; + } + + /** @return array{cells: list} */ + public function previewOccupancyPiece(string $mapId, int $revision, string $tilesetId, string $pieceId, array $expected, int $x, int $y): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $piece = $this->requireOccupancyPiece($map, $tilesetId, $pieceId, $expected); + try { + $writes = PhysicalOccupancyEditor::getPieceWrites($map, $piece, $x, $y); + } catch (MapSourceRefusal | InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + return ['cells' => array_map(static fn(array $write): array => [$write[0], $write[1], $write[2]->value], $writes)]; + } + + /** @return array{status: 'applied', changed: int, revision: int} */ + public function stampOccupancyPiece(string $mapId, int $revision, string $tilesetId, string $pieceId, array $expected, int $x, int $y): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $piece = $this->requireOccupancyPiece($map, $tilesetId, $pieceId, $expected); + try { + $applied = PhysicalOccupancyEditor::applyPiece($map, $piece, $x, $y); + } catch (MapSourceRefusal | InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($applied['command'] !== null) { + $this->history->record($applied['command']); + } + + return ['status' => 'applied', 'changed' => $applied['changed'], 'revision' => $this->getMapRevision($map)]; + } + + private function requireOccupancyPiece(ProjectMap $map, string $tilesetId, string $pieceId, array $expected): TilesetPiece + { + if ($map->getMapDataField(['tileset']) !== $tilesetId) { + throw new SessionRefusal('The map tileset changed. Choose a physical footprint again. Nothing was changed.'); + } + $piece = $this->requirePiece($map, $pieceId); + if (PhysicalOccupancyEditor::exportRecipe($piece) !== $expected) { + throw new SessionRefusal('The physical footprint changed. Reload the pieces before stamping. Nothing was changed.'); + } + + return $piece; + } + + /** + * Renames a glyph layer as one undo step. A rename that changes the + * map's collisions is asked about; it is made when asked again confirmed. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the rename. + */ + public function renameLayer(string $mapId, int $revision, string $layerId, string $name, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::renameLayer($map, $layerId, $name, $confirm)); + } + + /** + * Removes a glyph layer and its cells as one undo step. A removal that + * changes the map's collisions is asked about first. The layer left is + * the map's base layer. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the removal. + */ + public function removeLayer(string $mapId, int $revision, string $layerId, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::removeLayer($map, $layerId, $confirm)); + } + + /** + * Moves a glyph layer to an order (00-99), or one step `above` or + * `below` among the layers, as one undo step; a layer holding that order + * takes this one's. A move that changes the map's collisions is asked + * about first. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the move, or not exactly one of order and direction is given. + */ + public function moveLayer(string $mapId, int $revision, string $layerId, ?int $order, ?string $direction = null, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::moveLayer($map, $layerId, + self::resolveOrder($order, $direction, static fn(string $step): int => LayerEditor::findAdjacentLayerOrder($map, $layerId, $step)), + $confirm)); + } + + /** + * Makes a glyph layer decoration, drawn without collision, or gameplay, + * as one undo step. A change that changes the map's collisions is asked + * about first. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown, stale or refuses the change. + */ + public function setLayerDecoration(string $mapId, int $revision, string $layerId, bool $decoration, bool $confirm = false): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::setLayerDecoration($map, $layerId, $decoration, $confirm)); + } + + /** + * Adds an empty tile layer as one undo step, placed among the tile + * layers as one a tileset piece names is. `layer` is its name. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the layer. + */ + public function createTileLayer(string $mapId, int $revision, string $name): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::createTileLayer($map, $name)); + } + + /** + * Renames a tile layer and its settings as one undo step. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the rename. + */ + public function renameTileLayer(string $mapId, int $revision, string $name, string $newName): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::renameTileLayer($map, $name, $newName)); + } + + /** + * Removes a tile layer, its tiles and its settings as one undo step. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or has no such tile layer. + */ + public function removeTileLayer(string $mapId, int $revision, string $name): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::removeTileLayer($map, $name)); + } + + /** + * Moves a tile layer to a drawing order (00-99), or one step `above` or + * `below` among the tile layers, as one undo step; a tile layer holding + * that order takes this one's. + * + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or refuses the move, or not exactly one of order and direction is given. + */ + public function moveTileLayer(string $mapId, int $revision, string $name, ?int $order, ?string $direction = null): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => LayerEditor::moveTileLayer($map, $name, + self::resolveOrder($order, $direction, static fn(string $step): int => LayerEditor::findAdjacentTileLayerOrder($map, $name, $step)))); + } + + /** + * Sets a tile layer's offset across and down in field cells (each -0.5, + * 0 or 0.5) and the gameplay layer its tiles move with, or none, as one + * undo step, validated as the Engine reads them. + * + * @param array $offset + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string} + * @throws SessionRefusal When the map is unknown, stale or the Engine would refuse the settings. + */ + public function setTileLayerSettings(string $mapId, int $revision, string $name, array $offset, ?string $movesWith): array + { + return $this->editLayers($mapId, $revision, static fn(ProjectMap $map): array => + LayerEditor::setTileLayerSettings($map, $name, $offset, $movesWith)); + } + + /** + * Makes one layer edit on a map at its current revision and records it. + * It answers `applied` with the map's new revision, whether anything + * changed and the layer to work on (a glyph layer's id or a tile layer's + * name; null after removing a tile layer), or `question` when the edit + * would change the map's collisions: nothing changed then, and the edit + * is made by asking again confirmed. + * + * @param Closure(ProjectMap): array{command: ?Command, layer: ?string, question: ?string} $edit + * @return array{status: 'applied', revision: int, changed: bool, layer: ?string}|array{status: 'question', question: string} + * @throws SessionRefusal When the map is unknown or stale, or the edit is refused. + */ + private function editLayers(string $mapId, int $revision, Closure $edit): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $result = $edit($map); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($result['question'] !== null) { + return ['status' => 'question', 'question' => $result['question']]; + } + if ($result['command'] !== null) { + $this->history->record($result['command']); + } + + return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => $result['command'] !== null, + 'layer' => $result['layer']]; + } + + /** + * The order a move names: the order itself, or the order one step in a + * direction. + * + * @param Closure(string): int $findAdjacentOrder + * @throws SessionRefusal When not exactly one of order and direction is given. + */ + private static function resolveOrder(?int $order, ?string $direction, Closure $findAdjacentOrder): int + { + if (($order === null) === ($direction === null)) { + throw new SessionRefusal(sprintf("Move a layer to an order, or '%s' or '%s', not both or neither.", + LayerEditor::ABOVE, LayerEditor::BELOW)); + } + + return $order ?? $findAdjacentOrder($direction); + } + + /** + * The inspector rows of a map, and of one of its events when one is + * named, as an interface lists and edits them. Each row says how it is + * edited (`kind`) and carries the `key` an edit names it by. + * + * Kinds: `text`, `integer`, `float`, `boolean`, `options` (one of + * `options`, read as the matching `optionLabels` when it has them), + * `reference` (one of `references.list` for its `reference`), + * `conditions` (the condition line, `type:name[:extras]` joined by `;`), + * `writes` (the world-state write line, in the same form), + * `destination` (a map from `references.list` for `maps` and a spawn + * point on it, set together through `setEventDestination`), or `info` + * for a row that is only read here: headings and counts. A typed row's + * `raw` is the value its edit starts from. A row inside a list an author + * adds to and removes from carries `list` (its entry's index), and + * `addInspectorListEntry`/`removeInspectorListEntry` apply to it. + * + * An event's record rows (what every trigger reads beside its class and + * data: its conditions, completion writes, blocked message and cue + * conditions, and a type's lists: a Dialogue event's pages, a Story + * Script's inline commands) are keyed with the `frame` they are in, [] + * at the event, and carry `item` when they belong to an entry adding and + * removing apply to. A row that opens a frame of commands carries that + * `frame`; reading the event again with it lists that frame's rows + * alone, edited through the same calls. + * + * @param list $frame A frame of the event's inline commands; [] for the map and event rows. + * @return array{map: string, revision: int, event: ?string, frame: list, frameLabel: ?string, rows: list>} + * @throws SessionRefusal When the map, event or frame is unknown. + */ + public function readInspector(string $mapId, ?string $marker = null, array $frame = []): array + { + $frame = self::requireFrame($frame, 'readInspector'); + $map = $this->requireMap($mapId); + if ($marker !== null && $map->getEventDefinition($marker) === null && $map->getEventArea($marker) === null) { + throw new SessionRefusal(sprintf('%s has no event %s.', $mapId, $marker)); + } + if ($frame !== [] && $marker === null) { + throw new SessionRefusal('A frame of commands belongs to an event; name the event.'); + } + + return [ + 'map' => $mapId, + 'revision' => $this->getMapRevision($map), + 'event' => $marker, + 'frame' => $frame, + 'frameLabel' => $frame === [] || $marker === null ? null : $this->createMapInspector($map)->describeEventFrame($map, $marker, $frame), + 'rows' => $this->describePlacementRows(array_map(self::describeRow(...), $this->collectInspectorFields($map, $marker, $frame)), + ['kind' => 'event', 'map' => $mapId, 'marker' => $marker]), + // A story event an NPC delivers names that NPC, so an interface can offer `npc.adoptEvent`. + 'deliveredBy' => $marker === null ? null : self::describeDeliveringNpc($map, $marker), + ]; + } + + /** @return array{npc: string, name: string}|null The NPC an event could move onto as a page, or null. */ + private static function describeDeliveringNpc(ProjectMap $map, string $marker): ?array + { + try { + $plan = NpcEventAdoption::plan($map, $marker); + } catch (NpcRefusal) { + return null; + } + + return ['npc' => $plan->npcId, 'name' => $map->getNpcs()->get($plan->npcIndex)?->getName() ?? $plan->npcId]; + } + + /** + * Applies one inspector row's edit as one undo step. The row is found + * again by its key among the map's current rows, so the edit applies + * exactly as that row would in any interface. + * + * Changing the map's kind when it has tiles from its current kind asks + * first: nothing changes until the edit is made again with `$answer` + * `clear` (clear its tile layers and change) or `cancel`. + * + * When the edit changes other maps too (renaming a landmark renames the + * atlas entry of every map naming it), `revisions` gives each of those + * maps' new revision. + * + * @param array $key The row's key, as `readInspector` gave it. + * @return array{status: 'applied', revision: int, changed: bool, revisions?: array}|array{status: 'question', question: string, answers: list} + * @throws SessionRefusal When the map is unknown or stale, the row is gone or read-only, or the edit is refused. + */ + public function applyInspector(string $mapId, int $revision, array $key, string $value, ?string $answer = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $field = $this->requireInspectorField($map, $key); + $kind = self::describeRow($field)['kind']; + + if ($kind === 'info') { + throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); + } + + $inspector = $this->createMapInspector($map); + $cleared = 0; + if (($field['target'] ?? null) === 'map-kind') { + try { + $cleared = $inspector->countTileLayersClearedBy($map, $value); + } catch (InspectorRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($cleared > 0 && $answer === null) { + return $this->describeMapKindQuestion($map, $value, $cleared); + } + if ($answer !== null && ! in_array($answer, ['clear', 'cancel'], true)) { + throw new SessionRefusal(sprintf('Answer clear or cancel, not %s.', $answer)); + } + if ($answer === 'cancel') { + return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => false]; + } + } + + $before = array_map($this->getMapRevision(...), $this->getMapsById()); + $command = $this->runEdit(static fn(): ?Command => ($field['target'] ?? null) === 'map-kind' + ? $inspector->changeMapKind($map, $value, $cleared > 0 && $answer === 'clear') + : $inspector->apply($map, $field, $value)); + // A row may change other maps in the same step: a landmark rename carries the atlas entries naming it. + $others = []; + foreach ($this->getMapsById() as $otherId => $other) { + if ($other !== $map && $this->getMapRevision($other) !== ($before[$otherId] ?? null)) { + $others[(string) $otherId] = $this->getMapRevision($other); + } + } + + return ['status' => 'applied', 'revision' => $this->getMapRevision($map), 'changed' => $command !== null, + ...($others === [] ? [] : ['revisions' => $others])]; + } + + /** + * The event types an event can be, as `createEvent` and an event's Type + * row name them: by label. + * + * @return list + */ + public function listEventTypes(): array + { + return array_map(static fn(int $index, EventTypeDefinition $type): array => [ + 'index' => $index, + 'label' => $type->label, + 'description' => $type->description, + 'class' => $type->className, + ], array_keys(EventTypeCatalog::all()), EventTypeCatalog::all()); + } + + /** + * Places a new event of a type on cells as one undo step: its marker + * painted there and its definition written ({@see EventAuthoring}). + * + * @param list $cells The cells it triggers on, as [x, y]. + * @param string $type The type's label, as `listEventTypes` gives it. + * @param string|null $marker The marker to give it; null takes the first free one. + * @return array{marker: string, revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the cells, type or marker cannot be used. + */ + public function createEvent(string $mapId, int $revision, array $cells, string $type, ?string $marker = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $definition = EventTypeCatalog::findByLabel($type) ?? throw new SessionRefusal(sprintf('There is no event type %s.', $type)); + $created = null; + $this->runEdit(static function () use ($map, $cells, $definition, $marker, &$created): Command { + $created = EventAuthoring::createEvent($map, $cells, $definition, $marker); + + return $created['command']; + }); + + return ['marker' => $created['marker'], 'revision' => $this->getMapRevision($map)]; + } + + /** + * Deletes an event, its cells and its definition together, as one undo + * step. + * + * @return array{revision: int} + * @throws SessionRefusal When the map is unknown, stale or read-only, or has no such event. + */ + public function deleteEvent(string $mapId, int $revision, string $marker): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $this->runEdit(static fn(): Command => EventAuthoring::deleteEvent($map, $marker)); + + return ['revision' => $this->getMapRevision($map)]; + } + + /** + * Moves a story event an NPC delivers onto that NPC as its first page, + * as one undo step ({@see NpcEventAdoption}): the page keeps the event's + * conditions, writes, cue, action and one-shot completion, the event and + * its cells go, and a one-shot event's saved completion is aliased to the + * page so old saves keep it. Quest waymarks that named the event are + * listed for repointing at the NPC; validation refuses them until then. + * + * @param string|null $npcId The NPC to own it; the event's first speaker when null. + * @return array{revision: int, npc: string, index: int, page: array, dropped: list, alias: ?array{from: string, to: string}, waymarks: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the event or NPC cannot take the move. + */ + public function adoptEventOnNpc(string $mapId, int $revision, string $marker, ?string $npcId = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $plan = NpcEventAdoption::plan($map, $marker, $npcId, $this->isAliasTarget(ContentReferenceCategory::ONE_SHOT_EVENT, "{$map->mapId}:{$marker}")); + $alias = $plan->alias === null ? null : SaveManifestAlias::plan($this->workspace->projectRoot, $this->workspace->projectId, + ContentReferenceCategory::ONE_SHOT_EVENT, $plan->alias['from'], $plan->alias['to']); + } catch (NpcRefusal|RuntimeException $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $before = $map->getNpcs(); + $after = $before->withReplaced($plan->npcIndex, new ProjectNpc($plan->entry)); + $map->setNpcs($after); + try { + $deleted = EventAuthoring::deleteEvent($map, $marker); + $alias?->apply(); + } catch (EventRefusal|MapSourceRefusal|RuntimeException $refusal) { + $map->setNpcs($before); + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $commands = [new GenericCommand('NPC page', static fn() => $map->setNpcs($after), static fn() => $map->setNpcs($before)), $deleted]; + if ($alias !== null) { + $commands[] = new GenericCommand('Save alias', static fn() => $alias->apply(), static fn() => $alias->revert()); + } + $this->history->record(new CommandGroup(sprintf('Move event %s onto %s', $marker, $plan->npcId), $commands)); + + return [ + 'revision' => $this->getMapRevision($map), + 'npc' => $plan->npcId, + 'index' => $plan->npcIndex, + 'page' => $plan->page, + 'dropped' => $plan->dropped, + 'alias' => $plan->alias, + 'waymarks' => $this->describeEventWaymarks($map->mapId, $marker), + ]; + } + + /** Whether the save compatibility manifest sends some older identity to this one. */ + private function isAliasTarget(ContentReferenceCategory $category, string $identity): bool + { + try { + $manifest = PhpDataFile::evaluateIsolated($this->workspace->projectRoot . '/' . SaveManifestStep::PATH, $this->workspace->projectRoot); + } catch (Throwable) { + return false; + } + $aliases = is_array($manifest) ? (array) ($manifest['aliases'][$category->value] ?? []) : []; + + return array_any($aliases, static fn(mixed $alias): bool => is_array($alias) && ($alias['to'] ?? null) === $identity); + } + + /** @return list The quest objectives and quest starts whose waymarks name a map event. */ + private function describeEventWaymarks(string $mapId, string $marker): array + { + $found = []; + foreach ($this->workspace->getRecordDatabase('quests')?->getRecords() ?? [] as $quest) { + $data = $quest->toArray(); + foreach ((array) ($data['startWaymarks'] ?? []) as $w => $waymark) { + if (is_array($waymark) && ($waymark['map'] ?? null) === $mapId && ($waymark['event'] ?? null) === $marker) { + $found[] = sprintf('quest %s start waymark %d', strval($data['id'] ?? '?'), intval($w) + 1); + } + } + foreach ((array) ($data['objectives'] ?? []) as $o => $objective) { + foreach (is_array($objective) ? (array) ($objective['waymarks'] ?? []) : [] as $w => $waymark) { + if (is_array($waymark) && ($waymark['map'] ?? null) === $mapId && ($waymark['event'] ?? null) === $marker) { + $found[] = sprintf('quest %s objective %d waymark %d', strval($data['id'] ?? '?'), intval($o) + 1, intval($w) + 1); + } + } + } + } + + return $found; + } + + /** + * Moves every cell of an event by an offset, keeping its shape. + * + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, or a cell would leave the map or cover another event. + */ + public function moveEvent(string $mapId, int $revision, string $marker, int $deltaX, int $deltaY): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $command = $this->runEdit(static fn(): ?Command => EventAuthoring::moveEvent($map, $marker, $deltaX, $deltaY)); + + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; + } + + /** + * Binds an event to what it describes, a world object or a named map + * area, or unbinds it onto cells of its own, as one undo step + * ({@see EventAuthoring::bindEventSubject()}). A bound event's area is + * its subject's, so it moves with the object or the area and cannot be + * moved or reshaped itself. + * + * @param array{kind?: mixed, id?: mixed}|string|null $subject `{kind, id}`, kind one of the Engine's subject kinds; + * a bare id names a world object; null unbinds the event. + * @param list $cells Where an unbound event triggers, as [x, y]; unused when binding. + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, the event or subject is unknown, or the + * cells cannot take the event. + */ + public function setEventSubject(string $mapId, int $revision, string $marker, array|string|null $subject, array $cells = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + // A bare id names a world object, as it always has. + [$kind, $id] = is_string($subject) ? [WorldObjectAuthoring::SUBJECT_KIND, $subject] : [$subject['kind'] ?? null, $subject['id'] ?? null]; + if ($subject !== null && (! is_string($kind) || ! is_string($id))) { + throw new SessionRefusal('A subject is a world object id, or {kind, id} with kind one of ' . implode(', ', MapEventDefinition::SUBJECT_KINDS) . '.'); + } + $command = $this->runEdit(static fn(): ?Command => $subject === null + ? EventAuthoring::unbindEventSubject($map, $marker, $cells) + : EventAuthoring::bindEventSubject($map, $marker, $kind, $id)); + + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; + } + + /** + * The map's named areas, unsaved edits included: each id, its shape as + * written, the cells it covers, and what names it. + * + * @return array{revision: int, areas: list, cells: list, references: list}>} + * @throws SessionRefusal When the map is unknown or its areas cannot be read. + */ + public function readAreas(string $mapId): array + { + $map = $this->requireMap($mapId); + + return ['revision' => $this->getMapRevision($map), 'areas' => $this->describeAreas($map)]; + } + + /** + * Declares a named area: logical territory the map owns, which events + * bind to as their subject and landmarks anchor to. One undo step. + * + * @param string|null $id The area's id; null takes the first free `area-N`, to be renamed. + * @param array $area The Engine's CellArea shape: `{cells: [[x, y], ...]}` or `{x, y, width, height}` + * ({@see self::readAreaShape()} reads selected cells as one). + * @return array{revision: int, changed: bool, id: string} + * @throws SessionRefusal When the map is unknown, stale or read-only, the id is taken or not an area id, or the + * area is empty, malformed or off the map. + */ + public function createArea(string $mapId, int $revision, ?string $id, array $area): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { + $id ??= MapAreaAuthoring::findUnusedId($map); + } catch (MapAreaRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return [...$this->changeAreas($mapId, $revision, static fn(MapAreaAuthoring $areas): ?Command => $areas->createArea($id, $area)), 'id' => $id]; + } + + /** + * The area shape a request names: its `area` as written, or its selected + * `cells`, written as a rectangle when they fill one. + * + * @param array $params + * @return array + * @throws SessionRefusal When it names neither. + */ + public static function readAreaShape(array $params): array + { + if (is_array($params['area'] ?? null)) { + return $params['area']; + } + $cells = $params['cells'] ?? null; + if (is_array($cells) && array_all($cells, static fn(mixed $cell): bool => is_array($cell) && is_int($cell[0] ?? null) && is_int($cell[1] ?? null))) { + return MapAreaAuthoring::createShapeFor(array_map(static fn(array $cell): array => [$cell[0], $cell[1]], $cells)); + } + + throw new SessionRefusal('Name the area as "area" (cells, or x, y, width and height) or as selected "cells".'); + } + + /** + * Gives a named area a new shape, as one undo step. The events bound to + * it cover its new cells. + * + * @param array $area The Engine's CellArea shape. + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, there is no such area, or the area is + * empty, malformed or off the map. + */ + public function setArea(string $mapId, int $revision, string $id, array $area): array + { + return $this->changeAreas($mapId, $revision, static fn(MapAreaAuthoring $areas): ?Command => $areas->setArea($id, $area)); + } + + /** + * Renames a named area, and every event and landmark naming it, as one + * undo step. + * + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, there is no such area, or the new id is + * taken or not an area id. + */ + public function renameArea(string $mapId, int $revision, string $id, string $to): array + { + return $this->changeAreas($mapId, $revision, static fn(MapAreaAuthoring $areas): ?Command => $areas->renameArea($id, $to)); + } + + /** + * Deletes a named area nothing names, as one undo step. + * + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, there is no such area, or an event or + * landmark names it (the message names them). + */ + public function deleteArea(string $mapId, int $revision, string $id): array + { + return $this->changeAreas($mapId, $revision, static fn(MapAreaAuthoring $areas): ?Command => $areas->deleteArea($id)); + } + + /** + * @param callable(MapAreaAuthoring): ?Command $change + * @return array{revision: int, changed: bool} + */ + private function changeAreas(string $mapId, int $revision, callable $change): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $command = $this->runEdit(static fn(): ?Command => $change(new MapAreaAuthoring($map))); + + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; + } + + /** + * The map's areas for a map read: an unreadable areas block leaves the + * rest of the map readable, with its reason in `areasIssue`. + * + * @return array{areas: list>, areasIssue?: string} + */ + private function describeMapAreas(ProjectMap $map): array + { + try { + return ['areas' => $this->describeAreas($map)]; + } catch (SessionRefusal $refusal) { + return ['areas' => [], 'areasIssue' => $refusal->getMessage()]; + } + } + + /** + * @return list, cells: list, references: list}> + * @throws SessionRefusal When the areas cannot be read. + */ + private function describeAreas(ProjectMap $map): array + { + try { + return array_map(static fn(array $area): array => [...$area, 'references' => MapAreaAuthoring::describeReferences($map, $area['id'])], + MapAreaAuthoring::describeAreas($map)); + } catch (MapAreaRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Repaints an event as exactly a rectangle. + * + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the rectangle is empty, leaves the map or covers another event. + */ + public function setEventBounds(string $mapId, int $revision, string $marker, int $x, int $y, int $width, int $height): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $command = $this->runEdit(static fn(): ?Command => EventAuthoring::setEventBounds($map, $marker, $x, $y, $width, $height)); + + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; + } + + /** + * Sets a transfer event's destination map and the spawn point on it as + * one undo step, with the heading the player faces there when given; + * without one the spawn facing stays as it is. + * + * @param string|null $heading North, East, South or West, as the Spawn Facing row offers them; null leaves it. + * @return array{revision: int, changed: bool} + * @throws SessionRefusal When either map is unknown, the map is stale or read-only, the event has no destination, the spawn point is off the destination, or the heading is not one. + */ + public function setEventDestination(string $mapId, int $revision, string $marker, string $destinationMapId, int $x, int $y, ?string $heading = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $destination = $this->requireMap($destinationMapId); + $command = $this->runEdit(fn(): ?Command => $this->createMapInspector($map)->setTransferDestination($map, $marker, $destination, $x, $y, $heading)); + + return ['revision' => $this->getMapRevision($map), 'changed' => $command !== null]; + } + + /** + * Adds an entry to the list an inspector row belongs to (a row with + * `list`), after that row's entry, as one undo step. + * + * @param array $key The row's key, as `readInspector` gave it. + * @return array{revision: int, changed: bool, message: string} + * @throws SessionRefusal When the map is unknown or stale, the row is gone or in no list, or the list cannot take an entry. + */ + public function addInspectorListEntry(string $mapId, int $revision, array $key): array + { + return $this->editInspectorList($mapId, $revision, $key, + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->addListEntry($map, $field)); + } + + /** + * Removes the entry an inspector row belongs to, as one undo step. + * + * @param array $key The row's key, as `readInspector` gave it. + * @return array{revision: int, changed: bool, message: string} + * @throws SessionRefusal When the map is unknown or stale, the row is gone or in no list, or the list cannot lose the entry. + */ + public function removeInspectorListEntry(string $mapId, int $revision, array $key): array + { + return $this->editInspectorList($mapId, $revision, $key, + static fn(MapInspector $inspector, ProjectMap $map, array $field): ?InspectorListEdit => $inspector->removeListEntry($map, $field)); + } + + /** + * @param array $key + * @param Closure(MapInspector, ProjectMap, array): ?InspectorListEdit $edit + * @return array{revision: int, changed: bool, message: string} + */ + private function editInspectorList(string $mapId, int $revision, array $key, Closure $edit): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $field = $this->requireInspectorField($map, $key); + $inspector = $this->createMapInspector($map); + $result = null; + $this->runEdit(static function () use ($edit, $inspector, $map, $field, &$result): ?Command { + $result = $edit($inspector, $map, $field); + + return $result?->command; + }); + + return ['revision' => $this->getMapRevision($map), 'changed' => $result?->command !== null, 'message' => $result?->summary ?? 'Nothing changed.']; + } + + /** + * Runs one edit, records the command it returns, and turns any refusal + * into the session's. + * + * @param callable(): ?Command $edit + * @throws SessionRefusal + */ + private function runEdit(callable $edit): ?Command + { + try { + $command = $edit(); + } catch (InspectorRefusal $refusal) { + throw new SessionRefusal(implode("\n", [$refusal->getMessage(), ...$refusal->details]), previous: $refusal); + } catch (EventRefusal|MapSourceRefusal|MapAreaRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($command !== null) { + $this->history->record($command); + } + + return $command; + } + + /** + * The inspector row a key names, among the map's current rows. + * + * @param array $key + * @return array + * @throws SessionRefusal When no current row has the key. + */ + private function requireInspectorField(ProjectMap $map, array $key): array + { + $marker = is_string($key['marker'] ?? null) ? $key['marker'] : null; + $frame = self::requireFrame($key['frame'] ?? [], 'readInspector'); + + // A key comes back as the interface stored it, which may not keep + // its members in the order they were given. + ksort($key); + + return array_find($this->collectInspectorFields($map, $marker, $frame), static function (array $candidate) use ($key): bool { + $candidateKey = MapInspector::describeRowKey($candidate); + if ($candidateKey !== null) { + ksort($candidateKey); + } + + return $candidateKey === $key; + }) ?? throw new SessionRefusal('That row is no longer in the inspector; read it again.'); + } + + /** + * What to ask before a kind change clears a map's tiles. + * + * @return array{status: 'question', question: string, answers: list} + */ + private function describeMapKindQuestion(ProjectMap $map, string $tilesetId, int $layers): array + { + $labels = new ReferenceCatalog($this->workspace, $map)->labelsFor('tilesets'); + $current = (string) $map->getMapDataField(['tileset']); + $currentLabel = $labels[$current] ?? $current; + $label = $labels[$tilesetId] ?? $tilesetId; + + return [ + 'status' => 'question', + 'question' => sprintf('Change %s\'s kind to %s?', $map->getDisplayName(), $label), + 'answers' => [ + ['key' => 'cancel', 'label' => 'Cancel', 'description' => sprintf('Keep its kind, %s, and its tiles.', $currentLabel)], + ['key' => 'clear', 'label' => sprintf('Clear %d tile %s and change', $layers, $layers === 1 ? 'layer' : 'layers'), + 'description' => sprintf('Its tiles come from %s and would show the wrong art as %s. Glyphs stay%s. Undo restores them.', $currentLabel, $label, + $this->createMapInspector($map)->isKindChangeLeavingTerrainKinds($map, $tilesetId) + ? sprintf(', and with no terrain kinds in %s they block or let pass as the collision dictionary says', $label) : '')], + ], + ]; + } + + /** + * The values a reference row is chosen from, with how each reads. + * + * @return list + * @throws SessionRefusal When the map is unknown or the category is not a reference. + */ + /** + * The vocabulary conditions and world writes are built from, a part at a + * time ({@see ConditionEditor::getGrammar()}, {@see WorldWriteEditor::getGrammar()}). + * + * @return array{conditions: list>, writes: list>} + */ + public function describeWorldStateGrammar(): array + { + return ['conditions' => ConditionEditor::getGrammar(), 'writes' => WorldWriteEditor::getGrammar()]; + } + + /** + * Writes conditions or world writes built a part at a time as the one + * line a conditions or writes row is set to, refusing an entry the + * engine could not read rather than dropping it. + * + * @param 'conditions'|'writes' $codec + * @param list $entries Each an array as a row's `entries` lists them. + * @param list|null $writeTypes The write types the row allows, when it restricts them. + * @return array{line: string, descriptions: list} + * @throws SessionRefusal When an entry has no name, an unknown type or one the row does not allow. + */ + public function encodeWorldState(string $codec, array $entries, ?array $writeTypes = null): array + { + $entries = array_values($entries); + foreach ($entries as $number => $entry) { + if (! is_array($entry) || trim((string) ($entry['name'] ?? '')) === '') { + throw new SessionRefusal(sprintf('Entry %d needs a name.', $number + 1)); + } + } + try { + if ($codec === 'conditions') { + $line = ConditionCodec::encodeAll($entries); + $decoded = ConditionCodec::decodeAllStrictly($line); + + return ['line' => $line, 'descriptions' => array_map(ConditionEditor::describe(...), $decoded)]; + } + if ($codec === 'writes') { + $line = WorldWriteCodec::encodeAll($entries); + $decoded = WorldWriteCodec::decodeAllStrictly($line); + $refused = array_find($decoded, static fn(array $set): bool => $writeTypes !== null && ! in_array($set['type'], $writeTypes, true)); + if ($refused !== null) { + throw new SessionRefusal(sprintf('This row cannot write a %s; it allows %s.', $refused['type'], implode(', ', $writeTypes ?? []))); + } + + return ['line' => $line, 'descriptions' => array_map(WorldWriteCodec::describe(...), $decoded)]; + } + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + throw new SessionRefusal(sprintf('There is no %s codec; use conditions or writes.', $codec)); + } + + /** + * What an elemental affinity row is built from: the elements the game + * knows, and the named effects with their multipliers, which the + * terminal cycles and a graphical editor offers beside a typed one. + * + * @return array{elements: list, effects: list} + * @throws SessionRefusal When the project's element list cannot be read. + */ + public function describeAffinityVocabulary(): array + { + try { + $elements = $this->workspace->getElementIdentities(); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $effects = []; + foreach (ElementAffinityCodec::EFFECTS as $label => $multiplier) { + $effects[] = ['label' => $label, 'multiplier' => $multiplier]; + } + + return ['elements' => $elements, 'effects' => $effects]; + } + + /** + * Writes elemental affinities built a row at a time as the one line an + * affinity row is set to, refusing an element the game does not know, + * one named twice, or a multiplier that is not a number. + * + * @param list $entries Each `{element, multiplier}`. + * @return array{line: string, descriptions: list} + * @throws SessionRefusal When an entry cannot be stored as the Engine reads it. + */ + public function encodeAffinities(array $entries): array + { + $elements = $this->describeAffinityVocabulary()['elements']; + $affinities = []; + foreach (array_values($entries) as $number => $entry) { + $element = is_array($entry) && is_string($entry['element'] ?? null) ? trim($entry['element']) : ''; + $multiplier = is_array($entry) ? ($entry['multiplier'] ?? null) : null; + if (! in_array($element, $elements, true)) { + throw new SessionRefusal(sprintf('Entry %d needs an element the game knows: %s.', $number + 1, implode(', ', $elements))); + } + if (array_key_exists($element, $affinities)) { + throw new SessionRefusal(sprintf('%s is listed twice; keep one multiplier for it.', $element)); + } + if (! is_int($multiplier) && ! is_float($multiplier) || ! is_finite((float) $multiplier)) { + throw new SessionRefusal(sprintf('%s needs a multiplier: 2 weak, 0.5 resist, 0 null, -1 absorb, or any number between.', $element)); + } + $affinities[$element] = (float) $multiplier; + } + $line = ElementAffinityCodec::encodeAll($affinities); + + return [ + 'line' => $line, + 'descriptions' => array_map( + static fn(string $element, float $multiplier): string => sprintf('%s: %s', $element, ElementAffinityCodec::describe($multiplier)), + array_keys($affinities), + array_values($affinities), + ), + ]; + } + + /** + * A summon's or effect's timeline as a timeline editor lays it out: its + * frames per second and length, each track with its keyframes' frames and + * durations, and the cues, each value with the record row key that edits + * it through `database.apply`, as the record grid edits it. A cinematic is + * a command tree, not a timeline. + * + * @return array{fps: int, lengthFrames: int, tracks: list>, cues: list>} + * @throws SessionRefusal When the record is not a summon or effect the project has. + */ + public function describeCutsceneTimeline(string $category, int $index): array + { + $asset = $this->requireTimelineCutscene($category, $index); + $tracks = $cues = []; + foreach ($this->readDatabaseRecord($category, $index, ['tracks'])['rows'] as $row) { + $field = (string) ($row['key']['field'] ?? ''); + if (preg_match('/^track(\d+)(Id|Type|Presentation)$/', $field, $match) === 1) { + $tracks[(int) $match[1]][strtolower($match[2])] = (string) $row['value']; + } elseif (preg_match('/^track(\d+)(Asset|Sheetcolumns|Sheetrows|Fit|Attachment|Pivot|Anchor|Placementsubject|Placementattachment)$/', $field, $match) === 1) { + // An image track's art, and the keys that edit how it fills its cells and where it sits on its + // anchor: a battler point, or on the stage a subject and a named point on it. + $art = ['Asset' => 'asset', 'Sheetcolumns' => 'columns', 'Sheetrows' => 'rows', 'Fit' => 'fit', 'Attachment' => 'attachment', 'Pivot' => 'pivot', + 'Anchor' => 'anchor', 'Placementsubject' => 'subject', 'Placementattachment' => 'point'][$match[2]]; + $tracks[(int) $match[1]]['art'][$art] = in_array($art, ['columns', 'rows'], true) ? max(1, (int) $row['value']) : (string) ($row['raw'] ?? $row['value']); + if (in_array($art, ['fit', 'attachment', 'pivot', 'subject', 'point'], true)) { + $tracks[(int) $match[1]]['art'][$art . 'Key'] = $row['key']; + } + if (in_array($art, ['fit', 'attachment'], true)) { + $tracks[(int) $match[1]]['art'][$art . 'Options'] = array_values(array_filter($row['options'] ?? [], static fn(mixed $option): bool => $option !== '')); + } + } elseif (preg_match('/^track(\d+)Keyframe(\d+)(Frame|Duration|SourceFrame)$/', $field, $match) === 1) { + $name = lcfirst($match[3]); + $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][$name] = (int) $row['value']; + if ($name !== 'sourceFrame') { + $tracks[(int) $match[1]]['keyframes'][(int) $match[2]][$name . 'Key'] = $row['key']; + } + } + } + foreach ($this->readDatabaseRecord($category, $index, ['cues'])['rows'] as $row) { + if (preg_match('/^cue(\d+)(Id|Frame|Type)$/', (string) ($row['key']['field'] ?? ''), $match) === 1) { + $value = $match[2] === 'Frame' ? (int) $row['value'] : (string) $row['value']; + $cues[(int) $match[1]][strtolower($match[2])] = $value; + if ($match[2] === 'Frame') { + $cues[(int) $match[1]]['frameKey'] = $row['key']; + } + } + } + // Glyph tracks also have anchors, but those are not image art. Do not + // project image defaults or an image picker onto terminal-only tracks. + foreach ($tracks as &$track) { + if (($track['type'] ?? '') !== 'image') { + unset($track['art']); + } + } + unset($track); + $payload = $asset->payload(); + + return [ + // An effect or summon with separate terminal and graphical sequences names the one shown; others name none. + 'presentation' => $asset->getPresentationView()?->value, + 'fps' => (int) ($payload['fps'] ?? 0), + 'lengthFrames' => (int) ($payload['lengthFrames'] ?? 0), + 'tracks' => array_values(array_map(fn(array $track, int $position): array => self::placeOnStage([...$track, + 'keyframes' => array_values($track['keyframes'] ?? []), + ...(isset($track['art']) ? ['art' => [...$this->measureTrackArt($track['art']), + 'pivotDefaults' => self::getTimelinePivotDefaults($asset->type, $payload['tracks'][$position] ?? []), + ]] : [])], $payload['tracks'][$position] ?? null), $tracks, array_keys($tracks))), + 'cues' => array_values($cues), + // Stage ownership survives switching to an independent Terminal sequence. + 'ownedStage' => $asset->isOwnedStageEffect(), + 'canHaveStage' => $asset->getPresentationView() !== EffectPresentation::TERMINAL + && ($asset->type === CutsceneType::EFFECT || $asset->hasPresentations()), + 'stage' => is_array($payload['stage'] ?? null) ? $this->describeCutsceneStage($category, $index) : null, + ]; + } + + /** + * Consumer defaults are preview metadata, never authored values. A shared + * effect can play in field or battle; the client selects the context it is + * actually previewing. Stage tracks cannot acquire field/battler geometry. + * + * @return array + */ + private static function getTimelinePivotDefaults(CutsceneType $type, array $track): array + { + $center = ['x' => 0.5, 'y' => 0.5]; + if (($track['anchor'] ?? null) === 'stage') { + return ['stage' => $center]; + } + + return [ + ...($type === CutsceneType::EFFECT + ? ['field' => new \Ichiloto\Engine\Rendering\Presentation\PresentationSpritePivot()->toArray()] : []), + 'battle' => $center, + ]; + } + + /** + * A stage image's placement in stage units, and each keyframe's offset, + * as the Engine reads them with their absent defaults, so an editor can + * find the subject's box under a point of the art. Other tracks are as + * described. + * + * @param array $described + * @return array + */ + private static function placeOnStage(array $described, mixed $authored): array + { + if (! is_array($authored) || ($authored['anchor'] ?? null) !== 'stage' || ! isset($described['art'])) { + return $described; + } + + $placement = is_array($authored['placement'] ?? null) ? $authored['placement'] : []; + $described['art']['placement'] = [ + 'position' => self::readStagePoint($placement['position'] ?? null, ['x' => 0, 'y' => 0]), + 'size' => is_array($placement['size'] ?? null) ? ['width' => (float) ($placement['size']['width'] ?? 0), 'height' => (float) ($placement['size']['height'] ?? 0)] : null, + // An image's own pivot is its cell's centre unless authored. + 'pivot' => self::readStagePoint($authored['pivot'] ?? null, ['x' => 0.5, 'y' => 0.5]), + ]; + foreach ($described['keyframes'] as $index => $keyframe) { + $described['keyframes'][$index]['offset'] = self::readStagePoint($authored['keyframes'][$index]['position'] ?? null, ['x' => 0, 'y' => 0]); + } + + return $described; + } + + /** + * @param array{x: int|float, y: int|float} $default + * @return array{x: float, y: float} + */ + private static function readStagePoint(mixed $point, array $default): array + { + $point = is_array($point) ? $point : $default; + + return ['x' => (float) ($point['x'] ?? $default['x']), 'y' => (float) ($point['y'] ?? $default['y'])]; + } + + /** + * A summon sequence's stage as a timeline editor lays it out: its canvas + * and the frames it is shown between, each subject with its named points + * (where they are in its box), and its camera and cover keys on the + * clock, each value with the row key that edits it. + * + * @return array + */ + private function describeCutsceneStage(string $category, int $index): array + { + $stage = ['subjects' => [], 'camera' => [], 'covers' => []]; + foreach ($this->readDatabaseRecord($category, $index)['rows'] as $row) { + $name = ['stage.canvas' => 'canvas', 'stage.startFrame' => 'startFrame', 'stage.restoreFrame' => 'restoreFrame'][(string) ($row['key']['field'] ?? '')] ?? null; + if ($name !== null) { + $stage[$name] = $name === 'canvas' ? (string) $row['value'] : (int) $row['value']; + $stage[$name . 'Key'] = $row['key']; + } + } + foreach ($this->readDatabaseRecord($category, $index, [CutsceneSchemas::STAGE_SUBJECTS_KEY])['rows'] as $row) { + $field = (string) ($row['key']['field'] ?? ''); + if (preg_match('/^subject(\d+)Id$/', $field, $match) === 1) { + $stage['subjects'][(int) $match[1]]['id'] = (string) $row['value']; + } elseif (preg_match('/^subject(\d+)Attachment(\d+)(Id|X|Y)$/', $field, $match) === 1) { + $name = strtolower($match[3]); + $stage['subjects'][(int) $match[1]]['points'][(int) $match[2]][$name] = $name === 'id' ? (string) $row['value'] : (float) $row['value']; + $stage['subjects'][(int) $match[1]]['points'][(int) $match[2]][$name . 'Key'] = $row['key']; + } + } + foreach ([CutsceneSchemas::STAGE_CAMERA_KEY => ['camera', 'camera'], CutsceneSchemas::STAGE_COVERS_KEY => ['cover', 'covers']] as $list => [$prefix, $name]) { + foreach ($this->readDatabaseRecord($category, $index, [$list])['rows'] as $row) { + if (preg_match('/^' . $prefix . '(\d+)(Id|Frame)$/', (string) ($row['key']['field'] ?? ''), $match) === 1) { + $stage[$name][(int) $match[1]][strtolower($match[2])] = $match[2] === 'Frame' ? (int) $row['value'] : (string) $row['value']; + if ($match[2] === 'Frame') { + $stage[$name][(int) $match[1]]['frameKey'] = $row['key']; + } + } + } + } + + $authored = $this->requireTimelineCutscene($category, $index)->payload()['stage']['subjects'] ?? []; + foreach ($stage['subjects'] as $position => $subject) { + // Where the subject's box is, as the Engine reads it: its pivot is its bottom centre unless authored. + $data = is_array($authored[$position] ?? null) ? $authored[$position] : []; + $stage['subjects'][$position] += [ + 'position' => self::readStagePoint($data['position'] ?? null, ['x' => 0, 'y' => 0]), + 'size' => ['width' => (float) ($data['size']['width'] ?? 0), 'height' => (float) ($data['size']['height'] ?? 0)], + 'pivot' => self::readStagePoint($data['pivot'] ?? null, ['x' => 0.5, 'y' => 1]), + ]; + } + + return [...$stage, + 'subjects' => array_values(array_map(static fn(array $subject): array => [...$subject, 'points' => array_values($subject['points'] ?? [])], $stage['subjects'])), + 'camera' => array_values($stage['camera']), + 'covers' => array_values($stage['covers'])]; + } + + /** + * One authored frame, with independent Terminal output and, for an + * owned graphical effect stage, the Engine's shared presentation canvas. + * + * @return array{frame: int, totalFrames: int, fps: int, lines: list, terminalCanvas: array, cues: list>} + * @throws SessionRefusal When the record is not a summon or effect, or its timeline does not compile. + */ + public function readCutscenePreview(string $category, int $index, int $frame, int $width, int $height): array + { + $asset = $this->requireTimelineCutscene($category, $index); + try { + $compiled = $asset->type === CutsceneType::EFFECT + ? $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: $asset->isOwnedStageEffect()) + : $asset->compiledSummon(); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $preview = new TimelinePreviewSession($compiled, $asset->type === CutsceneType::EFFECT ? null : false); + $requestedFrame = $frame; + $frame = max(0, min($frame, $preview->totalFrames() - 1)); + + $lines = array_values($asset->isOwnedStageEffect() + ? new \Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage(EffectPresentation::TERMINAL, false, forStage: true) + ->drawFrame($preview->activeSegments($frame), max(1, $width), max(1, $height)) + : $preview->frame(max(1, $width), max(1, $height), $frame)); + $stageCanvas = null; + $stageCanvasError = null; + if ($asset->isOwnedStageEffect() && $asset->getPresentationView() !== EffectPresentation::TERMINAL) { + try { + $graphical = $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true); + $preview = new TimelinePreviewSession($graphical); + $frame = max(0, min($requestedFrame, $preview->totalFrames() - 1)); + $stageCanvas = new \Ichiloto\Editor\Cutscenes\Preview\EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true) + ->renderCanvas($graphical, $this->workspace->projectRoot . '/assets', $frame, + (int) $graphical->defaults['stage']['canvas']['width'], (int) $graphical->defaults['stage']['canvas']['height'])?->toArray(); + } catch (Throwable $failure) { + // A graphical failure is visible in its tab, never a field + // fallback or a dependency of independent Terminal playback. + $stageCanvasError = $failure->getMessage(); + } + } + + return ['frame' => $frame, 'totalFrames' => $preview->totalFrames(), 'fps' => $preview->fps(), + 'lines' => $lines, 'terminalCanvas' => $this->describeTerminalCanvas($lines, max(1, $width), max(1, $height)), + 'stageCanvas' => $stageCanvas, 'stageCanvasError' => $stageCanvasError, + 'cues' => array_values($preview->cuesAt($frame))]; + } + + /** + * Starts previewing a cinematic as it stands, unsaved edits included, as + * the terminal plays it: from the map event that triggers it, else its + * start map, in an isolated scene with silent audio, the way the + * Cutscenes workspace previews it. Nothing is written. A preview already + * running ends first, unless `keep` is asked and it is of this cinematic + * as it still stands: then it carries on where it is. + * + * @return array The preview, as {@see describeCinematicPreview()}. + * @throws SessionRefusal When the cinematic is unknown or does not compile. + */ + public function startCinematicPreview(int $index, int $width, int $height, bool $play = false, bool $keep = false): array + { + $type = CutsceneType::CINEMATIC; + $id = $this->workspace->cutscenes?->ids($type)[$index] ?? null; + $asset = ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal(sprintf('There is no %s %d.', $type->noun(), $index)); + $fingerprint = hash('xxh128', serialize($asset->payload())); + if ($keep && $this->cinematicPreview !== null && $this->cinematicPreviewId === $asset->id && $this->cinematicPreviewFingerprint === $fingerprint) { + if ($this->cinematicPreview->getScreenSize() !== [max(20, $width), max(8, $height)]) { + $this->cinematicPreview->resize($width, $height); + } + return $this->describeCinematicPreview(); + } + $this->stopCinematicPreview(); + $this->closeEffectField(); + try { + $definition = $asset->cinematicDefinition(); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $origin = CinematicPreviewOrigin::locate($this->workspace, $asset, $definition->startMap); + try { + $this->cinematicPreview = CinematicPreviewSession::start($this->workspace->projectRoot, $definition, [ + 'mapId' => $origin['mapId'], 'x' => $origin['x'], 'y' => $origin['y'], 'width' => max(20, $width), 'height' => max(8, $height), + ]); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $this->cinematicPreviewId = $asset->id; + $this->cinematicPreviewFingerprint = $fingerprint; + if ($play) { + $this->cinematicPreview->play(); + } + + return $this->describeCinematicPreview(); + } + + /** + * Plays, pauses, steps, advances while playing, skips, or restarts the + * cinematic previewed, or answers what it waits on: `confirm` turns the + * dialogue's page or picks the highlighted option, `choice` moves that + * highlight by `delta`. An advance is the time since the last one, so the + * preview keeps real time however often it is asked. An accepted skip + * plays the authored finalizer, even when the preview was paused. + * + * @param 'play'|'pause'|'step'|'tick'|'skip'|'restart'|'confirm'|'choice' $action + * @return array The preview, as {@see describeCinematicPreview()}. + * @throws SessionRefusal When no cinematic is previewed, the action is unknown, or a skip is refused. + */ + public function controlCinematicPreview(string $action, float $seconds = 0.0, int $delta = 0): array + { + $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + if ($action === 'skip') { + if ($preview->skip()) { + $preview->play(); + } elseif ($preview->failure() === null) { + throw new SessionRefusal('Skip refused: ' . ($preview->skipRefusalReason() ?? 'the Engine refused the skip.')); + } + + // A finalizer failure is the preview's normal failure state, not a policy refusal. + return $this->describeCinematicPreview(); + } + match ($action) { + 'play' => $preview->play(), + 'pause' => $preview->pause(), + 'step' => $preview->step(), + 'tick' => $preview->tick(max(0.0, min($seconds, 1.0))), + 'restart' => $this->cinematicPreview = $preview->restart(), + 'confirm' => $preview->confirm(), + 'choice' => $preview->moveChoice($delta), + default => throw new SessionRefusal(sprintf('A cinematic preview cannot %s.', $action)), + }; + + return $this->describeCinematicPreview(); + } + + /** + * Ends the cinematic preview, releasing its scene. + * + * @return array{stopped: bool} Whether one was running. + */ + public function stopCinematicPreview(): array + { + $stopped = $this->cinematicPreview !== null; + $this->cinematicPreview?->dispose(); + $this->cinematicPreview = null; + $this->cinematicPreviewId = null; + $this->cinematicPreviewFingerprint = null; + + return ['stopped' => $stopped]; + } + + /** + * One exchange with the editor window's graphical view of the cinematic + * being previewed ({@see CinematicPreviewSession::exchangeScene()}): the + * window's renderer event lines in (its READY, frame acknowledgements and + * rejections, exactly as the game's renderer writes them), the frames it + * should apply out, in order, each its FRAME payload without envelope. + * + * The answer names the current epoch and grid. Feedback for another epoch, + * or without one, is discarded before it can reach the graphical host. + * + * @param list $events + * @param string|null $sessionId The epoch the window is answering; null permits initial empty discovery. + * @return array{sessionId: non-empty-string, diagnostics: list, grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} + * @throws SessionRefusal When no cinematic is previewed or a line is not a renderer event. + */ + public function exchangeCinematicScene(array $events, ?string $sessionId = null): array + { + $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + $grid = $preview->getSceneGrid(); + try { + return [ + 'sessionId' => $preview->getSceneSessionId(), + 'diagnostics' => $preview->getDiagnostics(), + 'grid' => ['columns' => $grid->columns, 'rows' => $grid->rows, 'cellWidth' => $grid->cellWidth, 'cellHeight' => $grid->cellHeight], + 'messages' => $preview->exchangeScene($events, $sessionId), + ]; + } catch (RendererProtocolException | RendererTransportException $error) { + throw new SessionRefusal('The graphical preview stopped: ' . $error->getMessage(), previous: $error); + } + } + + /** + * Ends the graphical view of the cinematic being previewed, giving the + * Terminal picture back the preview's own screen. + * + * @return array{detached: bool} + */ + public function detachCinematicScene(): array + { + $this->cinematicPreview?->detachScene(); + + return ['detached' => $this->cinematicPreview !== null]; + } + + /** + * Shows an effect, as edited, unsaved changes included, on the game's + * field at one of its frames: at the player, where a new game starts + * (System's player starting position), in an isolated field with silent + * audio, as a cinematic's field animation presents it. Nothing is + * written. The field stays open while it stands where it did, so the + * editor window's graphical view of it ({@see exchangeEffectFieldScene()}) + * carries on; a cinematic preview running ends first. + * + * @return array{sessionId: non-empty-string, diagnostics: list, frame: int, totalFrames: int, fps: int, mapId: string, lines: list, issue: string|null} + * @throws SessionRefusal When the record is not an effect or it does not compile. + */ + public function showEffectOnField(int $index, int $frame, int $width, int $height): array + { + $asset = $this->requireTimelineCutscene(CutsceneType::EFFECT->getRecordCategory(), $index); + try { + // The timeline editor's own playhead: the same frames and rate its Terminal preview steps through. + $timeline = new TimelinePreviewSession($asset->compiledEffect(EffectPresentation::TERMINAL, false), null); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $start = $this->workspace->getSystemField('startingPositions'); + $player = is_array($start) && is_array($start['player'] ?? null) ? $start['player'] : []; + $mapId = is_string($player['destinationMap'] ?? null) ? trim($player['destinationMap']) : ''; + $spawn = new Vector2(intval($player['spawnPoint']['x'] ?? 0), intval($player['spawnPoint']['y'] ?? 0)); + $key = implode(':', [$mapId, (int) $spawn->x, (int) $spawn->y, max(20, $width), max(8, $height)]); + if ($this->effectField === null || $this->effectFieldKey !== $key) { + $this->stopCinematicPreview(); + $this->closeEffectField(); + $this->effectField = PreviewField::open($this->workspace->projectRoot, $mapId, $spawn, $width, $height, + fn(): float => $this->effectFieldSeconds); + $this->effectFieldKey = $key; + } + $frame = max(0, min($frame, $timeline->totalFrames() - 1)); + $this->effectFieldSeconds = $frame / $timeline->fps(); + try { + $this->effectField->showFieldEffect(static fn(EffectPresentation $presentation): CompiledEffectTimeline + => $asset->compiledEffect($presentation, false), $frame); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be shown on the field: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + + return ['frame' => $frame, 'totalFrames' => $timeline->totalFrames(), 'fps' => $timeline->fps(), 'mapId' => $mapId, + 'sessionId' => $this->effectField->sessionId, 'diagnostics' => $this->effectField->getDiagnostics(), + 'lines' => array_values($this->effectField->frame()), 'issue' => $this->effectField->mapFailure]; + } + + /** + * One exchange with the editor window's graphical view of the field an + * effect is shown on, as {@see exchangeCinematicScene()} is for a cinematic. + * + * @param list $events + * @param string|null $sessionId The epoch the window is answering; unqualified or stale events are discarded. + * @return array{sessionId: non-empty-string, diagnostics: list, grid: array{columns: int, rows: int, cellWidth: int, cellHeight: int}, messages: list}>} + * @throws SessionRefusal When no effect is shown on the field or a line is not a renderer event. + */ + public function exchangeEffectFieldScene(array $events, ?string $sessionId = null): array + { + $field = $this->effectField ?? throw new SessionRefusal('No effect is shown on the field.'); + $grid = $field->getSceneGrid(); + try { + return [ + 'sessionId' => $field->sessionId, + 'diagnostics' => $field->getDiagnostics(), + 'grid' => ['columns' => $grid->columns, 'rows' => $grid->rows, 'cellWidth' => $grid->cellWidth, 'cellHeight' => $grid->cellHeight], + 'messages' => $field->exchangeScene($events, $sessionId), + ]; + } catch (RendererProtocolException | RendererTransportException $error) { + throw new SessionRefusal('The graphical preview stopped: ' . $error->getMessage(), previous: $error); + } + } + + /** + * Closes the field an effect is shown on, with its graphical view. + * + * @return array{closed: bool} Whether one was open. + */ + public function closeEffectField(): array + { + $closed = $this->effectField !== null; + $this->effectField?->dispose(); + $this->effectField = null; + $this->effectFieldKey = null; + + return ['closed' => $closed]; + } + + /** + * A terminal picture as a renderer paints it: the Engine's styled runs on the + * picture's own grid, its colours kept and its control codes never counted as + * text. Cells take the share of the Engine's canvas the battle arena's do. + * + * @param list $lines + * @return array + */ + private function describeTerminalCanvas(array $lines, int $columns, int $rows): array + { + $grid = new RendererGridConfig($columns, $rows, max(1, intdiv(PresentationCanvas::DEFAULT_WIDTH, $columns)), + max(1, intdiv(PresentationCanvas::DEFAULT_HEIGHT, $rows))); + + return ($this->terminalComposer ??= new TerminalPresentationComposer())->createCanvasFromLines($lines, $grid)->toArray(); + } + + /** + * The cinematic previewed: which one, its terminal picture now, how long + * it has run, its status, whether it plays or has finished, and why it + * stopped or waits, when it does. + * + * @return array + */ + private function describeCinematicPreview(): array + { + $preview = $this->cinematicPreview ?? throw new SessionRefusal('No cinematic is being previewed.'); + + $lines = array_values($preview->frame()); + [$columns, $rows] = $preview->getScreenSize(); + + return [ + 'id' => $this->cinematicPreviewId, + 'sessionId' => $preview->getSceneSessionId(), + 'diagnostics' => $preview->getDiagnostics(), + 'lines' => $lines, + 'terminalCanvas' => $this->describeTerminalCanvas($lines, $columns, $rows), + 'elapsed' => $preview->elapsed(), + 'status' => $preview->status(), + 'playing' => $preview->isPlaying(), + 'finished' => $preview->isFinished(), + 'failure' => $preview->failure()['message'] ?? null, + 'wait' => $preview->waitDescription(), + 'waitingFor' => $preview->getWaitKind(), + ]; + } + /** + * A summon as the battle test plays it, at one command frame: the + * Engine's command preview of the summon as it stands, unsaved edits + * included, cast by the first member of the battle test party (else the + * starting party) its wielder policy allows, at the targets its linked + * action takes in the battle test troop (else the first troop), over the + * battle test arena, with the project's battle pacing, in one + * presentation: graphical, its canvas with the terminal arena's lines + * from the paired lane, or terminal alone, at the terminal sequence's own + * cadence, reading no graphical image. A frame of the summon's own + * timeline may be asked for instead of a command frame; each frame says + * which of the summon's frames it draws, so the timeline and the battle + * share one playhead. Enemies and the party are read as + * saved; nothing is resolved, spent or played aloud, and nothing is + * written. Seeking reuses the preview until the summon or its battle + * changes. + * + * @return array + * @throws SessionRefusal When the summon does not compile, the battle cannot be set up or the frame is outside the command. + */ + public function readSummonBattlePreview(int $index, int $frame, bool $reducedMotion = false, + EffectPresentation $presentation = EffectPresentation::GRAPHICAL, ?int $authoredFrame = null): array + { + $asset = $this->requireTimelineCutscene(CutsceneType::SUMMON->getRecordCategory(), $index); + $graphic = $presentation === EffectPresentation::GRAPHICAL; + try { + $definition = $asset->summonDefinition(); + // The terminal arena plays the terminal compile alone, which needs no graphical image. + $graphical = $graphic ? $asset->compiledSummon(EffectPresentation::GRAPHICAL) : null; + $terminal = $asset->compiledSummon(EffectPresentation::TERMINAL); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s does not compile: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + $entry = $this->readBattleTestEntry(); + $troop = $this->readPreviewTroop($entry); + $config = $this->workspace->config?->getRecord('ui.battle')->get('value'); + $battleUi = is_array($config) ? $config : []; + $key = hash('xxh128', serialize([$asset->id, $definition->toDataArray(), $graphical?->toArray(), $terminal->toArray(), + $entry, $this->readStartingPartyReferences(), $troop, $battleUi, $graphic ? $this->readProposedBattlerBindings() : null])); + if (($this->summonBattlePreviews[$presentation->value]['key'] ?? null) !== $key) { + // The terminal arena needs no graphical layout, and reads no image. + $catalog = $graphic ? $this->requireBattleLayoutCatalog('preview summons on') : null; + $this->summonBattlePreviews[$presentation->value] = ['key' => $key, 'preview' => ProjectDirectoryContext::run($this->workspace->projectRoot, + fn(): SummonBattlePreview => $this->createSummonBattlePreview($definition, $graphical, $terminal, $entry, $troop, + BattlePacing::fromBattleUiConfig($battleUi), $catalog, $presentation))]; + } + $preview = $this->summonBattlePreviews[$presentation->value]['preview']; + // A frame of the summon's own timeline is drawn where the battle plays it. + $frame = $authoredFrame === null ? $frame : $preview->findCommandFrame($authoredFrame); + try { + $read = $preview->readFrame(max(0, min($frame, $preview->totalFrames - 1)), $reducedMotion); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + + return [...$read, 'totalFrames' => $preview->totalFrames, 'fps' => BattleCommandTimeline::FPS, + 'assetRoot' => $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', + 'caster' => $preview->caster, 'targets' => $preview->targets, 'troop' => $troop['name'], 'phases' => $preview->phases]; + } + + /** + * The battle a summon preview plays in, built the way the battle test + * builds it, and the summon cast in it. + * + * @param array $entry The battle test as system data holds it. + * @param array{name: string, data: array} $troop + * @throws SessionRefusal When the battle cannot be set up or the summon cannot be cast in it. + */ + private function createSummonBattlePreview(SummonCutsceneDefinition $definition, ?SummonCompiledCutscene $graphical, + SummonCompiledCutscene $terminal, array $entry, array $troop, BattlePacing $pacing, ?BattlePresentationCatalog $catalog, + EffectPresentation $presentation): SummonBattlePreview + { + try { + [$battle, $skills] = $this->createPreviewBattle($entry, $troop); + + return SummonBattlePreview::create($definition, $graphical, $terminal, $battle, $skills, $pacing, $catalog, + $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', $presentation); + } catch (SessionRefusal $refusal) { + throw $refusal; + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed in battle: %s', $definition->name, $failure->getMessage()), previous: $failure); + } + } + + /** + * A battle effect as a battle plays it, at one command frame, in one + * presentation: in an action of the battle test party that the Engine's + * own animation selection plays it in (its attacks, skills, magic and + * items; a summon is previewed as a summon), with the animation's other + * effect beside it, the action's pose and the project's battle pacing. + * The effects are compiled as they stand, unsaved edits included, and the + * animations are read as they stand too. Every action that plays the + * effect is listed, so another can be chosen; a frame of the effect's own + * timeline may be asked for instead of a command frame. Nothing is + * resolved, spent or played aloud, and nothing is written. + * + * @return array + * @throws SessionRefusal When the effect does not compile, no action of the party plays it, or the battle cannot be set up. + */ + public function readEffectBattlePreview(int $index, int $frame, bool $reducedMotion = false, + EffectPresentation $presentation = EffectPresentation::GRAPHICAL, ?int $authoredFrame = null, int $binding = 0): array + { + $asset = $this->requireTimelineCutscene(CutsceneType::EFFECT->getRecordCategory(), $index); + $effects = $this->workspace->cutscenes?->assets(CutsceneType::EFFECT) ?? []; + $animations = array_values(array_map(static fn($record): mixed => $record->toArray(), $this->requireRecordDatabase('animations')->getRecords())); + $entry = $this->readBattleTestEntry(); + $troop = $this->readPreviewTroop($entry); + $config = $this->workspace->config?->getRecord('ui.battle')->get('value'); + $battleUi = is_array($config) ? $config : []; + $graphic = $presentation === EffectPresentation::GRAPHICAL; + $key = hash('xxh128', serialize([$asset->id, array_map(static fn(CutsceneAsset $effect): array => $effect->payload(), $effects), $animations, + $entry, $this->readStartingPartyReferences(), $troop, $battleUi, $binding, $graphic ? $this->readProposedBattlerBindings() : null])); + if (($this->effectBattlePreviews[$presentation->value]['key'] ?? null) !== $key) { + $catalog = $graphic ? $this->requireBattleLayoutCatalog('preview effects on') : null; + $this->effectBattlePreviews[$presentation->value] = ['key' => $key, 'preview' => ProjectDirectoryContext::run($this->workspace->projectRoot, + fn(): EffectBattlePreview => $this->createEffectBattlePreview($asset->id, $animations, $entry, $troop, + BattlePacing::fromBattleUiConfig($battleUi), $catalog, $presentation, $binding))]; + } + $preview = $this->effectBattlePreviews[$presentation->value]['preview']; + $frame = $authoredFrame === null ? $frame : $preview->findCommandFrame($authoredFrame); + try { + $read = $preview->readFrame(max(0, min($frame, $preview->totalFrames - 1)), $reducedMotion); + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed: %s', $asset->id, $failure->getMessage()), previous: $failure); + } + + return [...$read, 'totalFrames' => $preview->totalFrames, 'fps' => BattleCommandTimeline::FPS, + 'assetRoot' => $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', 'lane' => $preview->lane, + 'contexts' => $preview->contexts, 'binding' => $preview->binding, + 'caster' => $preview->caster, 'targets' => $preview->targets, 'troop' => $troop['name'], 'phases' => $preview->phases]; + } + + /** + * The battle an effect preview plays in, and the effect played in it. + * + * @param list $animations The animation records as they stand. + * @param array $entry The battle test as system data holds it. + * @param array{name: string, data: array} $troop + * @throws SessionRefusal When the battle cannot be set up or no action of it plays the effect. + */ + private function createEffectBattlePreview(string $effectId, array $animations, array $entry, array $troop, BattlePacing $pacing, + ?BattlePresentationCatalog $catalog, EffectPresentation $presentation, int $binding): EffectBattlePreview + { + // The animations as authored now; an entry the Engine would not read plays nothing, as in battle. + $library = AnimationLibrary::createFromAnimations(array_values(array_filter(array_map(static function (mixed $data): ?Animation { + try { + return is_array($data) ? Animation::fromArray($data) : null; + } catch (Throwable) { + return null; + } + }, $animations)))); + $compile = function (string $id, EffectPresentation $presentation): CompiledEffectTimeline { + $effect = $this->workspace->cutscenes?->find(CutsceneType::EFFECT, $id) + ?? throw new SessionRefusal(sprintf('The animation plays the effect %s, which does not exist.', $id)); + + return $effect->compiledEffect($presentation, true); + }; + try { + [$battle, $skills] = $this->createPreviewBattle($entry, $troop); + + return EffectBattlePreview::create($effectId, $battle, $skills, $library, $pacing, $catalog, + $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets', $presentation, $compile, $binding); + } catch (SessionRefusal $refusal) { + throw $refusal; + } catch (Throwable $failure) { + throw new SessionRefusal(sprintf('%s cannot be previewed in battle: %s', $effectId, $failure->getMessage()), previous: $failure); + } + } + /** + * The battle a preview plays in, built the way the battle test builds it: + * its party, or the starting party, against its troop, with the skills + * they know. + * + * @param array $entry The battle test as system data holds it. + * @param array{name: string, data: array} $troop + * @return array{0: BattleConfig, 1: SkillCatalog} + */ + private function createPreviewBattle(array $entry, array $troop): array + { + $actors = $this->workspace->actorDatabase->createActorStore(); + $skills = $this->workspace->loadSkillCatalog(); + $setup = ProjectBattleTest::fromArray($entry)->createSetup($actors, $this->readStartingPartyReferences()); + $party = $setup->createParty($actors, $this->loadItemStore(), $skills); + $previousEnemies = ConfigStore::has(EnemyStore::class) ? ConfigStore::get(EnemyStore::class) : null; + try { + ConfigStore::put(EnemyStore::class, new EnemyStore()); + + return [new BattleConfig($party, Troop::fromArray($troop['data']), settings: $setup->getBattleSettings()), $skills]; + } finally { + $previousEnemies === null ? ConfigStore::remove(EnemyStore::class) : ConfigStore::put(EnemyStore::class, $previousEnemies); + } + } + + /** + * The troop a summon preview is cast at: the battle test's, as the + * battle test fights it, else the project's first; as it stands now. + * + * @param array $entry The battle test as system data holds it. + * @return array{name: string, data: array} + * @throws SessionRefusal When the project has no troops, or the battle test names one it does not have. + */ + private function readPreviewTroop(array $entry): array + { + $named = is_string($entry['troop'] ?? null) ? trim($entry['troop']) : null; + foreach ($this->requireRecordDatabase('troops')->getRecords() as $record) { + $data = $record->toArray(); + $name = is_array($data) ? trim(strval($data['name'] ?? '')) : ''; + if ($name !== '' && ($named === null || $named === $name)) { + return ['name' => $name, 'data' => $data]; + } + } + + throw new SessionRefusal($named === null ? 'This project has no troop to cast a summon at.' + : sprintf('The battle test fights %s, which no troop is named.', $named)); + } + /** + * An image track's art with the image's own size, read from the file the + * track names inside the asset root; none for a file that is missing or + * not an image, which the track's own validation reports. + * + * @param array $art + * @return array + */ + private function measureTrackArt(array $art): array + { + $root = realpath($this->workspace->projectRoot . '/assets'); + $path = $root === false || ($art['asset'] ?? '') === '' ? false : realpath($root . '/' . $art['asset']); + $size = $path !== false && str_starts_with($path, $root . DIRECTORY_SEPARATOR) ? @getimagesize($path) : false; + + return [...$art, 'width' => $size === false ? null : $size[0], 'height' => $size === false ? null : $size[1]]; + } + + /** + * Chooses which of an effect's or summon's sequences its record shows and + * edits, terminal or graphical, as the TUI's sequence switch does. + * Nothing is written; the other sequence stays as it is. + * + * @return array{presentation: string} + * @throws SessionRefusal When the record has no separate sequences, or the sequence is unknown. + */ + public function selectCutscenePresentation(string $category, int $index, string $presentation): array + { + $asset = $this->requireTimelineCutscene($category, $index); + $chosen = EffectPresentation::tryFrom($presentation) + ?? throw new SessionRefusal(sprintf('A sequence is terminal or graphical, not %s.', $presentation)); + if (! $asset->hasPresentations()) { + throw new SessionRefusal(sprintf('%s has one sequence for every renderer.', $asset->id)); + } + $asset->selectPresentation($chosen); + $this->workspace->cutscenes?->refreshRecords($asset->type); + + return ['presentation' => $chosen->value]; + } + + /** + * Gives a summon or effect with one sequence for every renderer a + * terminal and a graphical sequence, each a copy of it, as one undo step, + * as the TUI's sequence choice does; the record then edits the terminal + * one. It reaches disk on save. + * + * @return array{changed: bool, presentation: string} + * @throws SessionRefusal When the record already has them or cannot be written. + */ + public function separateCutsceneSequences(string $category, int $index): array + { + $asset = $this->requireTimelineCutscene($category, $index); + if ($asset->hasPresentations()) { + throw new SessionRefusal(sprintf('%s already has a terminal and a graphical sequence.', $asset->id)); + } + try { + $command = $this->workspace->cutscenes?->changeAsset($asset->type, $index, sprintf('Separate %s sequences', $asset->type->noun()), + static function () use ($asset): void { + $asset->selectPresentation(EffectPresentation::TERMINAL); + $asset->splitIntoPresentations(); + })['command']; + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['changed' => $command !== null, 'presentation' => EffectPresentation::TERMINAL->value]; + } + + /** + * Gives a summon's graphical sequence a cinematic stage, or takes it + * away, as one undo step, as the TUI's Stage row does. It reaches disk + * on save. + * + * @return array{changed: bool, stage: bool} + * @throws SessionRefusal When the graphical sequence cannot safely own or release a stage. + */ + public function setCutsceneStage(string $category, int $index, bool $present): array + { + $asset = $this->requireTimelineCutscene($category, $index); + if ($asset->type === CutsceneType::SUMMON && ! $asset->hasPresentations()) { + throw new SessionRefusal(sprintf('A stage is a summon\'s graphical sequence\'s own; separate %s\'s sequences first.', $asset->id)); + } + try { + $command = $this->workspace->cutscenes?->changeAsset($asset->type, $index, $present ? 'Add stage' : 'Remove stage', + static function () use ($asset, $present): void { + $asset->setStage($present); + })['command']; + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($present && ! $asset->hasStage()) { + throw new SessionRefusal(sprintf('%s needs at least two frames to restore its stage before it ends.', $asset->id)); + } + if ($command !== null) { + $this->history->record($command); + } + + return ['changed' => $command !== null, 'stage' => $asset->hasStage()]; + } + + /** A summon or effect record's asset; a cinematic is a command tree, not a timeline. */ + private function requireTimelineCutscene(string $category, int $index): CutsceneAsset + { + $type = CutsceneType::findByRecordCategory($category); + if ($type === null || $type === CutsceneType::CINEMATIC) { + throw new SessionRefusal(sprintf('%s is not a summon or effect category.', $category)); + } + $id = $this->workspace->cutscenes?->ids($type)[$index] ?? null; + + return ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal(sprintf('There is no %s %d.', $type->noun(), $index)); + } + + /** + * The choices a reference row offers, read in the context it is edited + * in: the project's own references need no map, a map's own (its events, + * regions) are read from the map named, and a cutscene's own (a summon's + * cues) from the cutscene record named, as `{category, index}`. + * + * @param array{category?: mixed, index?: mixed, kind?: mixed, marker?: mixed, frame?: mixed, path?: mixed}|null $record + * Database {category,index,frame}, NPC {kind:'npc',index,frame}, or event {kind:'event',marker,path} for a + * row of its data, {kind:'event',marker,frame} for one of its record rows. + * @return list + * @throws SessionRefusal When the category, map or record is unknown. + */ + public function listReferences(?string $mapId, string $category, ?array $record = null): array + { + if (! ReferenceCatalog::knows($category)) { + throw new SessionRefusal(sprintf('There is no reference category %s.', $category)); + } + $cutscene = null; + $type = is_string($record['category'] ?? null) ? CutsceneType::findByRecordCategory($record['category']) : null; + if ($type !== null) { + $id = $this->workspace->cutscenes?->ids($type)[(int) ($record['index'] ?? -1)] ?? null; + $cutscene = ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal(sprintf('There is no %s %s.', $type->noun(), strval($record['index'] ?? ''))); + } + $catalog = new ReferenceCatalog($this->workspace, $mapId === null ? null : $this->requireMap($mapId), $cutscene); + if ($category === 'world_object_tile_layers') { + if ($mapId === null || !is_string($record['id'] ?? null)) { throw new SessionRefusal('A covered tile-layer picker needs its map-local object owner.'); } + $map = $this->requireMap($mapId); + $object = array_find(WorldObjectAuthoring::readEntries($map), static fn($entry): bool => $entry['id'] === $record['id']) + ?? throw new SessionRefusal('That world-object owner no longer exists.'); + return array_map(static fn($layer): array => ['value' => $layer['name'], 'label' => $layer['name']], + array_values(array_filter($map->describeTileLayers()['layers'], static fn($layer): bool => $layer['owner'] === ($object['covers']['layer'] ?? null)))); + } + if ($category === 'map_event_markers') { + // The events of the map a map event condition names. + if (!is_string($record['map'] ?? null)) { return []; } + $markers = $catalog->listMapEventMarkers($record['map']); + return array_map(static fn(string $marker): array => ['value' => $marker, 'label' => $markers['labels'][$marker]], $markers['values']); + } + if ($category === 'cinematic_movement_routes' && $record !== null) { + $catalog = $catalog->createMovementContext($this->getMovementOwnerCommands($mapId, $record)); + } + $labels = $catalog->labelsFor($category); + + return array_map(static fn(mixed $value): array => [ + 'value' => (string) $value, + 'label' => (string) ($labels[$value] ?? $value), + ], array_values($catalog->valuesFor($category))); + } + + /** Resolve only the real source owner supplied by the row, never the current selection. */ + private function getMovementOwnerCommands(?string $mapId, array $owner): array + { + if (($owner['kind'] ?? '') === 'event') { + $map = $this->requireMap($mapId ?? throw new SessionRefusal('An event route reference requires its map.')); + $marker = is_string($owner['marker'] ?? null) ? $owner['marker'] : ''; + $event = $map->getEventDefinition($marker) + ?? throw new SessionRefusal('That event route owner is no longer present.'); + if (array_key_exists('frame', $owner)) { + // A row of the event's record rows names its frame, as an NPC's does. + $records = $this->createMapInspector($map)->createEventRecordPane($marker, $event)->records(); + $frame = self::requireFrame($owner['frame'], 'references.list'); + if ($frame !== [] && $records->getFrameCommands(0, $frame) === null) { + throw new SessionRefusal('That route command frame is no longer present.'); + } + return MovementRouteFields::getOwnerCommands((array) ($records->getRecordByIndex(0)?->toArray() ?? []), + MovementRouteFields::getFrameOwnerPath($records->schema, $frame)); + } + $path = self::requireFrame($owner['path'] ?? [], 'references.list'); + return MovementRouteFields::getOwnerCommands($event, $path); + } + if (($owner['kind'] ?? '') === 'npc') { + $map = $this->requireMap($mapId ?? throw new SessionRefusal('An NPC route reference requires its map.')); + $database = (new NpcInspector($map, graphical: true))->records(); + } else { + $category = is_string($owner['category'] ?? null) ? $owner['category'] : ''; + $database = $this->requireRecordDatabase($category); + } + $index = filter_var($owner['index'] ?? null, FILTER_VALIDATE_INT); + $record = $index === false || $index === null ? null : $database->getRecordByIndex($index); + if ($record === null) { throw new SessionRefusal('That route command owner is no longer present.'); } + $frame = self::requireFrame($owner['frame'] ?? [], 'references.list'); + if ($frame !== [] && $database->getFrameCommands($index, $frame) === null) { + throw new SessionRefusal('That route command frame is no longer present.'); + } + return MovementRouteFields::getOwnerCommands((array) $record->toArray(), + MovementRouteFields::getFrameOwnerPath($database->schema, $frame)); + } + + /** + * A troop's graphical formation as an arranger draws it, composed by the + * Engine's BattleFormationLayout, the placement the battle itself uses: + * the battle canvas, the project's arenas with the one previewed and its + * backgrounds, the ground battlers stand on there (its edges, perspective + * and how much each side's depth is compressed to fit it), the starting + * party in its slots, and each member's enemy, authored placement (null + * when the battle arranges it), the place it stands at, where its feet + * stand on this arena, the bounds its idle art is drawn in, that art, and + * its body span at battle scale. The arena is a preview only, as in RPG + * Maker's Troops tab: a troop is placed once for every arena, standing on + * each arena's own ground, and choosing one writes nothing. With + * `$moving` (a member, a column and a depth) the formation is composed + * as moving that member there would leave it ({@see placeTroopMember()}), + * so a drag shows the battle's own picture as it goes; nothing is written. + * + * @param array{0: int, 1: float, 2: float}|null $moving + * @return array + * @throws SessionRefusal When the troop is unknown, the project has no graphical battle, or the move is refused. + */ + public function readTroopFormation(int $index, ?string $arena = null, ?array $moving = null): array + { + $catalog = $this->requireBattleLayoutCatalog('arrange troops on'); + ['members' => $members, 'places' => $places] = $this->readTroopPlaces($index); + if ($moving !== null) { + $places = $this->getMovedPlaces($index, ...$moving); + } + $enemies = array_map(static fn(array $member, ?BattleStageSlot $place): array => ['enemyId' => $member['enemy'], 'slot' => $place], + $members, $places); + [$formation, $view, $clearance] = $this->composeFormation($catalog, $arena, $enemies); + foreach ($formation->enemies as $position => $battler) { + $place = $formation->staging->enemyPlaces[$position]; + $members[$position]['place'] = ['x' => $place->x, 'depth' => $place->depth]; + $members[$position]['battler'] = self::describeFormationBattler($battler, $clearance['enemies'][$position]); + } + + return [...$view, 'members' => $members]; + } + + /** + * A battle arena as its page shows it: its background on the battle + * canvas with the ground it declares (or inherits) drawn by its edges, + * the starting party standing on it, and a troop beside them, all as + * the Engine stands them, so an author marks the ground by its picture. + * The troop is the one chosen, else the first with members. It reads + * the arena as it is now, unsaved edits included. `$ground` holds ground + * fields and `$footing` footing polygons (by habitat, `land` or `water`) + * as they stand while being dragged, laid over the arena's own and read + * by the Engine as authored ones are, so a drag shows the battle's + * picture as it goes; nothing is written. + * + * @param array $ground + * @param array $footing + * @return array + * @throws SessionRefusal When the arena or troop is unknown, the project has no graphical battle, or the ground or footing is refused. + */ + public function readArenaPreview(int $index, ?int $troop = null, array $ground = [], array $footing = []): array + { + $record = $this->requireRecordDatabase(BattleArenaFields::CATEGORY)->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('%s has no record %d.', BattleArenaFields::CATEGORY, $index)); + $catalog = $this->requireBattleLayoutCatalog('preview arenas on', $ground === [] && $footing === [] ? [] + : [(string) $record->get('id') => ['ground' => $ground, 'footing' => $footing]]); + $troops = $this->requireRecordDatabase('troops'); + $troop ??= array_key_first(array_filter($troops->getRecords(), static fn($entry): bool => $entry->getSubList('enemies') !== [])); + ['members' => $members, 'places' => $places] = $troop === null ? ['members' => [], 'places' => []] : $this->readTroopPlaces($troop); + $enemies = array_map(static fn(array $member, ?BattleStageSlot $place): array => ['enemyId' => $member['enemy'], 'slot' => $place], + $members, $places); + [$formation, $view, $clearance] = $this->composeFormation($catalog, (string) $record->get('id'), $enemies); + foreach ($formation->enemies as $position => $battler) { + $members[$position]['battler'] = self::describeFormationBattler($battler, $clearance['enemies'][$position]); + } + $troopNames = array_map(static fn($entry): string => (string) $entry->get('name'), $troops->getRecords()); + + return [...$view, 'members' => $members, 'troop' => $troop, 'troops' => array_values($troopNames)]; + } + + /** + * Replaces a battle arena's land or water footing, as one undo step: the + * polygon battlers of that habitat may plant their feet in, as canvas + * points in order. No points removes it. + * + * @param list $points + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the arena is unknown or read-only, or the polygon is refused. + */ + public function setArenaFooting(int $index, EnemyHabitat $habitat, array $points): array + { + $arenas = $this->requireRecordDatabase(BattleArenaFields::CATEGORY); + $change = $this->changeRecord(static fn(): RecordChange => BattleArenaFields::setFooting($arenas, $index, $habitat, $points, + sprintf('Set %s footing', $habitat->value))); + + return ['changed' => $change->command !== null, 'records' => $arenas->getEntryLabels()]; + } + + /** + * Places every member of a troop where the Engine arranges it, written as + * each member's placement, as one undo step: RPG Maker's Align. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the troop is unknown or read-only, or the project has no graphical battle. + */ + public function alignTroop(int $index): array + { + $places = array_map(static fn(BattleStageSlot $place): array => $place->toArray(), $this->arrangeTroop($index, true)); + + return $this->placeTroopMembers($index, $places, 'Align members'); + } + + /** + * Removes every member's placement, as one undo step, so the battle + * arranges the troop itself from the current art, on every arena. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the troop is unknown or read-only. + */ + public function arrangeTroopAutomatically(int $index): array + { + $count = count($this->readTroopPlaces($index)['members']); + + return $this->placeTroopMembers($index, array_fill(0, $count, null), 'Arrange members automatically'); + } + + /** + * Moves one member to a place, as one undo step. Every other member is + * placed where it stands now, so one the battle arranged does not move + * when this one does. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the troop or member is unknown, the troop is read-only, or the place is refused. + */ + public function placeTroopMember(int $index, int $member, float $x, float $depth): array + { + return $this->placeTroopMembers($index, array_map(static fn(BattleStageSlot $place): array => $place->toArray(), + $this->getMovedPlaces($index, $member, $x, $depth)), 'Move member'); + } + + /** + * Each member's place once one member moves to a column and depth: every + * other member where it stands now, so one the battle arranged stays put. + * + * @return list + * @throws SessionRefusal When the troop or member is unknown, or the place is refused. + */ + private function getMovedPlaces(int $index, int $member, float $x, float $depth): array + { + $places = $this->arrangeTroop($index, false); + $moved = $places[$member] ?? throw new SessionRefusal(sprintf('The troop has no member %d.', $member + 1)); + try { + $places[$member] = new BattleStageSlot($x, $depth, $moved->width, $moved->height, $moved->displayScale); + } catch (\InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + return $places; + } + + /** + * Each member of a troop with the place the battle stands it at: its own + * placement, or the Engine's arrangement beside the placed ones; with + * `$all`, the Engine's arrangement for every member. + * + * @return list + * @throws SessionRefusal When the troop is unknown or the project has no graphical battle. + */ + private function arrangeTroop(int $index, bool $all): array + { + $catalog = $this->requireBattleLayoutCatalog('arrange troops on'); + ['members' => $members, 'places' => $places] = $this->readTroopPlaces($index); + try { + return BattleFormationLayout::arrangeTroop($catalog, array_column($members, 'enemy'), $all ? [] : $places, + $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } catch (\InvalidArgumentException|\RuntimeException $error) { + throw new SessionRefusal('The troop cannot be arranged: ' . $error->getMessage(), previous: $error); + } + } + + /** + * @param list> $places + * @return array{changed: bool, records: list} + */ + private function placeTroopMembers(int $index, array $places, string $label): array + { + $troops = $this->requireRecordDatabase('troops'); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->placeMembers($troops, $index, $places, $label)); + + return ['changed' => $change->command !== null, 'records' => $troops->getEntryLabels()]; + } + + /** + * A troop's members as authored (enemy, authored placement or null, and + * why a placement cannot be read) and the place each stands at when it + * is readable, null for the battle to arrange. + * + * @return array{members: list>, places: list} + * @throws SessionRefusal When the troop is unknown. + */ + private function readTroopPlaces(int $index): array + { + $record = $this->requireRecordDatabase('troops')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('troops has no record %d.', $index)); + $members = $places = []; + foreach ($record->getSubList('enemies') as $entry) { + $enemy = is_array($entry) ? (string) ($entry['enemy'] ?? '') : ''; + $placement = is_array($entry) && is_array($entry['graphicalPlacement'] ?? null) ? $entry['graphicalPlacement'] : null; + $member = ['enemy' => $enemy, 'placement' => $placement, 'place' => null, 'battler' => null]; + $place = null; + if ($placement !== null) { + try { + $place = BattleStageSlot::fromArray($placement, 'Battle Placement'); + } catch (\InvalidArgumentException $error) { + $member['issue'] = $error->getMessage(); + } + } + $members[] = $member; + $places[] = $place; + } + + return ['members' => $members, 'places' => $places]; + } + + /** + * A troop as its Troops page shows it, the way RPG Maker's Troops tab + * does: its members with the enemy each fights, its terminal position + * and its battle placement; the project's enemies a member can be added + * from; and its graphical formation over a previewed arena + * ({@see readTroopFormation()}), or why there is none, so a game with + * only a terminal battle still composes its troops here. Align places + * every member where the Engine's arrangement stands it ({@see alignTroop()}). + * A member being dragged (`$moving`) is drawn where it would stand, unwritten. + * + * @param array{0: int, 1: float, 2: float}|null $moving + * @return array{members: list>, enemies: list, formation: ?array, formationIssue: ?string, align: array{available: bool, reason: ?string}} + * @throws SessionRefusal When the troop is unknown. + */ + public function readTroopPage(int $index, ?string $arena = null, ?array $moving = null): array + { + $record = $this->requireRecordDatabase('troops')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('troops has no record %d.', $index)); + $members = []; + foreach ($record->getSubList(TroopAuthoring::MEMBERS) as $entry) { + $position = is_array($entry['position'] ?? null) && array_is_list($entry['position']) ? array_values($entry['position']) : null; + $members[] = [ + 'enemy' => strval($entry['enemy'] ?? ''), + 'position' => $position, + 'placement' => is_array($entry['graphicalPlacement'] ?? null) ? $entry['graphicalPlacement'] : null, + ]; + } + $page = [ + 'members' => $members, + 'enemies' => $this->listEnemyNames(), + 'formation' => null, + 'formationIssue' => null, + 'align' => ['available' => true, 'reason' => null], + ]; + try { + $page['formation'] = $this->readTroopFormation($index, $arena, $moving); + } catch (SessionRefusal $refusal) { + $page['formationIssue'] = $refusal->getMessage(); + } + + return $page; + } + + /** + * Adds a member fighting one of the project's enemies to a troop, as one + * undo step ({@see TroopAuthoring::addMember()}): on the terminal field + * where the Engine stands a new member beside those there, by their + * sprites as the terminal battle draws them; in the graphical battle + * arranged beside the placed members until it is placed. + * + * @return array{changed: bool, member: int, records: list} + * @throws SessionRefusal When the troop is unknown or read-only, or the project has no such enemy. + */ + public function addTroopMember(int $index, string $enemy): array + { + $troops = $this->requireRecordDatabase('troops'); + $enemies = $this->listEnemyNames(); + $sprites = new EnemySprites($this->workspace->projectRoot)->readByEnemy($this->workspace->getRecordDatabase('enemies')); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->addMember($troops, $index, $enemy, $enemies, $sprites)); + + return ['changed' => true, 'member' => (int) $change->index, 'records' => $troops->getEntryLabels()]; + } + + /** + * Removes one member of a troop, as one undo step. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the troop or member is unknown, or the troop is read-only. + */ + public function removeTroopMember(int $index, int $member): array + { + $troops = $this->requireRecordDatabase('troops'); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->removeMember($troops, $index, $member)); + + return ['changed' => $change->command !== null, 'records' => $troops->getEntryLabels()]; + } + + /** + * Removes every member of a troop, as one undo step. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the troop is unknown or read-only. + */ + public function clearTroopMembers(int $index): array + { + $troops = $this->requireRecordDatabase('troops'); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->clearMembers($troops, $index)); + + return ['changed' => $change->command !== null, 'records' => $troops->getEntryLabels()]; + } + + /** + * Names a troop after its members, as one undo step. + * + * @return array{changed: bool, name: string, records: list, note?: string} + * @throws SessionRefusal When the troop is unknown or read-only, has no member, or the name is refused. + */ + public function nameTroopAfterMembers(int $index): array + { + $troops = $this->requireRecordDatabase('troops'); + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->nameAfterMembers($troops, $index)); + $record = $troops->getRecordByIndex($index); + + return array_filter([ + 'changed' => $change->command !== null, + 'name' => $record === null ? '' : $troops->getEntryLabel($record), + 'records' => $troops->getEntryLabels(), + 'note' => $change->note, + ], static fn(mixed $value): bool => $value !== null); + } + + /** @return list The project's enemies, by the name a troop member names them by. */ + private function listEnemyNames(): array + { + return array_values(array_map(strval(...), new ReferenceCatalog($this->workspace)->valuesFor('enemies'))); + } + + /** + * An enemy as Database > Enemies shows it: its terminal sprite, read the + * way the game reads it, and its battle art composed by the Engine's + * BattleFormationLayout at battle scale beside the starting party, standing + * where the battle arranges a troop of this enemy alone on the previewed + * arena's ground, with what it does not clear. Both read the record as it + * is now, unsaved edits included. A project without a graphical battle still gets the sprite, + * with the reason there is no art. + * + * @return array + * @throws SessionRefusal When the enemy is unknown. + */ + public function readEnemyPreview(int $index, ?string $arena = null): array + { + $record = $this->requireRecordDatabase('enemies')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('enemies has no record %d.', $index)); + $name = trim((string) $record->get('name')); + $preview = ['name' => $name, 'sprite' => $this->readEnemySprite($record->get('imagePath')), 'formation' => null, 'formationIssue' => null]; + + try { + $catalog = $this->requireBattleLayoutCatalog('preview enemies at battle scale on'); + // Where the battle stands a troop of this enemy alone, on the previewed arena's ground. + $composed = $this->composeFormation($catalog, $arena, [['enemyId' => $name, 'slot' => null]]); + [$formation, $view, $clearance] = $composed; + $preview['formation'] = [...$view, 'members' => [[ + 'enemy' => $name, + 'placement' => null, + 'place' => ['x' => $formation->staging->enemyPlaces[0]->x, 'depth' => $formation->staging->enemyPlaces[0]->depth], + 'battler' => self::describeFormationBattler($formation->enemies[0], $clearance['enemies'][0]), + ]]]; + } catch (SessionRefusal $refusal) { + $preview['formationIssue'] = $refusal->getMessage(); + } + + return $preview; + } + + /** + * An actor as Database > Actors shows it in battle: its art composed by + * the Engine's BattleFormationLayout at battle scale in the lead party + * slot, the rest of the starting party beside it for comparison. It reads + * the art as it is now, unsaved edits included. An actor without a + * stable id has no battle art to bind, and says so. + * + * @return array{name: string, identity: ?string, formation: ?array, formationIssue: ?string} + * @throws SessionRefusal When the actor is unknown. + */ + public function readActorPreview(int $index, ?string $arena = null): array + { + $actor = array_values($this->workspace->actorDatabase->getActors())[$index] + ?? throw new SessionRefusal(sprintf('actors has no record %d.', $index)); + $identity = $actor->hasDefinitionId() ? $actor->getDefinitionId() : null; + $preview = ['name' => $actor->getName(), 'identity' => $identity, 'formation' => null, 'formationIssue' => null]; + if ($identity === null) { + $preview['formationIssue'] = 'This actor has no stable id yet, which its battle art is bound to. Repair actor identities first.'; + + return $preview; + } + try { + [, $view] = $this->composeFormation($this->requireBattleLayoutCatalog('preview actors at battle scale on'), $arena, [], $identity); + $preview['formation'] = [...$view, 'members' => []]; + } catch (SessionRefusal $refusal) { + $preview['formationIssue'] = $refusal->getMessage(); + } + + return $preview; + } + + /** + * An enemy's terminal sprite rows, read by the game's own rule + * (`Graphics/Enemies/.txt`), never from outside that folder. + * + * @return array{lines?: list, issue?: string} + */ + private function readEnemySprite(mixed $imagePath): array + { + return new EnemySprites($this->workspace->projectRoot)->readSprite($imagePath); + } + + /** + * The project's battle presentation, when it lays battles out on a + * graphical canvas: its presentation code, with the battle art bound as + * data as it is now, unsaved edits included, so a preview shows the art + * being set. `$drags` lays ground fields and footing polygons over + * arenas' own, by arena id, as a drag stands them, without writing them. + * + * @param array, footing: array}> $drags + */ + private function requireBattleLayoutCatalog(string $purpose, array $drags = []): BattlePresentationCatalog + { + $assets = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + try { + $catalog = BattlePresentationCatalog::loadCode($assets); + $bindings = $catalog === null ? null : $this->readProposedBattlerBindings(); + if ($catalog !== null && $bindings !== null) { + $catalog = $catalog->bindBattlers(BattlerBindings::getFromArray($bindings, $assets)); + } + $arenas = $catalog === null ? null : $this->workspace->catalogs->readData(BattleArenaBindings::FILE)['payload']; + foreach ($drags as $id => ['ground' => $ground, 'footing' => $footing]) { + if (! is_array($arenas) || ! is_array($arenas['arenas'][$id] ?? null)) { + continue; + } + foreach (['ground' => $ground, 'footing' => $footing] as $key => $dragged) { + $declared = $arenas['arenas'][$id][$key] ?? []; + if ($dragged !== []) { + $arenas['arenas'][$id][$key] = [...(is_array($declared) ? $declared : []), ...$dragged]; + } + } + } + if ($catalog !== null && is_array($arenas)) { + $catalog = $catalog->bindArenas(BattleArenaBindings::getFromArray($arenas)); + } + } catch (\Throwable $error) { + throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); + } + if ($catalog?->ui === null) { + throw new SessionRefusal(sprintf('This project has no graphical battle layout to %s.', $purpose)); + } + + return $catalog; + } + + /** + * The battler bindings file as a save would write it now: the file as + * saved, with every battle art category's unsaved edits folded in. Null + * when the project binds no battle art as data. + * + * @return array|null + */ + private function readProposedBattlerBindings(): ?array + { + $payload = $this->workspace->catalogs->readData(BattlerBindings::FILE)['payload']; + + return is_array($payload) ? $payload : null; + } + + /** + * What converting a legacy cell-frame animation to a timeline would + * touch: its frames and cues, its current battle bindings, and the + * battle and field consumers that play it now. + * + * @return array As {@see LegacyAnimationConversion::describe()}. + * @throws SessionRefusal When the record is unknown or has nothing to convert. + */ + public function describeAnimationConversion(int $index): array + { + try { + return LegacyAnimationConversion::describe($this->workspace, $this->requireAnimationId($index)); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + } + + /** + * Converts a legacy cell-frame animation to a timeline with the timing + * its consumer needs, asked first and written as one undo step: the new + * timeline and, when a battle is to play it, the record naming it. The + * question carries each file as it would be written. Nothing supplies a + * rate; the author's cadence, ticks and rest frame are the timeline's. + * + * @param 'battle_phase'|'fixed' $cadence + * @param 'sourceEffect'|'targetEffect'|null $binding + * @return array The question with `preview` (path to source), or the write's result. + * @throws SessionRefusal When the conversion cannot be made as asked, edits are pending, or the files cannot be written. + */ + public function convertAnimation(int $index, string $timelineId, string $cadence, ?int $fps, int $ticksPerFrame, + int $restFrame, bool $includeFlash, ?string $binding, ?string $answer = null, ?string $confirm = null): array + { + $id = $this->requireAnimationId($index); + try { + $plan = LegacyAnimationConversion::plan($this->workspace, $id, $timelineId, EffectCadence::parse($cadence), + $fps, $ticksPerFrame, $restFrame, $includeFlash, $binding); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $root = rtrim($this->workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $relative = static fn(string $path): string => str_starts_with($path, $root) ? substr($path, strlen($root)) : $path; + $paths = array_map($relative, $plan->getChangedPaths()); + $name = strval($this->requireRecordDatabase('animations')->getRecordByIndex($index)?->get('name')); + $required = new SourceSetRequired($plan, sprintf('Convert %s to timeline %s', $name, $timelineId), 'this animation conversion', + sprintf('Convert %s to the timeline %s, writing %s?', $name, $timelineId, implode(' and ', $paths)), $paths); + $result = $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory('animations')->getRecordLabels(), $confirm); + if ($answer === null) { + $result['preview'] = array_combine($paths, array_values($plan->getProposedSources())); + } + + return $result; + } + + /** @throws SessionRefusal When the animations category has no record there. */ + private function requireAnimationId(int $index): int + { + $id = $this->requireRecordDatabase('animations')->getRecordByIndex($index)?->get('id'); + + return is_int($id) ? $id : throw new SessionRefusal(sprintf('animations has no record %d with an id.', $index)); + } + + /** + * Where an actor's or enemy's battle art is set: its record in the + * battle art category when it has one, and otherwise whether the + * project's presentation code binds it, which the editor leaves to the + * code rather than rewriting program-shaped source. + * + * @param 'actors'|'enemies' $side + * @return array{category: string, index: ?int, owner: 'data'|'code'|null, note?: string} + * @throws SessionRefusal When the side is unknown or the presentation cannot be read. + */ + public function describeBattlerArt(string $side, string $identity): array + { + $category = match ($side) { + 'actors' => 'battler_actors', + 'enemies' => 'battler_enemies', + default => throw new SessionRefusal(sprintf('Battle art is bound to actors or enemies, not %s.', $side)), + }; + $database = $this->requireRecordDatabase($category); + foreach ($database->getRecords() as $index => $record) { + if ($record->getDisplayValue('identity') === $identity) { + return ['category' => $category, 'index' => $index, 'owner' => 'data']; + } + } + try { + $code = BattlePresentationCatalog::loadCode($this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'); + } catch (\Throwable $error) { + throw new SessionRefusal('The battle presentation cannot be read: ' . $error->getMessage(), previous: $error); + } + $party = $side === 'actors'; + $owned = $code !== null && in_array($identity, [ + ...array_keys($party ? $code->actors : $code->enemies), + ...array_keys($party ? $code->actorPoses : $code->enemyPoses), + ...array_keys(($party ? $code->scale?->actors : $code->scale?->enemies) ?? []), + ], true); + + $described = ['category' => $category, 'index' => null, 'owner' => $owned ? 'code' : null]; + if ($code === null) { + $described['note'] = 'This project has no graphical battle to set art for.'; + } elseif ($owned) { + $described['note'] = sprintf('%s binds this art in code, which the editor does not rewrite. Once it moves to %s, it is set here.', + BattlePresentationCatalog::FILE, BattlerBindings::FILE); + } + + return $described; + } + + /** + * Composes enemies with the starting party over an arena, and describes + * what every formation view shares: the canvas and the picture of it the + * battle draws at rest (background and battlers, cropped and scaled as + * the battle draws them, for the frontend to paint with the renderer), + * the arenas with the one previewed and its backgrounds, and the party + * in its slots. Also returns + * the Engine's clearance diagnostics for party and enemies, by position, + * which include each battler's own. + * + * @param list $enemies + * @return array{0: BattleFormationLayout, 1: array, 2: array{party: list>, enemies: list>}} + */ + private function composeFormation(BattlePresentationCatalog $catalog, ?string $arena, array $enemies, ?string $lead = null): array + { + $assetRoot = $this->workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $names = []; + foreach ($this->workspace->actorDatabase->getActors() as $actor) { + $names[$actor->getDefinitionId()] = $actor->getName(); + } + [$partyIds, $partySource] = $this->readPreviewParty(); + if ($lead !== null) { + $partyIds = [$lead, ...array_filter($partyIds, static fn(string $id): bool => $id !== $lead)]; + } + // A theme formation holds so many; without one the battle arranges the whole party. + $partyIds = array_values($catalog->ui->partySlots === [] ? $partyIds : array_slice($partyIds, 0, count($catalog->ui->partySlots))); + + // Each enemy stands where its habitat lives, as in battle. + $enemies = array_map(fn(array $entry): array => ['habitat' => $this->readEnemyHabitat($entry['enemyId']), ...$entry], $enemies); + try { + $formation = BattleFormationLayout::compose($catalog, $arena, $enemies, $partyIds, $assetRoot); + // The picture is the battle's own canvas at rest, which the frontend paints with the renderer itself. + $canvas = $formation->composeCanvas()->toArray(); + } catch (\InvalidArgumentException|\RuntimeException $error) { + throw new SessionRefusal('The formation cannot be composed: ' . $error->getMessage(), previous: $error); + } + $clearance = $formation->getClearanceDiagnostics($assetRoot); + $party = []; + foreach ($formation->party as $position => $battler) { + $party[] = ['name' => $names[$partyIds[$position]] ?? $partyIds[$position], ...self::describeFormationBattler($battler, $clearance['party'][$position])]; + } + $arenas = []; + foreach ($formation->arenaChoices as $id => $name) { + $arenas[] = ['id' => (string) $id, 'name' => $name]; + } + $chosen = $formation->arena === null ? false : array_search($formation->arena, $catalog->arenas, true); + + return [$formation, [ + 'assetRoot' => $assetRoot, + 'canvas' => ['width' => $formation->layout->width, 'height' => $formation->layout->height], + 'picture' => $canvas, + 'arenas' => $arenas, + 'arena' => $chosen === false ? null : (string) $chosen, + 'backgrounds' => array_map(self::describeCanvasImage(...), $formation->backgrounds), + // Where feet may stand on this arena, so a canvas can show it and turn a dragged point into a place. + 'ground' => [...$formation->staging->ground->toArray(), 'partyCompression' => $formation->staging->partyCompression, + 'enemyCompression' => $formation->staging->enemyCompression], + // The arena's land and water footing as canvas points; an arena marking neither is land throughout. + 'footing' => ['land' => self::describeFooting($formation->arena?->footing->land ?? []), + 'water' => self::describeFooting($formation->arena?->footing->water ?? [])], + 'party' => $party, + 'partySource' => $partySource, + ], $clearance]; + } + + /** + * Where an enemy lives as its record holds it now; land for an enemy + * the project does not define or a habitat it cannot read, which the + * enemy's own record reports. + */ + private function readEnemyHabitat(string $enemy): EnemyHabitat + { + $record = array_find($this->requireRecordDatabase('enemies')->getRecords(), + static fn($entry): bool => $entry->get('name') === $enemy); + $habitat = $record?->get('habitat'); + + return is_string($habitat) ? EnemyHabitat::tryFrom($habitat) ?? EnemyHabitat::LAND : EnemyHabitat::LAND; + } + + /** + * @param list $polygon + * @return list + */ + private static function describeFooting(array $polygon): array + { + return array_map(static fn(array $point): array => ['x' => $point[0], 'y' => $point[1]], $polygon); + } + + /** + * The party a battle preview stands beside its enemies: the battle + * test's when it sets one, as the battle test will fight with it, else + * the starting party; and which it is. A battle test that cannot be read + * is named, not quietly replaced. + * + * @return array{0: list, 1: string} + */ + private function readPreviewParty(): array + { + $starting = array_values(array_filter($this->readStartingPartyReferences(), is_string(...))); + try { + $setup = ProjectBattleTest::fromArray($this->readBattleTestEntry())->setup; + } catch (InvalidArgumentException) { + return [$starting, 'starting party (the battle test cannot be read)']; + } + + return $setup === null ? [$starting, 'starting party'] + : [array_map(static fn($member): string => $member->actorId, $setup->members), 'battle test']; + } + + /** + * @param list $diagnostics What the Engine reports about it in this formation. + * @return array Where a battler stands and is drawn, its art, its body at battle scale and what it does not clear. + */ + private static function describeFormationBattler(BattleFormationBattler $battler, array $diagnostics): array + { + $bounds = $battler->bounds; + + return [ + 'ground' => ['x' => $battler->ground->x, 'y' => $battler->ground->y], + 'bounds' => ['x' => $bounds->x, 'y' => $bounds->y, 'width' => $bounds->width, 'height' => $bounds->height], + 'image' => $battler->image === null ? null : self::describeCanvasImage($battler->image), + 'bodySpan' => $battler->bodySpan, + 'horizontal' => $battler->horizontal, + 'diagnostics' => array_values(array_map(strval(...), $diagnostics)), + // What is drawn where it meets the surface, as the area a canvas draws it in, and how far its body rises or sinks. + 'contact' => self::describeContact($battler), + 'offset' => $battler->offset, + ]; + } + + /** @return array{kind: string, x: float, y: float, width: float, height: float, opacity: float}|null */ + private static function describeContact(BattleFormationBattler $battler): ?array + { + $drawn = $battler->contactComposites[0] ?? $battler->contactImages[0] ?? null; + if ($drawn === null) { return null; } + $destination = $drawn->destination; + + return ['kind' => $battler->contact->value, 'x' => $destination->x, 'y' => $destination->y, + 'width' => $destination->width, 'height' => $destination->height, 'opacity' => $drawn->opacity]; + } + + /** @return array An image's asset, where it is drawn and the part of the asset it shows. */ + private static function describeCanvasImage(CanvasImage $image): array + { + $destination = $image->destination; + + return array_filter([ + 'asset' => $image->asset, + 'x' => $destination->x, 'y' => $destination->y, 'width' => $destination->width, 'height' => $destination->height, + 'source' => $image->sourceRect === null ? null : [ + 'x' => $image->sourceRect->x, 'y' => $image->sourceRect->y, + 'width' => $image->sourceRect->width, 'height' => $image->sourceRect->height, + ], + 'flipX' => $image->flipX ?: null, + 'flipY' => $image->flipY ?: null, + // Only this part shows, as a sunk body shows only above its surface line. + 'clip' => $image->clipRect === null ? null : ['x' => $image->clipRect->x, 'y' => $image->clipRect->y, + 'width' => $image->clipRect->width, 'height' => $image->clipRect->height], + ], static fn(mixed $value): bool => $value !== null); + } + + /** + * A database category's records, as its list shows them, whether they + * can be edited and have unsaved changes, and which record operations + * the category takes. + * + * @return array{category: string, editable: bool, readOnly: ?string, dirty: bool, canCreate: bool, canDuplicate: bool, canDelete: bool, canReorder: bool, records: list} + * @throws SessionRefusal When the category is unknown. + */ + public function listDatabaseRecords(string $category): array + { + $database = $this->requireCategory($category); + + return [ + 'category' => $category, + 'editable' => $database->isEditable(), + 'readOnly' => $database->getReadOnlyReason(), + // What its Save writes is unsaved: its records, or what their pages edit and save with them. + 'dirty' => $this->isCategoryUnsaved($category), + // Every listed category with something to save, so a list of them can say which. + 'unsavedCategories' => $this->listUnsavedCategories(), + 'canCreate' => $database->supportsRecordCreation(), + 'canDuplicate' => $database->supportsRecordDuplication(), + 'canDelete' => $database->supportsRecordDeletion(), + // Only where the file keeps the order; a move elsewhere is refused with why. + 'canReorder' => $database->supportsDurableReorder(), + 'records' => $database->getRecordLabels(), + ]; + } + + /** + * One record's rows in a frame of its commands, described as the + * inspector's are, with the `key` an edit names a row by. At the root + * (`frame` []) the rows are the record's fields and its list's entries; + * a row that opens a command frame carries that `frame`, read again with + * it to edit the commands inside. A row of an item the record's lists + * hold carries `item` and `itemNoun`, and `childNoun` when the item holds + * a list of its own (a route's steps, a choice's options): add and + * remove act on it. The heading of a list beside the record's own (a + * quest's reward items) carries `listHeading`: adding to it adds that + * list's first entry. `listNoun` names an entry of the list an add with + * no row goes to. At the record itself, `panes` are the summaries the + * terminal shows beside it ({@see RecordPanes}): a class's curves, a + * skill's effects, a quest's rewards, each a title and its lines. + * + * @param array $frame The frame; [] for the record itself. + * @return array{category: string, index: int, frame: list, frameLabel: ?string, editable: bool, readOnly: ?string, listNoun: ?string, rows: list>, panes: list}>} + * @throws SessionRefusal When the category, record or frame is unknown. + */ + public function readDatabaseRecord(string $category, int $index, array $frame = []): array + { + $database = $this->requireCategory($category); + $frame = self::requireFrame($frame, 'database.record'); + $fields = $this->collectRecordFields($database, $index, $frame); + + return [ + 'category' => $category, + 'index' => $index, + 'frame' => $frame, + 'frameLabel' => $database->describeFrame($frame), + 'editable' => $database->isEditable(), + 'readOnly' => $database->getReadOnlyReason(), + // What an entry of the list an add with no row goes to is called: + // the record's own list, or the open frame's. + 'listNoun' => $database->getListNoun($frame), + // A row that names a field keeps its key whether or not it can be + // edited; a read-only one is an `info` row the edit refuses. + 'rows' => $this->describePlacementRows(array_map(static fn(array $field): array => self::describeRecordRow( + $database, + $index, + $frame, + $field, + isset($field['field']) ? 'record' : null, + ), $fields), ['kind' => 'database', 'category' => $category, 'index' => $index]), + 'panes' => $frame === [] ? array_values(RecordPanes::describe($this->workspace, $category, $index)) : [], + ]; + } + + /** + * Applies one record row's edit as one undo step. The row is found again + * by its key among the record's current rows in its frame. A value its + * field cannot take is refused with what is wrong with it. + * + * @param array $key The row's key, as `database.record` gave it. + * @return array{changed: bool, records: list, note?: string} Whether it changed, the labels afterwards (a rename shows), and what else it did. + * @throws SessionRefusal When the category or record is unknown or read-only, the row is gone or read-only, or the value is refused. + */ + public function applyDatabaseRecord(string $category, int $index, array $key, string $value, ?string $answer = null, ?string $confirm = null): array + { + $database = $this->requireCategory($category); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + // Field ids are unique within a frame, so the field and frame name the row. + $field = is_string($fieldId) ? array_find($this->collectRecordFields($database, $index, $frame), + static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; + + if (! is_string($fieldId) || $field === null) { + throw new SessionRefusal('That row is no longer in the record; read it again.'); + } + if (($field['editable'] ?? true) === false || self::describeRow([...$field, 'target' => 'record'])['kind'] === 'info') { + throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); + } + + try { + $change = $this->changeRecord(static fn(): RecordChange => $database->applyField( + $index, $frame, $fieldId, $value, (string) ($field['label'] ?? 'Database field'), + )); + } catch (SourceSetRequired $required) { + return $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory($category)->getRecordLabels(), $confirm); + } + + return array_filter(['changed' => $change->command !== null, 'records' => $database->getRecordLabels(), 'note' => $change->note], + static fn(mixed $value): bool => $value !== null); + } + + /** + * Sets several of a record's rows as one action, undone and redone + * together: what one gesture changes, such as a click that sets the + * ground point of two images showing the same stance. Each value goes + * through the record's own field rules, as one row's edit does; if any is + * refused, those already made are taken back and nothing is recorded. + * An edit that needs a file set written at once is not made this way. + * + * @param list, value: string}> $changes Each row's key, as `database.record` gave it, and its value. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When a row is gone or read-only, or a value is refused; nothing is changed. + */ + public function applyDatabaseRecordValues(string $category, int $index, array $changes, string $label): array + { + $database = $this->requireCategory($category); + $made = []; + try { + foreach ($changes as $change) { + $key = is_array($change) && is_array($change['key'] ?? null) ? $change['key'] : throw new SessionRefusal('Each change needs the row key database.record gave.'); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + $field = is_string($fieldId) ? array_find($this->collectRecordFields($database, $index, $frame), + static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; + if (! is_string($fieldId) || $field === null || ($field['editable'] ?? true) === false) { + throw new SessionRefusal('That row is no longer in the record, or cannot be edited; read it again.'); + } + try { + $applied = $database->applyField($index, $frame, $fieldId, strval($change['value'] ?? ''), (string) ($field['label'] ?? $label)); + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } catch (SourceSetRequired $required) { + throw new SessionRefusal(sprintf('%s writes several files at once; set it on its own.', trim((string) ($field['label'] ?? 'That row'))), previous: $required); + } + if ($applied->command !== null) { + $made[] = $applied->command; + } + } + } catch (SessionRefusal $refusal) { + foreach (array_reverse($made) as $command) { + $command->undo(); + } + throw $refusal; + } + if ($made !== []) { + $this->history->record(new CommandGroup($label, $made)); + } + + return ['changed' => $made !== [], 'records' => $database->getRecordLabels()]; + } + + /** + * Asks before writing a file set an edit needs at once, then writes it + * as one undo step: the workspace is reloaded from the written files, so + * every map and category reads afresh. The question carries the plan's + * fingerprint, and writing requires it back: a plan made again from + * other choices or changed files is refused, so what is written is + * exactly what the author was shown. + * + * @param Closure(): list $records The category's labels afterwards. + * @param string|null $confirm The fingerprint the question gave, when answering write. + * @return array{status: 'question', question: string, answers: list, confirm: string}|array{changed: bool, records: list, reloaded?: true} + * @throws SessionRefusal When an answer is not one offered, the plan is not the one shown, edits are pending, or the files cannot be written. + */ + private function writeSourceSet(SourceSetRequired $required, ?string $answer, Closure $records, ?string $confirm = null): array + { + if ($answer === null) { + return [ + 'status' => 'question', + 'question' => $required->question, + 'answers' => [ + ['key' => 'cancel', 'label' => 'Cancel', 'description' => 'Leave all files unchanged.'], + ['key' => 'write', 'label' => sprintf('Write %d files now', count($required->paths)), + 'description' => sprintf('Writes %s now, not on Save. Undo restores the files.', implode(', ', $required->paths))], + ], + // Answering write names this, so what is written is what was asked about. + 'confirm' => $required->plan->getFingerprint(), + ]; + } + if (! in_array($answer, ['write', 'cancel'], true)) { + throw new SessionRefusal(sprintf('Answer write or cancel, not %s.', $answer)); + } + if ($answer === 'cancel') { + return ['changed' => false, 'records' => $records()]; + } + // The plan made now must be the one the author was shown: the same + // choices over the same files. Anything else is asked again. + if ($confirm === null || ! hash_equals($required->plan->getFingerprint(), $confirm)) { + throw new SessionRefusal(sprintf('The files or choices changed since %s was shown, so nothing was written. Review it again.', $required->subject)); + } + if ($this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal(sprintf('Save or undo pending edits before %s. No files were changed.', $required->subject)); + } + + $command = new SourceSetCommand($required->label, $required->subject, $required->plan, $this->workspace, + fn(): ProjectWorkspace => $this->workspace, + function (ProjectWorkspace $workspace): void { $this->workspace = $workspace; }, + ); + + try { + $command->execute(); + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + $this->history->record($command); + + return ['changed' => true, 'records' => $records(), 'reloaded' => true]; + } + + /** + * Adds an item at a record row as one undo step: after the item the row + * belongs to, or with `$child` at the end of what that item holds (a + * route's steps, a choice's options). A key of `{frame}` alone adds at + * the end of that frame, or of the record's own list. + * + * @param array $key A row's key, as `database.record` gave it, or `{frame}` alone. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the category, record or frame is unknown or read-only, or the row's item cannot take it. + */ + public function addDatabaseItem(string $category, int $index, array $key, bool $child = false): array + { + $database = $this->requireCategory($category); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + + if ($fieldId !== null && ! is_string($fieldId)) { + throw new SessionRefusal('A row key names its field as a string, as database.record gave it.'); + } + + $change = $this->changeRecord(static fn(): RecordChange => $database->addItem($index, $frame, $fieldId, $child)); + + return ['changed' => $change->command !== null, 'records' => $database->getRecordLabels()]; + } + + /** + * Removes the item a record row belongs to (an entry or command with + * everything under it, a route step, line or lane, a choice's option + * with its arm) as one undo step; a row that belongs to none changes + * nothing. + * + * @param array $key A row's key, as `database.record` gave it. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the category, record or frame is unknown or read-only, or the key names no row. + */ + public function removeDatabaseItem(string $category, int $index, array $key): array + { + $database = $this->requireCategory($category); + $frame = self::requireFrame($key['frame'] ?? [], 'database.record'); + $fieldId = $key['field'] ?? null; + + if (! is_string($fieldId)) { + throw new SessionRefusal('Name the row whose item to remove by its key, as database.record gave it.'); + } + + $change = $this->changeRecord(static fn(): RecordChange => $database->removeItem($index, $frame, $fieldId)); + + return ['changed' => $change->command !== null, 'records' => $database->getRecordLabels()]; + } + + /** + * Creates a blank record at the end of a category, as one undo step: + * for an identity when one is given (an enemy's battle art, made for + * that enemy), refused when that identity already has one. + * + * @return array{index: int, records: list} The new record's index, and the labels afterwards. + * @throws SessionRefusal When the category is unknown, read-only or takes no new records. + */ + public function createDatabaseRecord(string $category, ?string $identity = null): array + { + $database = $this->requireCategory($category); + $change = $this->changeRecord(static fn(): RecordChange => $database->createRecord($identity)); + + return ['index' => (int) $change->index, 'records' => $database->getRecordLabels()]; + } + + /** + * Duplicates a record below itself under a fresh identity, as one undo + * step: the one given (made unique) when the copy is to be something of + * its own, so its file is named for it. + * + * @return array{index: int, records: list} The copy's index, and the labels afterwards. + * @throws SessionRefusal When the category or record is unknown or read-only, or the record cannot be duplicated. + */ + public function duplicateDatabaseRecord(string $category, int $index, ?string $identity = null): array + { + $database = $this->requireCategory($category); + $change = $this->changeRecord(static fn(): RecordChange => $database->duplicateRecord($index, $identity)); + + return ['index' => (int) $change->index, 'records' => $database->getRecordLabels()]; + } + + /** + * Retires a skill from everything that names it, at once and as one undo + * step: what classes teach, what actors know, can learn, attack or counter + * with, what enemies use, what skills and states counter with. With a + * replacement, each moves onto it (a list already holding it drops the old + * entry) and saves that knew the skill read the replacement; without one, + * each is removed and saves drop the skill. The skill is then free to + * delete. Asks before writing, as every file set does. + * + * @param string|null $replacement The skill that takes its place, or null to remove it from everything. + * @return array The question, or what was written. + * @throws SessionRefusal When a skill is unknown, the replacement is itself, the two are a spell and an ability, it is a summon's action, or edits are pending. + */ + public function retireSkill(int $index, ?string $replacement = null, ?string $answer = null, ?string $confirm = null): array + { + $this->requireCategory('skills'); + $database = $this->workspace->getRecordDatabase('skills') ?? throw new SessionRefusal('This project has no skills.'); + $record = $database->getRecordByIndex($index) ?? throw new SessionRefusal(sprintf('There is no skill %d.', $index)); + $name = strval($record->get('name')); + $isMagic = strval($record->get('kind')) === 'magic'; + if ($replacement !== null) { + $target = array_find($database->getRecords(), static fn($candidate): bool => strval($candidate->get('name')) === $replacement) + ?? throw new SessionRefusal(sprintf('There is no skill named %s to take its place.', $replacement)); + if ($target === $record) { + throw new SessionRefusal(sprintf('%s cannot take its own place.', $name)); + } + if ($isMagic !== (strval($target->get('kind')) === 'magic')) { + throw new SessionRefusal(sprintf('%s and %s must both be spells or both abilities: saves keep the two apart.', $name, $replacement)); + } + } + if ($this->workspace->loadSkillCatalog()->isSummonAction($name)) { + throw new SessionRefusal(sprintf('%s is a summon\'s action and follows its summon.', $name)); + } + if ($answer === null && $this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal(sprintf('Save or undo pending edits before retiring %s. No files were changed.', $name)); + } + try { + $plan = SkillReferences::planRetirement($this->workspace->projectRoot, $this->workspace->projectId, $name, $replacement, $isMagic); + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($plan === null) { + return ['changed' => false, 'records' => $this->requireCategory('skills')->getRecordLabels()]; + } + $paths = array_map(fn(string $path): string => substr($path, strlen($this->workspace->projectRoot) + 1), $plan->getChangedPaths()); + $required = $replacement === null + ? new SourceSetRequired($plan, sprintf('Retire %s', $name), 'this skill retirement', + sprintf('Remove %s from everything that names it? Saves that knew it drop it.', $name), $paths) + : new SourceSetRequired($plan, sprintf('Retire %s into %s', $name, $replacement), 'this skill retirement', + sprintf('Move everything that names %s onto %s, and let saves read %s as %s?', $name, $replacement, $name, $replacement), $paths); + + return $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory('skills')->getRecordLabels(), $confirm); + } + /** + * A Mastery Map as the graphical page draws it: the regions, and each node + * with its place on the map and what it builds on, and the project's words + * for the price and level each card names. + * + * @return array{regions: list, nodes: list>, issue: ?string, + * pointsLabel: string, levelLabel: string} + * @throws SessionRefusal When the map is unknown. + */ + public function readMasteryPage(int $index): array + { + try { + // Each card names its price and level in the project's own words for Mastery points and the level. + $term = fn(string $path): string => $this->workspace->config?->getTermLabel($path) ?? Vocabulary::DEFAULTS[$path]; + + return [...$this->masteryAuthoring->readPage($this->requireRecordDatabase(MasteryFields::MAPS), + $this->workspace->getRecordDatabase(MasteryFields::REGIONS), $this->workspace->getRecordDatabase(MasteryFields::BRANCHES), $index), + 'pointsLabel' => $term(MasteryVocabulary::POINTS), 'levelLabel' => $term('stats.level_short')]; + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * Places a node on its map, as fractions (0 to 1) of the map's width and height, as one undo step. + * + * @return array{changed: bool, records: list} + */ + public function placeMasteryNode(int $index, string $node, float $x, float $y): array + { + $maps = $this->requireRecordDatabase(MasteryFields::MAPS); + $change = $this->changeRecord(fn(): RecordChange => $this->masteryAuthoring->placeNode($maps, $index, $node, $x, $y)); + + return ['changed' => $change->command !== null, 'records' => $this->requireCategory(MasteryFields::MAPS)->getRecordLabels()]; + } + + /** + * Makes one node build on another, or stop doing so, as one undo step. + * + * @return array{changed: bool, records: list} + */ + public function toggleMasteryRequirement(int $index, string $node, string $requires): array + { + $maps = $this->requireRecordDatabase(MasteryFields::MAPS); + $change = $this->changeRecord(fn(): RecordChange => $this->masteryAuthoring->toggleRequirement($maps, + $this->workspace->getRecordDatabase(MasteryFields::REGIONS), $this->workspace->getRecordDatabase(MasteryFields::BRANCHES), $index, $node, $requires)); + + return ['changed' => $change->command !== null, 'records' => $this->requireCategory(MasteryFields::MAPS)->getRecordLabels()]; + } + + /** + * Moves a character's own skills from their actor file onto their Mastery + * Map, at once and as one undo step: known skills become nodes known from + * the start, learnables become nodes costing their EXP requirement, skills + * their role teaches stay with the role, and the actor's lists are emptied. + * Asks before writing, as every file set does. + * + * @return array The question, or what was written. + * @throws SessionRefusal When the actor is unknown, no regions are declared, edits are pending, or a file cannot take it. + */ + public function adoptMasterySkills(string $actorId, ?string $answer = null, ?string $confirm = null): array + { + if ($answer === null && $this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal(sprintf('Save or undo pending edits before moving %s\'s skills. No files were changed.', $actorId)); + } + try { + $adoption = MasteryAdoption::plan($this->workspace->projectRoot, $actorId); + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($adoption === null) { + return ['changed' => false, 'nodes' => []]; + } + $paths = array_map(fn(string $path): string => substr($path, strlen($this->workspace->projectRoot) + 1), $adoption['plan']->getChangedPaths()); + $required = new SourceSetRequired($adoption['plan'], sprintf('Move %s\'s skills onto their Mastery Map', $actorId), 'this skill move', + sprintf('Move %s\'s own skills onto their Mastery Map (%s), and empty the actor\'s skill lists?', $actorId, + $adoption['nodes'] === [] ? 'no new nodes' : implode(', ', $adoption['nodes'])), $paths); + $result = $this->writeSourceSet($required, $answer, fn(): array => $this->requireCategory(MasteryFields::MAPS)->getRecordLabels(), $confirm); + + return [...$result, 'nodes' => $adoption['nodes']]; + } + + /** + * Deletes a record as one undo step; the file changes on save. + * + * @return array{index: ?int, records: list} The record to select next (null when none is left), and the labels afterwards. + * @throws SessionRefusal When the category or record is unknown or read-only, or the category keeps its entries. + */ + public function deleteDatabaseRecord(string $category, int $index): array + { + $database = $this->requireCategory($category); + $change = $this->changeRecord(static fn(): RecordChange => $database->deleteRecord($index)); + + return ['index' => $change->index, 'records' => $database->getRecordLabels()]; + } + + /** + * Moves a record one place up or down, as one undo step, where the file + * keeps the order. A move past either end changes nothing. + * + * @param string $direction "up" or "down". + * @return array{index: int, changed: bool, records: list} Where the record is now, whether it moved, and the labels afterwards. + * @throws SessionRefusal When the category or record is unknown or read-only, the direction is neither, or the file would not keep the order (with why). + */ + public function moveDatabaseRecord(string $category, int $index, string $direction): array + { + $database = $this->requireCategory($category); + $step = match ($direction) { + 'up' => -1, + 'down' => 1, + default => throw new SessionRefusal(sprintf('A record moves "up" or "down", not "%s".', $direction)), + }; + $change = $this->changeRecord(static fn(): RecordChange => $database->moveRecord($index, $step)); + + return ['index' => (int) $change->index, 'changed' => $change->command !== null, 'records' => $database->getRecordLabels()]; + } + + /** + * Saves one database category, backing up the files it overwrites + * first, as the terminal editor's save does. A category with nothing + * unsaved writes nothing (`saved` false). No database validation runs on + * save, so there are no warnings yet. + * + * @return array{saved: bool, warnings: list, backupFailures: list} + * @throws SessionRefusal When the category is unknown or read-only, or its source cannot take the save. + */ + public function saveDatabase(string $category): array + { + $this->refuseDuringChangeSet('Saving a database'); + $database = $this->requireCategory($category); + if (! $database->isEditable()) { + throw new SessionRefusal(sprintf('Read-only: %s.', $database->getReadOnlyReason() ?? 'this category cannot be written')); + } + // What the page edits beside the record (an enemy's battle art) is + // saved with it, first, so a save it refuses leaves the page unsaved. + $embedded = ['saved' => false, 'backupFailures' => []]; + foreach (DatabaseCatalog::getEmbedded() as $definition) { + if (in_array($category, $definition->hosts, true) && $this->workspace->getRecordDatabase($definition->key)?->isDirty()) { + $saved = $this->saveDatabase($definition->key); + $embedded = ['saved' => true, 'backupFailures' => [...$embedded['backupFailures'], ...$saved['backupFailures']]]; + } + } + if (! $database->isDirty()) { + return ['saved' => $embedded['saved'], 'warnings' => [], 'backupFailures' => $embedded['backupFailures']]; + } + $paths = $database->getBackupPaths(); + $failures = $this->backups->isEnabled() && $paths !== [] ? $this->backups->backup(...$paths)['failed'] : []; + + try { + $database->save(); + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return ['saved' => true, 'warnings' => [], 'backupFailures' => [...$embedded['backupFailures'], ...array_values($failures)]]; + } + + /** + * Whether a category's Save has something to write: its own records, or + * what their pages edit beside them and save with them (an enemy's + * battle art, the battle scale), the same set `saveDatabase` writes. + */ + private function isCategoryUnsaved(string $category): bool + { + if ($this->requireCategory($category)->isDirty()) { + return true; + } + + return array_any(DatabaseCatalog::getEmbedded(), fn($definition): bool => in_array($category, $definition->hosts, true) + && ($this->workspace->getRecordDatabase($definition->key)?->isDirty() ?? false)); + } + + /** + * The listed Database and cutscene categories with something to save. + * + * @return list + */ + private function listUnsavedCategories(): array + { + $keys = [ + ...array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories()), + ...array_map(static fn(CutsceneType $type): string => $type->getRecordCategory(), CutsceneType::cases()), + ]; + + return array_values(array_filter($keys, function (string $key): bool { + try { + return $this->isCategoryUnsaved($key); + } catch (SessionRefusal) { + // A category this project cannot open has nothing to save. + return false; + } + })); + } + + /** + * Makes one record change, records it, and turns a refusal into the session's. + * + * @param callable(): RecordChange $change + * @throws SessionRefusal + */ + private function changeRecord(callable $change): RecordChange + { + try { + $applied = $change(); + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied->command !== null) { + $this->history->record($applied->command); + } + + return $applied; + } + + /** + * A record's rows in a frame, refusing a record or frame that is gone. + * + * @param list $frame + * @return array> + * @throws SessionRefusal + */ + private function collectRecordFields(DatabaseCategory $database, int $index, array $frame): array + { + try { + return $database->getRecordRows($index, $frame); + } catch (RecordRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + } + + /** + * One row of a record pane (a database record's or an NPC's) as plain + * data: described as an inspector row, its key naming the frame it lives + * in, with the frame it opens, its exact multi-line text, and the item + * it belongs to as the record's own rules locate it. + * + * @param array $frame + * @param array $field + * @param string|null $target The key's target, or null for a row no edit applies through. + * @return array + */ + private static function describeRecordRow(ProjectRecordDatabase|DatabaseCategory $records, int $index, array $frame, array $field, ?string $target): array + { + $row = self::describeRow([...$field, 'target' => $target]); + + if (isset($row['key'])) { + $row['key']['frame'] = $frame; + $item = $records->locateItem($index, $frame, (string) ($field['field'] ?? '')); + + if ($item !== null) { + $row['item'] = true; + $row['itemNoun'] = $item->noun; + if ($item->kind === RecordItem::LIST) { + // A list's heading: entries are added to it, never removed with it. + $row['listHeading'] = true; + } + if ($item->childNoun !== null) { + $row['childNoun'] = $item->childNoun; + } + } + } + if (is_array($field['frame'] ?? null)) { + $row['frame'] = array_values($field['frame']); + } + $control = MapInspector::findControl($field); + if ($control?->type === InputControlType::MULTILINE) { + $row['multiline'] = true; + $row['value'] = $control->rawValue; + } + + return $row; + } + + /** + * A frame of a record's commands, as a request or a row key names it. + * + * @param string $reader What gave the frame, for the refusal. + * @return list + * @throws SessionRefusal When the frame is not a list of indexes and keys. + */ + private static function requireFrame(mixed $frame, string $reader): array + { + if (! is_array($frame) || ! array_is_list($frame) || ! array_all($frame, static fn(mixed $segment): bool => is_int($segment) || is_string($segment))) { + throw new SessionRefusal(sprintf('A frame must be a list of indexes and keys, as %s gave it.', $reader)); + } + + return $frame; + } + + /** Reads a transient inspector for the map-owned declaration. */ + public function readWorldObject(string $mapId, string $id): array + { + $map = $this->requireMap($mapId); + $authoring = $this->createWorldObjectAuthoring($map); + $index = $authoring->getIndex($id); + $records = $authoring->getRecords(); + $object = WorldObjectAuthoring::readEntries($map)[$index]; + $issue = $authoring->getSourceIssue(); + $fields = $records->getSettingsFields($index); + foreach ($fields as &$field) { + $owner = isset($field['entry'][0]) && str_starts_with($field['field'] ?? '', 'variant') + ? $object['variants'][$field['entry'][0]] : $object; + $field = FieldSpriteFields::describeFields([$field], $owner, $map->getAssetRoot())[0]; + if ($issue !== null) { $field['editable'] = false; } + } + unset($field); + return ['map' => $mapId, 'revision' => $this->getMapRevision($map), 'event' => null, + 'worldObject' => ['id' => $id, 'variants' => array_column($object['variants'] ?? [], 'id'), + 'anchor' => $object['anchor'], 'covers' => $object['covers'] ?? null, 'issue' => $issue], + 'issue' => $issue, + 'rows' => [...array_map(static fn($field): array => self::describeRecordRow($records, $index, [], $field, 'world-object'), $fields), + // What the object is on the minimap: a map landmark row, applied through the map's inspector. + ...array_map(self::describeRow(...), new MapLandmarkFields(new ReferenceCatalog($this->workspace, $map))->describeObjectFields($map, $id))]]; + } + + public function createWorldObject(string $mapId, int $revision, string $id, int $x, int $y, ?string $resource = null, ?string $text = null): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->createObject($id, $x, $y, $resource, $text)); + } + + public function deleteWorldObject(string $mapId, int $revision, string $id): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->deleteObject($id)); + } + + public function applyWorldObject(string $mapId, int $revision, string $id, array $key, string $value): array + { + if (($key['target'] ?? null) === MapLandmarkFields::OBJECT_TARGET) { + return $this->applyWorldObjectLandmark($mapId, $revision, $id, $key, $value); + } + $field = is_string($key['field'] ?? null) ? $key['field'] : throw new SessionRefusal('Name a current world-object field.'); + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->applyField($id, $field, $value)); + } + + public function changeWorldObjectItem(string $mapId, int $revision, string $id, array $key, bool $remove): array + { + $field = is_string($key['field'] ?? null) ? $key['field'] : throw new SessionRefusal('Name a current world-object list row.'); + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->changeItem($id, $field, $remove)); + } + + public function placeWorldObject(string $mapId, int $revision, string $id, int $x, int $y, bool $coverage = false): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->placeObject($id, $x, $y, $coverage)); + } + + /** + * Moves a world object by a step with what shows it in the terminal, as + * one undo step: its anchor, the cells it covers, and the glyphs on those + * cells of its covered layer, with the tiles those glyphs draw. The cells + * it leaves become blank. Furniture moved this way leaves no glyph + * behind; an event that describes it moves with `event.move`. Each glyph + * keeps the piece it plays where it stands, read from its tiles, and a + * glyph no piece draws there (the object's own art presents it) moves + * without tiles. A role still in doubt is asked about as painting asks, + * and answered in `$choices` by glyph. + * + * @param array $choices + * @return array{changed: bool, revision: int}|array{status: 'question', glyph: string, roles: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the object is unknown, or a destination cell + * leaves the map, holds another glyph or is covered by another object. + */ + public function moveWorldObject(string $mapId, int $revision, string $id, int $dx, int $dy, array $choices = []): array + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + if ($dx === 0 && $dy === 0) { + return ['changed' => false, 'revision' => $this->getMapRevision($map)]; + } + try { + $entry = array_find(WorldObjectAuthoring::readEntries($map), static fn(array $entry): bool => ($entry['id'] ?? null) === $id) + ?? throw new InvalidArgumentException(sprintf('%s has no world object %s.', $mapId, $id)); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + $commands = []; + $layerName = $entry['covers']['layer'] ?? null; + if (is_string($layerName)) { + $layerId = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === $layerName)['id'] + ?? throw new SessionRefusal(sprintf('World object %s covers layer %s, which %s does not have.', $id, $layerName, $mapId)); + $from = array_map(static fn(array $cell): array => [(int) $cell[0], (int) $cell[1]], $entry['covers']['cells']); + $to = array_map(static fn(array $cell): array => [$cell[0] + $dx, $cell[1] + $dy], $from); + $writes = []; + $assigned = []; + foreach ($from as $cell) { + if (! in_array($cell, $to, true)) { + $writes[] = ['x' => $cell[0], 'y' => $cell[1], 'symbol' => ' ', 'color' => '']; + } + } + foreach ($to as $index => [$x, $y]) { + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new SessionRefusal(sprintf('Moving %s by %d, %d reaches %d, %d, outside %s.', $id, $dx, $dy, $x, $y, $mapId)); + } + if (! in_array([$x, $y], $from, true) && ($held = $map->getLayerSymbol($layerId, $x, $y)) !== ' ') { + throw new SessionRefusal(sprintf('%d, %d on the %s layer holds %s; clear it before moving %s there.', $x, $y, $layerName, $held, $id)); + } + [$fromX, $fromY] = $from[$index]; + $writes[] = ['x' => $x, 'y' => $y, 'symbol' => $map->getLayerSymbol($layerId, $fromX, $fromY), + 'color' => $map->getLayerColor($layerId, $fromX, $fromY) ?? '']; + $role = CanvasEditor::findPlayedRole($map, $layerId, $fromX, $fromY); + if ($role !== null) { + $assigned["{$x},{$y}"] = $role->key; + } else { + // A glyph no piece's tiles draw here (the object's own art presents it) moves without tiles. + $choices = [$map->getLayerSymbol($layerId, $fromX, $fromY) => null, ...$choices]; + } + } + try { + $plan = CanvasEditor::plan($map, $layerId, $writes, $choices, true, assigned: $assigned); + if ($plan !== null && $plan['unresolved'] !== []) { + $glyph = (string) array_key_first($plan['unresolved']); + + return ['status' => 'question', 'glyph' => $glyph, 'roles' => array_map( + static fn(PieceRole $role): array => ['key' => $role->key, 'label' => $role->label], + $plan['unresolved'][$glyph], + )]; + } + $glyphs = CanvasEditor::apply($map, $layerId, $plan['writes'] ?? $writes, 'Move world object', $plan['tiles'] ?? [])['command']; + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($glyphs !== null) { + $commands[] = $glyphs; + } + } + try { + $object = $this->createWorldObjectAuthoring($map)->moveObject($id, $dx, $dy); + } catch (InvalidArgumentException|\RuntimeException $error) { + foreach (array_reverse($commands) as $command) { + $command->undo(); + } + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($object !== null) { + $commands[] = $object; + } + if ($commands !== []) { + $this->history->record(new CommandGroup('Move world object', $commands)); + } + + return ['changed' => $commands !== [], 'revision' => $this->getMapRevision($map)]; + } + + public function moveWorldObjectVariant(string $mapId, int $revision, string $id, string $variantId, int $offset): array + { + return $this->changeWorldObject($mapId, $revision, static fn($authoring): ?Command => $authoring->moveVariant($id, $variantId, $offset)); + } + + /** + * One exchange with a map's editing canvas: the window's answers in, what it should apply out. The canvas draws + * the map as it stands in memory, unsaved edits included, through the Engine's frame composer, at a variant + * selection and preview time. One canvas lives per open map at its size; a resize, or the map read afresh when a + * file set written at once reloads the project, starts a new one, whose new session id tells the window to start + * again, and answers meant for an earlier canvas are not applied to it. + * + * @param list $events The window's answers, as renderer event lines. + * @param array $variants + * @return array + * @throws SessionRefusal When the map is unknown or cannot be composed, or the window reports an error. + */ + public function exchangeAuthoringCanvas(string $mapId, array $events, array $variants = [], float $seconds = 0, ?string $sessionId = null): array + { + $map = $this->requireMap($mapId); + $host = $this->authoringCanvases[$mapId] ?? null; + if ($host === null || ! $host->drawsMap($map)) { + $host?->dispose(); + $host = $this->authoringCanvases[$mapId] = new \Ichiloto\Editor\Field\AuthoringCanvasHost($map, uniqid('canvas-', true)); + } + try { + $messages = $host->exchange($sessionId === $host->sessionId ? $events : [], $variants, $seconds); + } catch (InvalidArgumentException|MapSourceRefusal|\Ichiloto\Engine\Rendering\Transport\Exceptions\RendererTransportException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $source = $host->getSource(); + + return ['sessionId' => $host->sessionId, 'map' => $map->mapId, 'revision' => $this->getMapRevision($map), + 'assetRoot' => $map->getAssetRoot(), 'layerIds' => $map->getPresentationLayerIds(), + 'grid' => ['columns' => $host->grid->columns, 'rows' => $host->grid->rows, + 'cellWidth' => $host->grid->cellWidth, 'cellHeight' => $host->grid->cellHeight], + 'messages' => $messages, 'assetPaths' => $source?->assetPaths ?? [], 'diagnostics' => $source?->diagnostics ?? [], + 'graphicsIssue' => $source?->graphicsIssue]; + } + + /** + * Renders a map for review as the game draws it, both ways, as it stands, + * unsaved edits included: the Terminal field's picture and a still of the + * graphical field, for the whole map or a region, with the player or an + * actor on a cell when the view asks ({@see MapRenderer}). Nothing is + * written. + * + * @throws SessionRefusal When the view names an unknown map, actor or cells off the map, or the map cannot be loaded. + */ + public function renderMapReview(MapRenderView $view): MapRendering + { + try { + return new MapRenderer($this->workspace)->render($view); + } catch (InvalidArgumentException|RuntimeException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + } + + /** + * Renders a map, or a rectangle of it, for review as the game draws it, + * unsaved edits included, and writes the review into a folder + * ({@see MapReviewWriter}): the Terminal picture beside the graphical + * still, on one page that carries its stills. With a cell, the player + * stands there facing the heading given, for door checks. The project's + * files never change. + * + * @param array{0: int, 1: int, 2: int, 3: int}|null $region x, y, width and height in map cells; null is the whole map. + * @param array{0: int, 1: int}|null $player The cell the player stands on, or null for nobody. + * @return array{page: string, files: array, diagnostics: list} + * @throws SessionRefusal When the view cannot be rendered or the folder cannot be written. + */ + public function writeMapReview(string $mapId, string $directory, ?array $region = null, ?array $player = null, ?string $heading = null): array + { + try { + $subject = $player === null ? null : new MapRenderSubject($player[0], $player[1], + $heading === null ? \Ichiloto\Engine\Core\Enumerations\MovementHeading::SOUTH : MapRenderSubject::readHeading($heading)); + $view = new MapRenderView($mapId, $region === null ? null : ['x' => $region[0], 'y' => $region[1], 'width' => $region[2], 'height' => $region[3]], + $subject); + $rendering = $this->renderMapReview($view); + $writer = new MapReviewWriter($directory, new FieldStillRaster(rtrim($this->workspace->projectRoot, '/') . '/assets')); + $written = $writer->writeRendering($rendering); + + return ['page' => $writer->writeReviewPage($view->label), 'files' => $written['files'], 'diagnostics' => $written['diagnostics']]; + } catch (InvalidArgumentException|RuntimeException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + } + + /** + * Closes a map's editing canvas, releasing its signs' prepared labels and its renderer client. + * + * @return array{closed: bool} + */ + public function closeAuthoringCanvas(string $mapId): array + { + $host = $this->authoringCanvases[$mapId] ?? null; + $host?->dispose(); + unset($this->authoringCanvases[$mapId]); + + return ['closed' => $host !== null]; + } + + /** + * Applies a world object's minimap row, found again among its current rows, as one undo step. + * + * @param array $key + * @return array{changed: bool, revision: int} + * @throws SessionRefusal When the map is stale, the row is gone or the edit is refused. + */ + private function applyWorldObjectLandmark(string $mapId, int $revision, string $id, array $key, string $value): array + { + $map = $this->requireCurrentMap($mapId, $revision); + ksort($key); + $field = array_find(new MapLandmarkFields(new ReferenceCatalog($this->workspace, $map))->describeObjectFields($map, $id), static function (array $candidate) use ($key): bool { + $candidateKey = MapInspector::describeRowKey($candidate); + if ($candidateKey !== null) { ksort($candidateKey); } + return $candidateKey === $key; + }) ?? throw new SessionRefusal('That row is no longer in the inspector; read it again.'); + $inspector = $this->createMapInspector($map); + $command = $this->runEdit(static fn(): ?Command => $inspector->apply($map, $field, $value)); + + return ['changed' => $command !== null, 'revision' => $this->getMapRevision($map)]; + } + + private function createWorldObjectAuthoring(ProjectMap $map): WorldObjectAuthoring + { + return new WorldObjectAuthoring($map, new ReferenceCatalog($this->workspace, $map)); + } + + private function changeWorldObject(string $mapId, int $revision, callable $edit): array + { + $map = $this->requireCurrentMap($mapId, $revision); + try { $command = $edit($this->createWorldObjectAuthoring($map)); } + catch (InvalidArgumentException|\RuntimeException $error) { throw new SessionRefusal($error->getMessage(), previous: $error); } + if ($command !== null) { $this->history->record($command); } + return ['changed' => $command !== null, 'revision' => $this->getMapRevision($map)]; + } + + /** + * Creates a fixed NPC at a tile under the id given, or one derived from its name. + * + * @param string|null $id The stable id; null derives one from the name. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the tile is outside the map or taken, or the id is not one or is taken. + */ + public function createNpc(string $mapId, int $revision, int $x, int $y, string $name, ?string $id = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->create($map, $x, $y, $name, $id)); + } + + /** + * Moves an NPC's anchor to a tile; a move to where it stands changes nothing. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, or the tile is outside the map or taken. + */ + public function moveNpc(string $mapId, int $revision, int $index, int $x, int $y): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->move($map, $index, $x, $y)); + } + + /** + * Duplicates an NPC under a fresh id, appended; `index` is the copy's. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the NPC is unknown. + */ + public function duplicateNpc(string $mapId, int $revision, int $index): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->duplicate($map, $index)); + } + + /** + * Deletes an NPC nothing names; `index` is null afterwards and `id` the deleted NPC's. + * + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, or something names it (each on a line of its own). + */ + public function deleteNpc(string $mapId, int $revision, int $index): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->delete($map, $index)); + } + + /** + * Gives an NPC authored without a stable id the one given, or one from + * its name. An existing id changes through the NPC's Id row + * ({@see applyNpc()}), which refuses while anything names it. + * + * @param string|null $id The stable id; null derives one from the name. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the NPC is unknown, it already has an id, or the id is not one or is taken. + */ + public function assignNpcId(string $mapId, int $revision, int $index, ?string $id = null): array + { + $map = $this->requireCurrentMap($mapId, $revision); + + return $this->applyNpcChange($map, fn(NpcAuthoring $authoring): NpcChange => $authoring->assignId($map, $index, $id)); + } + + /** + * One NPC's rows in a frame of its commands, described as the + * inspector's are ({@see readInspector()}), with the `key` an edit names + * a row by. At the root (`frame` []) the rows are its fields under + * headings, with its identity notes and dialogue variants; a row that + * opens a script frame carries that `frame`, read again with it to edit + * the commands inside. Multi-line text carries `multiline` and its exact + * text as `value`. Visibility conditions and completion writes are + * `info` here: they are built in the terminal's own editors for now. + * + * @param array $frame The frame; [] for the NPC itself. + * @return array{map: string, revision: int, index: int, frame: list, frameLabel: ?string, npc: array, rows: list>} + * @throws SessionRefusal When the map, NPC or frame is unknown. + */ + public function readNpc(string $mapId, int $index, array $frame = []): array + { + $frame = self::requireNpcFrame($frame); + $map = $this->requireMap($mapId); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); + $npc = $map->getNpcs()->get($index) ?? throw new SessionRefusal(sprintf('%s has no NPC %d.', $mapId, $index)); + $fields = $this->collectNpcFields($inspector, $index, $frame); + $rows = array_map(static fn(array $field): array => self::describeNpcRow($inspector->records(), $index, $field, $frame), $fields); + + return [ + 'map' => $mapId, + 'revision' => $this->getMapRevision($map), + 'index' => $index, + 'frame' => $frame, + 'frameLabel' => $frame === [] ? null : $inspector->records()->describeFramePath($frame), + 'npc' => [ + 'index' => $index, + 'id' => $npc->getId(), + 'name' => $npc->getName(), + 'x' => $npc->getX(), + 'y' => $npc->getY(), + 'sprite' => $npc->getVisibleSprite(), + ], + 'rows' => $this->describePlacementRows($frame === [] ? self::insertNpcFieldSheetRow($rows, $npc, $map->getAssetRoot()) : $rows, + ['kind' => 'npc', 'map' => $mapId, 'index' => $index]), + ]; + } + + /** The field id of an NPC's graphical character sheet, a row only the graphical editor shows. */ + private const string NPC_FIELD_SHEET = 'sprites2d.sheet'; + + /** + * An NPC's rows with its graphical character sheet after its terminal + * appearance: a picture picked from the project's images, set through + * {@see NpcAuthoring::setFieldSheet()}. The terminal editor's rows are + * its own; graphical art never changes them. + * + * @param list> $rows + * @return list> + */ + private static function insertNpcFieldSheetRow(array $rows, ProjectNpc $npc, string $assetRoot): array + { + $sprites = $npc->toArray()['sprites2d'] ?? null; + $row = self::describeRow([ + 'label' => ' Field Sheet', + 'field' => self::NPC_FIELD_SHEET, + 'value' => is_array($sprites) && is_string($sprites['sheet'] ?? null) ? $sprites['sheet'] : '', + 'reference' => 'png_assets', + 'noneLabel' => 'None: the glyph shows', + 'target' => 'npc', + ]); + $row['key'] = [...$row['key'], 'frame' => []]; + $sheetRows = []; + if ($sprites !== null) { + $row['imagePreview'] = CharacterSheetPreview::describe($sprites, $assetRoot); + if (is_array($sprites) && is_string($sprites['sheet'] ?? null) && $sprites['sheet'] !== '') { + $characterIndex = $sprites['index'] ?? 0; + $layer = $sprites['layer'] ?? CharacterSheet::DEFAULT_LAYER; + $indexValue = is_scalar($characterIndex) ? strval($characterIndex) : '(invalid index)'; + $layerValue = is_scalar($layer) ? strval($layer) : '(invalid layer)'; + $sheetRows = [ + self::describeRow(['label' => ' Sheet Character Index', 'field' => 'sprites2d.index', + 'value' => $indexValue, 'target' => 'npc', + 'options' => CharacterSheetPreview::getIndexOptions($sprites['sheet'])]), + self::describeRow(['label' => ' Graphical Layer', 'field' => 'sprites2d.layer', + 'value' => $layerValue, 'target' => 'npc', + 'control' => new InputControl(InputControlType::INTEGER, $layerValue)]), + ]; + foreach ($sheetRows as &$sheetRow) { + $sheetRow['key']['frame'] = []; + } + unset($sheetRow); + } + } + $appearance = array_keys(array_filter($rows, static fn(array $candidate): bool => + in_array($candidate['key']['field'] ?? null, ['sprite', 'sprites.north', 'sprites.south', 'sprites.east', 'sprites.west'], true))); + $at = $appearance === [] ? count($rows) : max($appearance) + 1; + + return [...array_slice($rows, 0, $at), $row, ...$sheetRows, ...array_slice($rows, $at)]; + } + + /** + * Applies one NPC row's edit as one undo step. The row is found again by + * its key among the NPC's current rows. A rename carries an id its old + * name derived along (`followedId`) unless something names it + * (`idReferences`). The Id row sets the id itself, refused while + * anything names the current one. + * + * @param array $key The row's key, as `readNpc` gave it. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, the row is gone or read-only, or the edit is refused. + */ + public function applyNpc(string $mapId, int $revision, int $index, array $key, string $value): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); + $frame = self::requireNpcFrame($key['frame'] ?? []); + $fieldId = $key['field'] ?? null; + if ($fieldId === self::NPC_FIELD_SHEET && $frame === []) { + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->setFieldSheet($map, $index, $value === '' ? null : $value)); + } + if (in_array($fieldId, ['sprites2d.index', 'sprites2d.layer'], true) && $frame === []) { + $integer = filter_var($value, FILTER_VALIDATE_INT); + if ($integer === false) { + throw new SessionRefusal('A sheet character index or graphical layer must be an integer.'); + } + + return $this->applyNpcChange($map, static fn(NpcAuthoring $authoring): NpcChange => $fieldId === 'sprites2d.index' + ? $authoring->setFieldSheetIndex($map, $index, $integer) + : $authoring->setFieldSheetLayer($map, $index, $integer)); + } + // Field ids are unique within a frame, so the field and frame name the row. + $field = is_string($fieldId) ? array_find($this->collectNpcFields($inspector, $index, $frame), + static fn(array $candidate): bool => ($candidate['field'] ?? null) === $fieldId) : null; + + if ($field === null) { + throw new SessionRefusal('That row is no longer on the NPC; read it again.'); + } + if (self::describeNpcRow($inspector->records(), $index, $field, $frame)['kind'] === 'info') { + throw new SessionRefusal(sprintf('%s cannot be edited here.', trim((string) ($field['label'] ?? 'That row')))); + } + + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->applyField($inspector, $index, $frame, $field, $value)); + } + + /** + * Adds an item at an NPC row as one undo step: in a script frame a + * command after the row's (at the end when the key names no field) or a + * route step under its route; at the root a line in the row's dialogue + * variant, or a new variant. The key is a row's, or `{frame}` alone. + * + * @param array $key + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the NPC or frame is unknown. + */ + public function addNpcItem(string $mapId, int $revision, int $index, array $key): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); + $frame = self::requireNpcFrame($key['frame'] ?? []); + $fieldId = is_string($key['field'] ?? null) ? $key['field'] : ''; + + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->addSubItem($inspector, $index, $frame, $fieldId)); + } + + /** + * Moves the page an NPC row belongs to one place earlier (step -1) or + * later (step 1), as one undo step: talking runs the first available + * page. A row of no page changes nothing. + * + * @param array $key A row's key, as `readNpc` gave it. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the NPC is unknown. + */ + public function moveNpcItem(string $mapId, int $revision, int $index, array $key, int $step): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); + $fieldId = is_string($key['field'] ?? null) ? $key['field'] : ''; + + if ($step !== -1 && $step !== 1) { + throw new SessionRefusal('A page moves one place: step -1 or 1.'); + } + + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->moveSubItem($inspector, $index, $fieldId, $step)); + } + + /** + * Removes the item an NPC row belongs to (a route step, command, + * dialogue line or variant) as one undo step; a row that belongs to none + * changes nothing. + * + * @param array $key A row's key, as `readNpc` gave it. + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the map is unknown, stale or read-only, or the NPC or frame is unknown. + */ + public function removeNpcItem(string $mapId, int $revision, int $index, array $key): array + { + $map = $this->requireCurrentMap($mapId, $revision); + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); + $frame = self::requireNpcFrame($key['frame'] ?? []); + $fieldId = is_string($key['field'] ?? null) ? $key['field'] : ''; + + return $this->applyNpcChange($map, + static fn(NpcAuthoring $authoring): NpcChange => $authoring->removeSubItem($inspector, $index, $frame, $fieldId)); + } + + /** + * Makes one NPC change, records it, and says what it did. + * + * @param callable(NpcAuthoring): NpcChange $change + * @return array{revision: int, changed: bool, index: ?int, id: ?string, followedId: ?string, idReferences: list} + * @throws SessionRefusal When the change is refused or the map's source cannot take it. + */ + private function applyNpcChange(ProjectMap $map, callable $change): array + { + try { + $applied = $change(new NpcAuthoring($this->workspace)); + } catch (NpcRefusal $refusal) { + throw new SessionRefusal(implode("\n", [$refusal->getMessage(), ...$refusal->details]), previous: $refusal); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($applied->command !== null) { + $this->history->record($applied->command); + } + + return [ + 'revision' => $this->getMapRevision($map), + 'changed' => $applied->command !== null, + 'index' => $applied->index, + 'id' => $applied->npc?->getId(), + 'followedId' => $applied->followedId, + 'idReferences' => $applied->idReferences, + ]; + } + + /** + * An NPC's rows in a frame, refusing an NPC or frame that is gone. + * + * @param list $frame + * @return array> + * @throws SessionRefusal + */ + private function collectNpcFields(NpcInspector $inspector, int $index, array $frame): array + { + if ($inspector->map->getNpcs()->get($index) === null) { + throw new SessionRefusal(sprintf('%s has no NPC %d.', $inspector->map->mapId, $index)); + } + + return $inspector->getFields($index, $frame) + ?? throw new SessionRefusal(sprintf('%s is no longer there; read the NPC again.', $inspector->records()->describeFramePath($frame))); + } + + /** + * One NPC row as plain data, as any record pane's row is + * ({@see describeRecordRow()}). The id note is read here; assigning an + * id is its own request. + * + * @param array $field + * @param list $frame + * @return array + */ + private static function describeNpcRow(ProjectRecordDatabase $records, int $index, array $field, array $frame): array + { + $fieldId = $field['field'] ?? null; + // Keyed whether or not it can be edited; a read-only row is `info`. + $named = is_string($fieldId) && $fieldId !== NpcInspector::ASSIGN_ID_FIELD; + + $row = self::describeRecordRow($records, $index, $frame, $field, $named ? 'npc' : null); + + // A page's own rows (not its lines) can move it: talking runs the first available page, so order is meaning. + $item = $named && $frame === [] ? $records->locateItem($index, [], (string) $fieldId) : null; + if ($item?->kind === RecordItem::ENTRY && $item->framePath === [] && $item->childIndex === null && $item->listKey === null) { + $row['ordered'] = true; + } + + return $row; + } + + /** + * A frame of an NPC's commands, as a request or a row key names it. + * + * @return list + * @throws SessionRefusal When the frame is not a list of indexes and keys. + */ + private static function requireNpcFrame(mixed $frame): array + { + return self::requireFrame($frame, 'readNpc'); + } + + /** + * Undoes the last change, wherever it was made. + * + * @return array{label: ?string, maps: list, revisions: array, databases: list} What was undone, the maps it changed and their revisions now, and the database categories it changed. + */ + public function undo(): array + { + if ($this->changeSet !== null && $this->history->count() <= $this->changeSet['steps']) { + throw new SessionRefusal('Undo would reach back before the change set began; cancel the change set instead.'); + } + + return $this->traverseHistory(fn(): ?Command => $this->history->undo()); + } + + /** + * Redoes the last undone change. + * + * @return array{label: ?string, maps: list, revisions: array, databases: list} + */ + public function redo(): array + { + return $this->traverseHistory(fn(): ?Command => $this->history->redo()); + } + + /** + * Saves one map through its transaction, after the checks validation + * has for it, which never block this save: a map with errors (defects + * the player would meet) saves as a draft, and validation fails until + * they are fixed. {@see commitChanges()} is the write that refuses them. + * When the save would take cells existing saves may stand on, nothing is + * written until the author has seen where those saves move: the first + * call asks, and `write` with the question's `confirm` saves the map and + * the relocation step together. + * + * @return array{saved: string, warnings: list, errors: list>, backupFailures: list, relocated: list} + * |array{status: 'question', map: string, question: string, answers: list, + * relocations: list, to: array{int, int}}>, moves: list, + * contentVersion: int, confirm: string} + * |array{status: 'cancelled', map: string, saved: null} + * @throws SessionRefusal When the map is unknown or refuses to save, the answer is not write or cancel, or the + * relocations changed since they were shown. + */ + public function saveMap(string $mapId, ?string $answer = null, ?string $confirm = null): array + { + $this->refuseDuringChangeSet('Saving a map'); + $map = $this->requireMap($mapId); + try { + $relocation = SaveRelocationPlanner::plan($map, $this->workspace->projectRoot); + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + if ($relocation !== null) { + // Saves standing where the map is now solid or taken by an NPC move with the save, once the author has seen where. + if ($answer === null) { + return ['status' => 'question', 'map' => $mapId, 'question' => sprintf('Save %s and move existing saves off cells it makes solid or puts an NPC on?', $mapId), + 'answers' => [ + ['key' => 'cancel', 'label' => 'Cancel', 'description' => 'Save nothing.'], + ['key' => 'write', 'label' => 'Save and move saves', 'description' => sprintf('Saves the map and writes %s. Undo removes the save step.', SaveManifestStep::PATH)], + ], + 'relocations' => $relocation->relocations, 'moves' => $relocation->describeMoves(), 'contentVersion' => $relocation->contentVersion, + 'confirm' => $relocation->getFingerprint()]; + } + if ($answer === 'cancel') { + return ['status' => 'cancelled', 'map' => $mapId, 'saved' => null]; + } + if ($answer !== 'write') { + throw new SessionRefusal(sprintf('Answer write or cancel, not %s.', $answer)); + } + if ($confirm === null || ! hash_equals($relocation->getFingerprint(), $confirm)) { + throw new SessionRefusal('The map or the save manifest changed since the save relocations were shown, so nothing was written. Review them again.'); + } + } + $issues = MapValidator::validate($map, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); + $failures = []; + $step = $relocation?->apply(); + + try { + $saved = $map->save(function (string ...$paths) use (&$failures): void { + if ($this->backups->isEnabled() && $paths !== []) { + $failures = $this->backups->backup(...$paths)['failed']; + } + }); + } catch (MapSourceRefusal|FileSetTransactionFailure $refusal) { + // The map is not saved, so neither is the step that moves saves for it. + $relocation?->revert(); + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($step !== null) { + $this->history->record($step); + } + + return ['saved' => $saved, 'warnings' => MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)), + 'errors' => array_map(static fn(Issue $issue): array => $issue->describe(), MapValidator::selectSeverity($issues, Severity::ERROR)), + 'backupFailures' => array_values($failures), 'relocated' => $relocation?->describeMoves() ?? []]; + } + + /** + * Plays the game in the background, in its own graphical window, as the terminal editor's playtest does: from the + * project's own root, writing nothing into it, with its player data in a data root of its own holding the + * author's player settings (volume and mute included) and none of their saves. It starts on the open map at a cell, or at the game's title with the project's own + * starting position. The game reads maps from disk, so a playtest is refused while a map it plays from is unsaved: + * the start map, or, from the title, any map. + * + * @param PlaytestStart $start Where the playtest begins; from the title the map and cell are not used. + * @param string|null $renderer One of {@see describePlaytestOptions()}'s renderers; null is its default. + * @return array The playtest, as {@see describePlaytest()}. + * @throws SessionRefusal When the map is unknown or unsaved, a playtest is running, the renderer cannot be + * launched from here, or it cannot start. + */ + public function startPlaytest(?string $mapId, ?int $x, ?int $y, PlaytestStart $start = PlaytestStart::CELL, ?string $renderer = null): array + { + if ($this->playtest?->isRunning() === true) { + throw new SessionRefusal('A playtest is already running; stop it or close its window first.'); + } + $options = $this->describePlaytestOptions(); + $renderer ??= $options['defaultRenderer']; + $offered = array_find($options['renderers'], static fn(array $choice): bool => $choice['id'] === $renderer); + if ($offered === null) { + throw new SessionRefusal(sprintf('A playtest cannot use the %s renderer; choose one of %s.', $renderer, + implode(', ', array_column($options['renderers'], 'id')))); + } + if (! $offered['available']) { + throw new SessionRefusal(sprintf('%s cannot be used for a playtest: %s.', $offered['label'], $offered['reason'] ?? 'it is unavailable')); + } + if ($start === PlaytestStart::CELL) { + if ($mapId === null || $x === null || $y === null) { + throw new SessionRefusal('Choose the cell to start on, or play from the title.'); + } + $map = $this->requireMap($mapId); + if ($map->isDirty()) { + throw new SessionRefusal(sprintf('Save %s before playtesting it; the game reads the map on disk.', $mapId)); + } + if ($x < 0 || $y < 0 || $x >= $map->getWidth() || $y >= $map->getHeight()) { + throw new SessionRefusal(sprintf('%d, %d is outside %s.', $x, $y, $mapId)); + } + } else { + $unsaved = array_keys(array_filter($this->getMapsById(), static fn(ProjectMap $map): bool => $map->isDirty())); + if ($unsaved !== []) { + throw new SessionRefusal(sprintf('Save %s before playing from the title; the game reads every map on disk.', implode(', ', $unsaved))); + } + } + $launch = null; + try { + $launch = $start === PlaytestStart::CELL + ? PlaytestLaunch::fromCell($this->workspace->projectRoot, (string) $mapId, (int) $x, (int) $y) + : PlaytestLaunch::fromTitle($this->workspace->projectRoot); + $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); + $this->playtest = $launcher->start($launch, $renderer); + $this->playtestStart = $start; + } catch (RuntimeException $error) { + $launch?->destroy(); + throw new SessionRefusal(sprintf('The playtest could not start: %s', $error->getMessage()), previous: $error); + } + + return $this->describePlaytest(); + } + + /** + * Where a playtest from this editor can begin, and the renderers it can launch with. The first is Automatic, the + * default: the console picks GPUI when it is installed and usable, otherwise the terminal, in a terminal window + * of its own. Each installed renderer follows, with whether it is usable and why not when it is not. When the + * console cannot list them, Automatic is still offered and the issue says why. + * + * @return array{starts: list, renderers: list, + * defaultRenderer: string, issue: ?string} + */ + public function describePlaytestOptions(): array + { + $issue = null; + try { + $listed = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot)->listRenderers($this->workspace->projectRoot); + } catch (RuntimeException $error) { + $listed = ['renderers' => [], 'preferredId' => null]; + $issue = $error->getMessage(); + } + $preferred = array_find($listed['renderers'], static fn(array $renderer): bool => $renderer['id'] === $listed['preferredId']); + $automatic = ['id' => PlaytestLauncher::AUTOMATIC, + 'label' => $preferred === null ? 'Automatic' : sprintf('Automatic (%s)', $preferred['displayName']), 'available' => true, 'reason' => null]; + + return [ + 'starts' => array_map(static fn(PlaytestStart $start): string => $start->value, PlaytestStart::cases()), + 'renderers' => [$automatic, ...array_map(static fn(array $renderer): array => ['id' => $renderer['id'], + 'label' => $renderer['displayName'], 'available' => $renderer['available'], 'reason' => $renderer['reason']], $listed['renderers'])], + 'defaultRenderer' => PlaytestLauncher::AUTOMATIC, + 'issue' => $issue, + ]; + } + + /** + * Whether a playtest is running, where it started, and how the last one + * ended: its exit code and, when it failed, the end of its output. + * + * @return array{running: bool, start: ?string, map: ?string, x: ?int, y: ?int, stopped: bool, exitCode: ?int, log: ?string} + */ + public function describePlaytest(): array + { + $run = $this->playtest; + $running = $run?->isRunning() ?? false; + $exitCode = $running ? null : $run?->getExitCode(); + + return [ + 'running' => $running, + 'start' => $run === null ? null : $this->playtestStart->value, + 'map' => $run?->launch->mapId, + 'x' => $run?->launch->x, + 'y' => $run?->launch->y, + 'stopped' => $run?->wasStopped() ?? false, + 'exitCode' => $exitCode, + // Only a run that ended on its own with an error says why. + 'log' => $run !== null && ! $running && ! $run->wasStopped() && $exitCode !== 0 ? $run->readLogTail() : null, + ]; + } + + /** + * Ends a running playtest, game window and all. + * + * @return array The playtest, as {@see describePlaytest()}. + */ + public function stopPlaytest(): array + { + $this->playtest?->stop(); + + return $this->describePlaytest(); + } + + /** + * The project's battle test as the Battle Test dialog edits it, as RPG + * Maker's Troops > Battle Test does: the troop to preselect, the party + * and the arena, kept in system data as the Engine's ProjectBattleTest. + * With no party set the starting party stands in, as it will in the + * battle, and says so. Each member comes with what it may be given (the + * Engine's BattleTestChoices, the same as the in-game arena offers) and + * the setup with the Engine's own problems. Reads the record as it is + * now, unsaved edits included, or describes a draft the dialog is + * editing without writing it. + * + * @param array|null $draft An entry to describe instead of the record's. + * @return array + * @throws SessionRefusal When the project has no system settings. + */ + public function describeBattleTest(?array $draft = null): array + { + $entry = $draft ?? $this->readBattleTestEntry(); + $view = [ + 'battleTest' => $entry, + 'troops' => array_values(array_map(strval(...), $this->listDatabaseRecords('troops')['records'])), + 'arenas' => $this->readArenaChoices(), + 'actors' => array_map(static fn($actor): array => ['id' => $actor->getDefinitionId(), 'name' => $actor->getName()], + array_values(array_filter($this->workspace->actorDatabase->getActors(), static fn($actor): bool => $actor->hasDefinitionId()))), + 'maxMembers' => BattleTestSetup::MAX_MEMBERS, + 'troop' => null, 'arena' => null, 'source' => 'starting party', 'members' => [], 'problems' => [], 'issue' => null, + ]; + try { + $test = ProjectBattleTest::fromArray($entry); + } catch (InvalidArgumentException $invalid) { + return [...$view, 'issue' => 'The battle test cannot be read: ' . $invalid->getMessage()]; + } + + return ProjectDirectoryContext::run($this->workspace->projectRoot, function () use ($view, $test): array { + $actors = $this->workspace->actorDatabase->createActorStore(); + $items = $this->loadItemStore(); + $skills = $this->workspace->loadSkillCatalog(); + $summons = new SummonCutsceneLibrary($this->workspace->projectRoot . '/assets/Cutscenes/Summons'); + $view = [...$view, 'troop' => $test->troop, 'source' => $test->setup === null ? 'starting party' : 'battle test']; + try { + $setup = $test->createSetup($actors, $this->readStartingPartyReferences()); + } catch (InvalidArgumentException $invalid) { + return [...$view, 'arena' => $test->arena, 'issue' => $invalid->getMessage()]; + } + $choices = new BattleTestChoices($actors, $items, new BattleTestLoadoutCatalog($skills, $summons)); + $names = array_column($view['actors'], 'name', 'id'); + $members = []; + foreach ($setup->members as $index => $member) { + $described = ['actor' => $member->actorId, 'name' => $names[$member->actorId] ?? $member->actorId, 'level' => $member->level, + 'commands' => $member->commands === null ? null : array_column($member->commands, 'value'), + 'skills' => $member->skills, 'summons' => $member->summons, 'maxLevel' => null, 'equipment' => [], 'choices' => null]; + if ($actors->get($member->actorId) !== null) { + $described['maxLevel'] = $choices->getMaxLevel($member->actorId); + $described['equipment'] = array_map(static fn(string $slot): array => ['slot' => $slot, 'item' => $member->equipment[$slot] ?? null, + 'choices' => $choices->getEquipmentChoices($member->actorId, $slot)], $choices->getSlotNames($member->actorId)); + $described['choices'] = array_combine(['commands', 'skills', 'magic', 'summons'], array_map( + static fn(string $field): array => $choices->getLoadoutChoices($setup, $index, $field), ['commands', 'skills', 'magic', 'summons'])); + } + $members[] = $described; + } + + return [...$view, 'arena' => $setup->arena, 'members' => $members, + 'problems' => $setup->getProblems($actors, $items, $skills, $summons)]; + }); + } + + /** + * Sets the project's battle test, as the Battle Test dialog applies it: + * the whole entry written to system data through the record service in + * one undo step, read the Engine's way first so a shape it would not + * read is refused before anything changes. An empty entry removes it. + * + * @param array $battleTest As the Engine's ProjectBattleTest writes it. + * @return array The battle test, as {@see describeBattleTest()}. + * @throws SessionRefusal When the entry is not a battle test. + */ + public function applyBattleTest(array $battleTest): array + { + try { + $entry = ProjectBattleTest::fromArray($battleTest)->toArray(); + } catch (InvalidArgumentException $invalid) { + throw new SessionRefusal('That is not a battle test: ' . $invalid->getMessage(), previous: $invalid); + } + $row = array_find($this->readDatabaseRecord('system', 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === ProjectBattleTest::SYSTEM_KEY) + ?? throw new SessionRefusal('System settings have no battle test.'); + $this->applyDatabaseRecord('system', 0, $row['key'], + $entry === [] ? '' : (string) json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + + return $this->describeBattleTest(); + } + + /** + * Plays the battle test in the background in its own graphical window, + * as `ichiloto battle` does: the named troop, or the battle test's own, + * fought by the battle test's party in its arena. It runs from the + * project's own root with the battle test as it is now, unsaved edits + * included, as its launch, and writes nothing into the project; anything + * else a battle reads from disk must be saved first. + * + * @param int|null $troopIndex The troop to fight; null for the battle test's troop. + * @return array The battle test run, as {@see describeBattleTestRun()}. + * @throws SessionRefusal When no troop is chosen, the setup has problems, something is unsaved, one is running, or it cannot start. + */ + public function startBattleTest(?int $troopIndex = null): array + { + if ($this->battleTestRun?->isRunning() === true) { + throw new SessionRefusal('A battle test is already running; stop it or close its window first.'); + } + $troops = $this->requireRecordDatabase('troops'); + $troop = $troopIndex === null + ? (ProjectBattleTest::fromArray($this->readBattleTestEntry())->troop ?? null) + : trim(strval(($troops->getRecordByIndex($troopIndex) ?? throw new SessionRefusal(sprintf('troops has no record %d.', $troopIndex)))->get('name'))); + if ($troop === null || $troop === '') { + throw new SessionRefusal('Choose a troop to fight in the battle test.'); + } + $unsaved = $this->listBattleUnsavedChanges(); + if ($unsaved !== []) { + throw new SessionRefusal(sprintf('Save %s first; the battle reads them on disk.', implode(', ', $unsaved))); + } + $described = $this->describeBattleTest(); + if ($described['issue'] !== null || $described['problems'] !== []) { + throw new SessionRefusal("The battle test party cannot be set up:\n" . implode("\n", array_filter([$described['issue'], ...$described['problems']]))); + } + $launch = null; + try { + $launch = PlaytestLaunch::fromBattleTest($this->workspace->projectRoot, [...$this->readBattleTestEntry(), 'troop' => $troop]); + $launcher = PlaytestLauncher::discover(projectRoot: $this->workspace->projectRoot); + $this->battleTestRun = $launcher->start($launch, self::PLAYTEST_RENDERER); + $this->battleTestTroop = $troop; + } catch (RuntimeException $error) { + $launch?->destroy(); + throw new SessionRefusal(sprintf('The battle test could not start: %s', $error->getMessage()), previous: $error); + } + + return $this->describeBattleTestRun(); + } + + /** + * Whether a battle test is running, the troop it fights, and how the + * last one ended: its exit code and, when it failed, the end of its output. + * + * @return array{running: bool, troop: ?string, stopped: bool, exitCode: ?int, log: ?string} + */ + public function describeBattleTestRun(): array + { + $run = $this->battleTestRun; + $running = $run?->isRunning() ?? false; + $exitCode = $running ? null : $run?->getExitCode(); + + return [ + 'running' => $running, + 'troop' => $run === null ? null : $this->battleTestTroop, + // Troops composed in the test and kept there, waiting to be added ({@see addKeptBattleTestTroops()}). + 'keptTroops' => $run?->countKeptTroops() ?? 0, + 'stopped' => $run?->wasStopped() ?? false, + 'exitCode' => $exitCode, + 'log' => $run !== null && ! $running && ! $run->wasStopped() && $exitCode !== 0 ? $run->readLogTail() : null, + ]; + } + + /** + * Adds the troops a battle test composed and kept, each after the last + * troop and named after its members, one undo step each, unsaved until + * Troops is saved ({@see TroopAuthoring::addTroop()}). + * + * @return array{changed: bool, names: list, refused: list, records: list} + */ + public function addKeptBattleTestTroops(): array + { + $troops = $this->requireRecordDatabase('troops'); + $enemies = $this->listEnemyNames(); + $names = $refused = []; + foreach ($this->battleTestRun?->takeKeptTroops() ?? [] as $members) { + try { + $change = $this->changeRecord(fn(): RecordChange => $this->troopAuthoring->addTroop($troops, $members, $enemies)); + $names[] = $troops->getEntryLabel($troops->getRecordByIndex((int) $change->index)); + } catch (SessionRefusal $refusal) { + $refused[] = $refusal->getMessage(); + } + } + + return ['changed' => $names !== [], 'names' => $names, 'refused' => $refused, 'records' => $troops->getEntryLabels()]; + } + + /** + * Ends a running battle test, window and all. + * + * @return array The run, as {@see describeBattleTestRun()}. + */ + public function stopBattleTest(): array + { + $this->battleTestRun?->stop(); + + return $this->describeBattleTestRun(); + } + + /** The system data's battle test as it stands, unsaved edits included; empty when there is none. */ + private function readBattleTestEntry(): array + { + $system = $this->requireRecordDatabase('system')->getRecordByIndex(0) + ?? throw new SessionRefusal('The project has no system settings.'); + $entry = $system->get(ProjectBattleTest::SYSTEM_KEY); + + return is_array($entry) ? $entry : []; + } + + /** @return list The starting party as system data holds it now, unsaved edits included. */ + private function readStartingPartyReferences(): array + { + $starting = $this->workspace->getSystemField('startingParty'); + + return is_array($starting) ? array_values($starting) : []; + } + + /** @return list The battle presentation's arenas; none without one. */ + private function readArenaChoices(): array + { + try { + $catalog = $this->workspace->catalogs->loadBattlePresentation(); + } catch (\Throwable) { + return []; + } + $choices = $catalog?->getArenaChoices() ?? []; + + return array_map(static fn(string|int $id, string $name): array => ['id' => (string) $id, 'name' => $name], array_keys($choices), $choices); + } + + /** The Engine's item store for this project, as authored data reads it. */ + private function loadItemStore(): ItemStore + { + EngineDataBootstrap::ensure($this->workspace->projectRoot); + + $items = ConfigStore::has(ItemStore::class) ? ConfigStore::get(ItemStore::class) : null; + + return $items instanceof ItemStore ? $items + : throw new SessionRefusal('The project\'s items cannot be read, so a battle test party cannot be set up.'); + } + + /** + * What a battle reads from disk that has unsaved edits: every database + * but System (whose battle test the launch carries) and every + * cutscene, since summons play them. Maps are not part of a battle. + * + * @return list + */ + private function listBattleUnsavedChanges(): array + { + $system = $this->workspace->getRecordDatabase('system'); + $unsaved = []; + foreach ($this->workspace->listSaveableDatabases() as $label => $database) { + if ($database !== $system && $database->isDirty()) { + $unsaved[] = $label; + } + } + foreach ($this->workspace->cutscenes?->dirtyAssets() ?? [] as $asset) { + $unsaved[] = $asset->type->noun() . ' ' . $asset->id; + } + + return $unsaved; + } + + /** Ends what the session started that would outlive it: a running playtest or battle test. */ + public function close(): void + { + // Changes never committed leave the project as it was. + if ($this->changeSet !== null) { + $this->cancelChanges(); + } + foreach (array_keys($this->authoringCanvases) as $mapId) { + $this->closeAuthoringCanvas((string) $mapId); + } + $this->playtest?->stop(); + $this->battleTestRun?->stop(); + $this->audition?->stop(); + } + + /** + * Plays a music track or sound effect a field names, once through, to + * hear it ({@see AudioAudition}): the game's own players find and play + * the file. Another audition, or stopping, ends it. + * + * @param 'bgm'|'sfx' $kind The reference kind of the field. + * @return array{playing: array{kind: string, name: string}|null} + * @throws SessionRefusal When the kind is not audio, the file is missing or nothing on this computer can play it. + */ + public function playAudio(string $kind, string $name): array + { + if ((ReferenceCatalog::describeMedia($kind)['kind'] ?? null) !== 'audio') { + throw new SessionRefusal(sprintf('%s is not music or a sound effect.', $kind)); + } + $this->audition ??= new AudioAudition(new PreviewGame(), $this->workspace->projectRoot); + $refusal = $this->audition->play($kind, $name); + if ($refusal !== null) { + throw new SessionRefusal($refusal); + } + + return $this->describeAudio(); + } + + /** + * Stops the audition, if one is playing. + * + * @return array{playing: null} + */ + public function stopAudio(): array + { + $this->audition?->stop(); + + return ['playing' => null]; + } + + /** + * What the audition is playing, as kind and name; null once it has finished. + * + * @return array{playing: array{kind: string, name: string}|null} + */ + public function describeAudio(): array + { + return ['playing' => $this->audition?->describePlaying()]; + } + + /** Whether any map or database has changes not yet saved. */ + public function hasUnsavedChanges(): bool + { + return $this->workspace->hasUnsavedChanges(); + } + + /** + * Names every map, database and cutscene with changes not yet saved. + * + * @return list + */ + public function listUnsavedChanges(): array + { + return $this->workspace->listUnsavedChanges(); + } + + /** + * Saves every unsaved map, database and cutscene, as the terminal + * editor's Save All does, and names what is still unsaved after it: a + * map whose save would move its folder, or a document that failed. + * + * @return array{summary: string, skippedRenames: list, skippedRelocations: list, failures: list, warnings: list, backupFailures: list, unsaved: list} + */ + public function saveAll(): array + { + $this->refuseDuringChangeSet('Saving everything'); + $result = WorkspaceSave::saveAll($this->workspace, $this->backups); + + return [ + 'summary' => $result->summary, + 'skippedRenames' => $result->skippedRenames, + 'skippedRelocations' => $result->skippedRelocations, + 'failures' => $result->failures, + 'warnings' => $result->warnings, + 'errors' => $result->errors, + 'backupFailures' => $result->backupFailures, + 'unsaved' => $this->workspace->listUnsavedChanges(), + ]; + } + + /** + * Validates the project as it stands in this session, unsaved edits + * included, by the same checks as `ichiloto validate`. + * + * @param list|null $mapIds Only the issues about these maps; null for every issue. + * @return array{issues: list>, errors: int, warnings: int} + */ + /** + * Checks one map as saving it does ({@see MapValidator}), unsaved edits + * included, without the rest of the project: what an editor shows beside + * the map. Each issue names the cells it is about, when it names any. + * + * @return array{map: string, revision: int, issues: list>, errors: int, warnings: int} + * @throws SessionRefusal When the map is unknown. + */ + public function validateMap(string $mapId): array + { + $map = $this->requireMap($mapId); + $issues = MapValidator::validate($map, $this->getMapsById(), MapValidator::readInventoryCatalog($this->workspace)); + + return [ + 'map' => $map->mapId, + 'revision' => $this->getMapRevision($map), + 'issues' => array_map(static fn(Issue $issue): array => $issue->describe(), $issues), + 'errors' => count(MapValidator::selectSeverity($issues, Severity::ERROR)), + 'warnings' => count(MapValidator::selectSeverity($issues, Severity::WARNING)), + ]; + } + + public function validateProject(?array $mapIds = null): array + { + $issues = new ProjectValidator()->validate($this->workspace); + if ($mapIds !== null) { + $issues = array_values(array_filter($issues, static fn(Issue $issue): bool => in_array($issue->map, $mapIds, true))); + } + + return [ + 'issues' => array_map(static fn(Issue $issue): array => $issue->describe(), $issues), + 'errors' => count(MapValidator::selectSeverity($issues, Severity::ERROR)), + 'warnings' => count(MapValidator::selectSeverity($issues, Severity::WARNING)), + ]; + } + + /** + * Registers the project's own save migration class as the next step of + * its save compatibility manifest: the content version rises by exactly + * one and saves made before it run the class as they load. The step names + * the class alone, as the Engine reads a step as a class or as declared + * position edits, never both. The class must load from the project (its + * own vendor/autoload.php) and implement the Engine's + * ContentMigrationInterface; the staged manifest is read back as the game + * reads it before it is written. One undo step. Inside a change set it is + * undone with the change set, and a change set that also moves saves off + * map cells is refused whole. + * + * @return array{from: int, to: int, class: string, contentVersion: int} + * @throws SessionRefusal When the class is not named or would not load as a migration, or the manifest cannot be + * read or rewritten; nothing is written. + */ + public function registerSaveMigration(string $class): array + { + try { + $registration = SaveMigrationRegistration::plan($this->workspace->projectRoot, $this->workspace->projectId, $class); + $plan = $registration['plan']; + $plan->apply(); + } catch (RuntimeException $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $this->history->record(new GenericCommand('Register save migration', static fn() => $plan->apply(), static fn() => $plan->revert())); + if ($this->changeSet !== null) { + $this->changeSet['migrationRegistered'] = true; + } + + return ['from' => $registration['from'], 'to' => $registration['to'], 'class' => $registration['class'], 'contentVersion' => $registration['to']]; + } + + /** + * Retires a map in the save compatibility manifest: one new step whose + * `mapRetirements` entry moves every save standing on `$map` onto `$into`, + * at the `to` of the first landing rectangle holding its cell, else at + * `$to`, and marks `$into` visited for saves that visited `$map`. The + * content version rises by one. Every landing must be a cell a save can + * stand on in `$into` as the session holds it, unsaved edits included + * ({@see SaveLandingCells}): plain floor the player can reach, with no + * event or NPC on it. `$map` may still exist; delete it afterwards with + * `map.delete`. A map redrawn under its own ID retires into itself: the + * landings are then judged on its new layout. The retirement adds no map alias: a one-shot event that + * moved follows it through {@see addSaveAlias()}. + * + * One undo step. Inside a change set it is undone with the change set, + * and committing re-checks every landing it wrote against the maps as the + * change set leaves them, followed through the steps written after it. + * + * @param array{int, int} $to Where saves no landing holds land on `$into`. + * @param list>|null $landings Rectangles of `$map` (`x`, `y`, `width`, `height`), each one's `to` + * and optional `map`: the successor holding that part of the places, `$into` when left out. + * @return array{from: int, to: int, contentVersion: int, map: string, into: string} + * @throws SessionRefusal When the entry is malformed, `$into` is not a map, a landing cannot be + * stood on, or the manifest cannot be read or take the step; nothing is written. + */ + public function retireMap(string $map, string $into, array $to, ?array $landings = null): array + { + $entry = ['map' => trim($map), 'into' => trim($into), ...($landings === null ? [] : ['landings' => $landings]), 'to' => $to]; + try { + $retirement = SaveMapRetirement::readEntry($entry); + } catch (RuntimeException $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if (! isset($this->getMapsById()[$retirement->into])) { + throw new SessionRefusal(sprintf('There is no map %s for the saves on %s to move into.', $retirement->into, $retirement->map)); + } + // Each landing is judged on the map it lands on: `into`, or the successor a landing names. + $refusals = $this->describeLandingRefusals($this->workspace->analyzeReachability(), $retirement->getLandingPlaces()); + if ($refusals !== []) { + throw new SessionRefusal(sprintf('Saves on %s cannot land at %s. Choose plain floor the player can reach, with no event or NPC on it.', + $retirement->map, implode('; ', $refusals))); + } + try { + $planned = SaveMapRetirement::plan($this->workspace->projectRoot, $this->workspace->projectId, $entry); + $plan = $planned['plan']; + $plan->apply(); + } catch (RuntimeException $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $this->history->record(new GenericCommand(sprintf('Retire map %s', $retirement->map), static fn() => $plan->apply(), static fn() => $plan->revert())); + if ($this->changeSet !== null) { + $this->changeSet['retirements'][] = ['from' => $planned['from'], 'map' => $retirement->map]; + } + + return ['from' => $planned['from'], 'to' => $planned['to'], 'contentVersion' => $planned['to'], 'map' => $retirement->map, 'into' => $retirement->into]; + } + + /** + * Adds one alias to the save compatibility manifest, so every save reads + * the saved identity `$from` as `$to`, whatever its content version: a + * one-shot event that moved to another map (`old-map:Q` to `new-map:Q`), + * a renamed quest. `$to`, followed through the aliases already declared, + * must be a current identity of its category where the category has a + * catalogue, as validation requires. The staged manifest is read back as + * the game reads it. One undo step, and inside a change set it is undone + * with the change set. + * + * @param string $category A {@see ContentReferenceCategory} value, such as `one_shot_events`. + * @return array{category: string, from: string, to: string, added: bool} `added` is false when the manifest already had it. + * @throws SessionRefusal When the category is unknown, the target is not current, or the game would refuse the + * alias; nothing is written. + */ + public function addSaveAlias(string $category, string $from, string $to): array + { + $kind = ContentReferenceCategory::tryFrom(trim($category)) ?? throw new SessionRefusal(sprintf('%s is not a saved-content category; use one of %s.', + $category, implode(', ', array_map(static fn(ContentReferenceCategory $case): string => $case->value, ContentReferenceCategory::cases())))); + [$from, $to] = [trim($from), trim($to)]; + if ($from === '' || $to === '') { + throw new SessionRefusal('Name both the saved identity and the identity it now reads as.'); + } + $target = $this->followSaveAliases($kind, $to); + $known = new SaveCompatibilityValidator()->listKnownTargets($this->workspace, $kind); + if ($known !== null && ! in_array($target, $known, true)) { + throw new SessionRefusal(sprintf('%s is not a current %s identity, so saves cannot read %s as it. Create it first, or alias to one that exists.', + $target, $kind->value, $from)); + } + try { + $plan = SaveManifestAlias::plan($this->workspace->projectRoot, $this->workspace->projectId, $kind, $from, $to); + $plan?->apply(); + } catch (RuntimeException $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + if ($plan !== null) { + $this->history->record(new GenericCommand('Save alias', static fn() => $plan->apply(), static fn() => $plan->revert())); + } + + return ['category' => $kind->value, 'from' => $from, 'to' => $to, 'added' => $plan !== null]; + } + + /** Where an identity ends through the aliases the manifest already declares for its category. */ + private function followSaveAliases(ContentReferenceCategory $category, string $identity): string + { + try { + $manifest = PhpDataFile::evaluateIsolated($this->workspace->projectRoot . '/' . SaveManifestStep::PATH, $this->workspace->projectRoot); + } catch (Throwable) { + return $identity; // Planning the alias reports an unreadable manifest. + } + $aliases = []; + foreach (is_array($manifest) ? (array) ($manifest['aliases'][$category->value] ?? []) : [] as $alias) { + if (is_array($alias) && is_string($alias['from'] ?? null) && is_string($alias['to'] ?? null)) { + $aliases[trim($alias['from'])] = trim($alias['to']); + } + } + for ($seen = []; isset($aliases[$identity]) && ! isset($seen[$identity]); $identity = $aliases[$identity]) { + $seen[$identity] = true; + } + + return $identity; + } + + /** + * Why each save landing cannot be stood on, as the session holds its map. + * + * @param list $landings + * @return list One `field x 3, y 4 (it is solid)` per refused landing; empty when all may be stood on. + */ + private function describeLandingRefusals(ProjectReachability $reachability, array $landings): array + { + $refusals = []; + $cells = []; + foreach ($landings as ['map' => $mapId, 'cell' => [$x, $y]]) { + $map = $this->getMapsById()[$mapId] ?? null; + $report = $reachability->reports[$mapId] ?? null; + $reason = match (true) { + $map === null => 'there is no such map', + $report === null => sprintf('%s cannot be read as the game reads it', $mapId), + default => ($cells[$mapId] ??= SaveLandingCells::readMap($map, $report))->findLandingRefusal($x, $y), + }; + if ($reason !== null) { + $refusals[] = sprintf('%s x %d, y %d (%s)', $mapId, $x, $y, $reason); + } + } + + return $refusals; + } + + /** + * Why the map retirements a change set wrote no longer land on open + * floor, once its edits and the steps written after them have moved the + * maps under them; null when every landing may still be stood on. + * + * @param array{retirements?: list} $changes + */ + private function findRetirementRefusal(array $changes): ?string + { + $retirements = $changes['retirements'] ?? []; + if ($retirements === []) { + return null; + } + try { + $manifest = PhpDataFile::evaluateIsolated($this->workspace->projectRoot . '/' . SaveManifestStep::PATH, $this->workspace->projectRoot); + $reachability = $this->workspace->analyzeReachability(); + $problems = []; + foreach ($retirements as ['from' => $from, 'map' => $map]) { + $landings = SaveMapRetirement::followLandings(is_array($manifest) ? $manifest : [], $from, $map); + $refused = $this->describeLandingRefusals($reachability, array_map(static fn(array $landing): array + => ['map' => $landing['map'], 'cell' => $landing['cell']], $landings)); + if ($refused !== []) { + $problems[] = sprintf('saves on %s at %s', $map, implode('; ', $refused)); + } + } + } catch (Throwable $failure) { + return sprintf('The map retirements this change set wrote could not be checked: %s', rtrim($failure->getMessage(), '.') . '.'); + } + + return $problems === [] ? null : sprintf('The change set leaves map retirement landings where saves cannot stand: %s. ' + . 'Keep those cells plain floor the player can reach, or retire the map with other landings.', implode('; ', $problems)); + } + + /** + * Opens a change set: edits made from here on are written only by + * {@see commitChanges()}, and only when the project they leave is valid. + * This is the write path for anyone changing maps without an author + * reviewing each save (another agent, a script): what the player would + * meet is refused before it is written. An author's own saves stay + * drafts ({@see saveMap()}). + * + * Edits that write files at once (creating or duplicating a map, + * inserting or removing rows) may run inside it; a refused or cancelled + * change set undoes them. Saving, moving or deleting a map is refused + * while it is open. + * + * @return array{status: 'open', errors: int, warnings: int} + * @throws SessionRefusal When a change set is already open or edits are unsaved. + */ + public function beginChanges(): array + { + if ($this->changeSet !== null) { + throw new SessionRefusal('A change set is already open; commit or cancel it first.'); + } + if ($this->workspace->hasUnsavedChanges()) { + throw new SessionRefusal('Save or undo pending edits before a change set begins, so it holds only its own changes.'); + } + $baseline = new ProjectValidator()->validate($this->workspace); + $this->changeSet = [ + 'baseline' => $baseline, + 'revisions' => array_map($this->getMapRevision(...), $this->getMapsById()), + 'steps' => $this->history->count(), + 'dropped' => $this->history->countDropped(), + ]; + + return ['status' => 'open', 'errors' => count(MapValidator::selectSeverity($baseline, Severity::ERROR)), + 'warnings' => count(MapValidator::selectSeverity($baseline, Severity::WARNING))]; + } + + /** + * Writes the open change set when the project it leaves is valid, and + * otherwise writes nothing and undoes it. + * + * Validation runs over the project as saving would leave it. The change + * set is refused when a map it changed has any error (a defect the player + * would meet), whether the change set made it or found it there, or when + * it leaves a new error anywhere else (a door on another map now leading + * nowhere). Warnings never refuse it. + * A map retirement the change set wrote is checked again: each landing, + * moved by the save steps written after it, must still be a cell a save + * can stand on in the maps the change set leaves. + * + * Everything it left unsaved -- databases, cutscenes, maps and the save + * relocations its maps need -- is then written as one transaction: every + * document works out its file and proves the staged bytes read back as + * what it holds before any file is installed, and a file that cannot be + * installed puts back the ones already installed. A document that refuses + * refuses the change set exactly as an error does: nothing is written, + * and the change set is undone. Should a document fail to take up what it + * wrote once every file is installed, the change set is saved and + * `reason` says which, to reload before editing further. + * + * @param bool $relocateSaves Whether saves standing where a changed map is now solid may move to open floor; + * without it such a change set is refused, naming where they would move. + * @param bool $checkOnly Whether to only report what committing would find, then undo the change set. + * @return array{status: 'saved'|'refused'|'checked', maps: list, saved: list, written: array>, + * issues: list>, blocking: int, relocated: list, backupFailures: list, + * reason?: ?string, unrestored?: int, unrestoredFiles?: list} + * `written` names each document written and the files it wrote or removed, relative to the project root. + * @throws SessionRefusal When no change set is open. + */ + public function commitChanges(bool $relocateSaves = false, bool $checkOnly = false): array + { + $changes = $this->changeSet ?? throw new SessionRefusal('No change set is open; begin one first.'); + $changed = $this->listChangedMaps($changes); + $issues = new ProjectValidator()->validate($this->workspace); + $before = array_fill_keys(array_map(self::identifyIssue(...), $changes['baseline']), true); + $blocking = array_values(array_filter($issues, static fn(Issue $issue): bool => $issue->severity === Severity::ERROR + && (in_array($issue->map, $changed, true) || ! isset($before[self::identifyIssue($issue)])))); + $reported = array_values(array_filter($issues, static fn(Issue $issue): bool => in_array($issue->map, $changed, true) + || in_array($issue, $blocking, true))); + $outcome = ['maps' => $changed, 'saved' => [], 'written' => [], 'issues' => array_map(static fn(Issue $issue): array => $issue->describe(), $reported), + 'blocking' => count($blocking), 'relocated' => [], 'backupFailures' => []]; + + // A map the change set made holds no save, though its blank files were written when it was made. + $made = array_values(array_diff(array_keys($this->getMapsById()), array_keys($changes['revisions']))); + $changedMaps = array_values(array_filter(array_map(fn(string $mapId): ?ProjectMap => $this->getMapsById()[$mapId] ?? null, $changed), + static fn(?ProjectMap $map): bool => $map?->isDirty() === true && ! in_array($map->mapId, $made, true))); + // The change set writes one manifest step for every map that moves saves. + $relocations = SaveRelocationPlanner::planAll($changedMaps, $this->workspace->projectRoot)?->describeMoves() ?? []; + $retirementRefusal = $blocking === [] ? $this->findRetirementRefusal($changes) : null; + $reason = match (true) { + $blocking !== [] => sprintf('%d error%s the player would meet.', count($blocking), count($blocking) === 1 ? '' : 's'), + $retirementRefusal !== null => $retirementRefusal, + // A manifest step is a migration class or declared position edits, never both. + $relocations !== [] && ($changes['migrationRegistered'] ?? false) => 'This change set registers a save migration class and moves saves off cells a changed map now takes. ' + . 'The Engine has no save migration step that does both, so the change set is not written. Register the migration and change the map in separate change sets.', + $relocations !== [] && ! $relocateSaves => 'Saves may stand where a changed map is now solid; allow them to move to open floor, or change the map.', + default => null, + }; + if ($reason !== null || $checkOnly) { + $unrestored = $this->rollBackChanges($changes); + + return ['status' => $reason === null ? 'checked' : 'refused', ...$outcome, 'relocated' => $relocations, 'reason' => $reason, + ...($unrestored > 0 ? ['unrestored' => $unrestored] : [])]; + } + + $unsaved = array_values(array_filter($changed, fn(string $mapId): bool => ($this->getMapsById()[$mapId] ?? null)?->isDirty() === true)); + $backupFailures = []; + $backup = function (string ...$paths) use (&$backupFailures): void { + if ($this->backups->isEnabled() && $paths !== []) { + $backupFailures = $this->backups->backup(...$paths)['failed']; + } + }; + + try { + $saves = WorkspaceSave::prepareAll($this->workspace, $relocateSaves ? fn(Command $step) => $this->history->record($step) : null, $made); + $written = SaveSet::commit($this->workspace->projectRoot, $saves, $backup); + } catch (SaveSetAdoptionFailure $partial) { + // The files are written: the change set is, though the editor's view of it is not current. + $written = $partial->written; + $outcome['reason'] = $partial->getMessage(); + } catch (Throwable $refusal) { + $unrestored = $this->rollBackChanges($changes); + $files = $refusal instanceof SaveSetRefusal ? $refusal->unrestored : []; + + return ['status' => 'refused', ...$outcome, 'relocated' => $relocations, 'backupFailures' => array_values($backupFailures), + 'reason' => ($files === [] ? 'Nothing was written: ' : 'The change set was not written, and some files could not be put back: ') + . $refusal->getMessage(), + ...($unrestored > 0 ? ['unrestored' => $unrestored] : []), + ...($files === [] ? [] : ['unrestoredFiles' => array_map($this->describeProjectPath(...), $files)])]; + } + + $this->changeSet = null; + $outcome['saved'] = $unsaved; + $outcome['relocated'] = $relocateSaves ? $relocations : []; + $outcome['backupFailures'] = array_values($backupFailures); + $outcome['written'] = array_map(fn(array $paths): array => array_map($this->describeProjectPath(...), $paths), + array_filter($written, static fn(array $paths): bool => $paths !== [])); + + return ['status' => 'saved', ...$outcome]; + } + + /** + * Cancels the open change set, undoing every edit it made, those that + * wrote files at once included. + * + * @return array{status: 'cancelled', unrestored?: int} + * @throws SessionRefusal When no change set is open. + */ + public function cancelChanges(): array + { + $unrestored = $this->rollBackChanges($this->changeSet ?? throw new SessionRefusal('No change set is open.')); + + return ['status' => 'cancelled', ...($unrestored > 0 ? ['unrestored' => $unrestored] : [])]; + } + + /** + * The maps a change set changed: every map whose revision moved since it + * began, and every map it made. A file set written at once moves only the + * revisions of the maps whose content it changed ({@see getMapRevision()}). + * + * @param array{revisions: array} $changes + * @return list + */ + private function listChangedMaps(array $changes): array + { + $changed = []; + foreach ($this->getMapsById() as $mapId => $map) { + if (($changes['revisions'][$mapId] ?? null) !== $this->getMapRevision($map)) { + $changed[] = (string) $mapId; + } + } + + return $changed; + } + + /** + * Undoes every step of a change set and removes the maps it made, then + * closes it. + * + * @param array{revisions: array, steps: int, dropped: int} $changes + * @return int How many of its earliest steps could no longer be undone, dropped from a full undo history. + */ + private function rollBackChanges(array $changes): int + { + $unrestored = $this->history->countDropped() - $changes['dropped']; + $this->changeSet = null; + while ($this->history->count() > max(0, $changes['steps'] - $unrestored)) { + $this->undo(); + } + foreach (array_diff(array_keys($this->getMapsById()), array_keys($changes['revisions'])) as $made) { + $this->deleteMap((string) $made, confirmed: true); + } + + return $unrestored; + } + + /** A file of the project as the project names it: relative to its root. */ + private function describeProjectPath(string $path): string + { + // A file found through the root's real path names it as well as one found through the root as opened. + foreach (array_unique([$this->workspace->projectRoot, realpath($this->workspace->projectRoot) ?: $this->workspace->projectRoot]) as $root) { + $root = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + } + + return $path; + } + + /** An issue's identity, to tell an issue found before a change set from one it made. */ + private static function identifyIssue(Issue $issue): string + { + return implode("\0", [$issue->severity->value, $issue->where, $issue->message, $issue->code ?? '', $issue->map ?? '']); + } + + /** Refuses what a change set cannot hold or undo while one is open. */ + private function refuseDuringChangeSet(string $doing): void + { + if ($this->changeSet !== null) { + throw new SessionRefusal(sprintf('%s is not part of a change set; commit or cancel it first.', $doing)); + } + } + + /** + * Runs one history step and names the maps and database categories it + * changed, so an interface reloads what it shows of them. + * + * @param callable(): ?Command $step + * @return array{label: ?string, maps: list, revisions: array, databases: list} + */ + private function traverseHistory(callable $step): array + { + $workspace = $this->workspace; + $before = array_map($this->getMapRevision(...), $this->getMapsById()); + $databases = $this->listDatabaseVersions(); + try { $command = $step(); } + catch (MapSourceRefusal $error) { throw new SessionRefusal($error->getMessage(), previous: $error); } + // A step that put another workspace in place (a file set written at + // once, or its undo) read every category afresh. A map read afresh + // keeps its revision while it holds what it did, so only the maps + // the files changed are named. + $reloaded = $this->workspace !== $workspace; + $changed = $revisions = []; + foreach ($this->getMapsById() as $mapId => $map) { + $revision = $this->getMapRevision($map); + if ($revision !== ($before[$mapId] ?? null)) { + $changed[] = (string) $mapId; + $revisions[$mapId] = $revision; + } + } + $changedDatabases = $reloaded + ? array_keys($this->listDatabaseVersions()) + : array_keys(array_diff_assoc($this->listDatabaseVersions(), $databases)); + + return ['label' => $command?->label, 'maps' => $changed, 'revisions' => $revisions, 'databases' => array_values(array_map('strval', $changedDatabases))]; + } + + /** @return array Each category the session edits, by key, with its content version. */ + private function listDatabaseVersions(): array + { + $cutscenes = []; + foreach ($this->workspace->cutscenes === null ? [] : CutsceneType::cases() as $type) { + $cutscenes[$type->getRecordCategory()] = $this->workspace->cutscenes->records($type)->getContentVersion(); + } + + return [ + 'actors' => $this->workspace->actorDatabase->getContentVersion(), + ...array_map(static fn(ProjectRecordDatabase $database): string => $database->getContentVersion(), $this->workspace->recordDatabases), + ...$cutscenes, + ]; + } + + /** + * The rows an inspector read lists: the map's and its event's, or one + * frame of the event's inline commands alone. + * + * @param list $frame + * @return array> + * @throws SessionRefusal When the frame is gone. + */ + private function collectInspectorFields(ProjectMap $map, ?string $marker, array $frame = []): array + { + $inspector = $this->createMapInspector($map); + + if ($frame !== [] && $marker !== null) { + return $inspector->getEventFrameFields($map, $marker, $frame) + ?? throw new SessionRefusal('That frame of commands is no longer on the event; read the event again.'); + } + + return [...$inspector->getMapFields($map), ...($marker === null ? [] : $inspector->getEventFields($map, $marker))]; + } + + private function createMapInspector(ProjectMap $map): MapInspector + { + // A session serves graphical editors, which also author what only a + // graphical renderer shows. + return new MapInspector(new ReferenceCatalog($this->workspace, $map), graphical: true); + } + + /** + * One inspector row as plain data. + * + * @param array $field + * @return array + */ + private static function describeRow(array $field): array + { + $target = $field['target'] ?? null; + $control = MapInspector::findControl($field); + $choices = MapInspector::findChoiceValues($field); + $kind = match (true) { + ($field['editable'] ?? true) === false, $target === null => 'info', + // A row whose edit is one action (freezing an actor's identity), + // made by applying it with no value. + is_string($field['action'] ?? null) => 'action', + ($field['mapConditions'] ?? false) === true, ($field['conditions'] ?? false) === true => 'conditions', + ($field['worldWrites'] ?? false) === true => 'writes', + ($field['affinities'] ?? false) === true => 'affinities', + ($field['destination'] ?? false) === true => 'destination', + is_string($field['reference'] ?? null) => 'reference', + $choices !== null => 'options', + $control !== null => match ($control->type) { + InputControlType::INTEGER => 'integer', + InputControlType::FLOAT => 'float', + InputControlType::BOOLEAN => 'boolean', + default => 'text', + }, + default => 'info', + }; + $list = $field['list'] ?? $field['encounterList'] ?? $field[MapInspector::ARENA_RULE_LIST] ?? $field['bgmVariantList'] ?? $field['landmarkList'] ?? $field['areaList'] ?? $field['speakerList'] ?? null; + + // The terminal nests a row by indenting its label; an interface is given the depth instead. + $label = (string) ($field['label'] ?? ''); + $depth = intdiv(strlen($label) - strlen(ltrim($label, ' ')), 2); + $key = MapInspector::describeRowKey($field); + + return array_filter([ + 'label' => trim($label), + 'mapPlacement' => $field['mapPlacement'] ?? null, + // A list entry's row: which entry (and nested entry) it is part of, and its name within it. + 'entry' => is_array($field['entry'] ?? null) ? array_values($field['entry']) : null, + 'entryLabel' => is_string($field['entryLabel'] ?? null) ? $field['entryLabel'] : null, + // A list whose entries are made on the map (from selected cells), never added here. + 'listAddsOnMap' => ($field['listAddsOnMap'] ?? false) === true ? true : null, + 'name' => is_string($field['name'] ?? null) ? $field['name'] : null, + 'depth' => $depth, + // A section heading: a row that only names the rows after it. + 'heading' => $kind === 'info' && (string) ($field['value'] ?? '') === '' && $key === null && $list === null ? true : null, + // Multi-line text is read whole, as it is edited. + 'value' => $control?->type === InputControlType::MULTILINE ? $control->rawValue : (string) ($field['value'] ?? ''), + 'raw' => match ($kind) { + 'conditions' => is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? ''), + 'writes', 'affinities' => (string) ($field['value'] ?? ''), + 'text', 'integer', 'float', 'boolean' => $control?->rawValue, + default => null, + }, + 'kind' => $kind, + 'options' => $kind === 'options' ? $choices : null, + 'optionLabels' => $kind === 'options' && is_array($field['choices'] ?? null) + ? array_values(array_map(static fn(array $choice): string => (string) $choice['label'], $field['choices'])) : null, + 'reference' => match ($kind) { + 'reference' => $field['reference'], + 'destination' => 'maps', + default => null, + }, + // The map a map-local reference's choices come from, when its entry names one ('' until it does). + 'referenceMap' => $kind === 'reference' && is_string($field['referenceMap'] ?? null) ? $field['referenceMap'] : null, + // What the value looks like, when the reference names a picture: shown with the row and in its picker. + 'media' => $kind === 'reference' ? ReferenceCatalog::describeMedia((string) $field['reference']) : null, + 'imagePreview' => is_array($field['imagePreview'] ?? null) ? $field['imagePreview'] : null, + // How the choice of nothing reads, where the row says (the Engine's own attack). + 'noneLabel' => $kind === 'reference' && is_string($field['noneLabel'] ?? null) ? $field['noneLabel'] : null, + 'action' => $kind === 'action' ? $field['action'] : null, + // A list picked a member at a time: each pick toggles one member. + 'multi' => $kind === 'reference' && ($field['multi'] ?? false) === true ? true : null, + 'list' => is_array($list) && $target !== null ? ['index' => (int) ($list['index'] ?? 0)] : null, + // Conditions and writes are built a part at a time from these. + 'entries' => match ($kind) { + 'conditions' => ConditionCodec::decodeAll(is_string($field['encoded'] ?? null) ? $field['encoded'] : (string) ($field['value'] ?? '')), + 'writes' => WorldWriteCodec::decodeAll((string) ($field['value'] ?? '')), + // Affinities are built a row at a time: an element and its multiplier. + 'affinities' => self::describeAffinityEntries((string) ($field['value'] ?? '')), + default => null, + }, + 'writeTypes' => $kind === 'writes' && is_array($field['writeTypes'] ?? null) ? array_values($field['writeTypes']) : null, + // One axis of a coordinate pair under the heading before it. + 'axis' => in_array($field['axis'] ?? null, ['x', 'y'], true) ? $field['axis'] : null, + // A row about the rows before it: a warning (a line's emotion its speaker lacks) or an + // error (a chest whose reward the Engine cannot give). + 'tone' => in_array($field['tone'] ?? null, ['warning', 'error'], true) ? $field['tone'] : null, + // A record row: the frame of commands it opens, and the item adding and removing at it apply to. + 'frame' => is_array($field['frame'] ?? null) && $target !== null ? array_values($field['frame']) : null, + 'item' => ($field['item'] ?? false) === true ? true : null, + 'itemNoun' => is_string($field['itemNoun'] ?? null) ? $field['itemNoun'] : null, + 'listHeading' => ($field['listHeading'] ?? false) === true ? true : null, + 'childNoun' => is_string($field['childNoun'] ?? null) ? $field['childNoun'] : null, + 'ordered' => ($field['ordered'] ?? false) === true ? true : null, + 'multiline' => $control?->type === InputControlType::MULTILINE ? true : null, + 'key' => $key, + ], static fn(mixed $value): bool => $value !== null); + } + + /** + * An affinity line as the rows it is built from. + * + * @return list + */ + private static function describeAffinityEntries(string $line): array + { + $entries = []; + foreach (ElementAffinityCodec::decodeAll($line) as $element => $multiplier) { + $entries[] = ['element' => $element, 'multiplier' => $multiplier]; + } + + return $entries; + } + + /** @throws SessionRefusal */ + private function requireRecordDatabase(string $category): ProjectRecordDatabase + { + // A cutscene type's assets are records too, edited through the library's own record category. + $cutscene = CutsceneType::findByRecordCategory($category); + if ($cutscene !== null) { + return $this->workspace->cutscenes?->records($cutscene) + ?? throw new SessionRefusal(sprintf('This project has no %s assets to edit.', $cutscene->noun())); + } + if (! DatabaseCatalog::knows($category)) { + throw new SessionRefusal(sprintf('There is no database category %s.', $category)); + } + + return $this->workspace->getRecordDatabase($category) + ?? throw new SessionRefusal(sprintf('The %s database has no records to edit here.', $category)); + } + + /** + * A Database category as this session edits it: actors through the + * actor service, with what this editor's actor panes show, and every + * schema-driven category through the shared record rules. + * + * @throws SessionRefusal When the category is unknown. + */ + private function requireCategory(string $category): DatabaseCategory + { + if ($category === 'actors') { + return new ActorCategory($this->workspace, $this->actorAuthoring); + } + $cutscene = CutsceneType::findByRecordCategory($category); + if ($cutscene !== null) { + return new CutsceneRecordCategory($this->workspace->cutscenes + ?? throw new SessionRefusal(sprintf('This project has no %s assets to edit.', $cutscene->noun())), $cutscene); + } + + return new RecordCategory($this->requireRecordDatabase($category)); + } + + /** @return array */ + private function getMapsById(): array + { + return array_combine($this->workspace->mapIds, $this->workspace->maps); + } + + /** @throws SessionRefusal */ + private function requireMap(string $mapId): ProjectMap + { + return $this->getMapsById()[$mapId] ?? throw new SessionRefusal(sprintf('There is no map %s.', $mapId)); + } + + /** + * A map's revision as an interface is given it: its own state version, + * counted from the last revision given for an earlier reading of it. A + * reading that holds what that revision named keeps it; any other counts + * on from it. + */ + private function getMapRevision(ProjectMap $map): int + { + $known = $this->mapRevisions[$map->mapId] ?? null; + + if ($known === null || $known['map']->get() !== $map) { + $start = $known === null ? null : ($known['content'] === $map->contentFingerprint ? $known['last'] : $known['last'] + 1); + $known = ['map' => WeakReference::create($map), 'base' => $start === null ? 0 : $start - $map->stateVersion(), 'last' => 0, 'content' => '']; + } + + $known['last'] = $known['base'] + $map->stateVersion(); + $known['content'] = $map->contentFingerprint; + $this->mapRevisions[$map->mapId] = $known; + + return $known['last']; + } + + /** @throws SessionRefusal When the map changed since the revision the caller saw. */ + private function requireCurrentMap(string $mapId, int $revision): ProjectMap + { + $map = $this->requireMap($mapId); + if ($this->getMapRevision($map) !== $revision) { + throw new SessionRefusal(sprintf('%s changed since revision %d; reload it and edit again.', $mapId, $revision)); + } + + return $map; + } +} diff --git a/src/Session/InvalidRequest.php b/src/Session/InvalidRequest.php new file mode 100644 index 00000000..6e7094ba --- /dev/null +++ b/src/Session/InvalidRequest.php @@ -0,0 +1,12 @@ +requireCurrentMap($context['map'], $context['revision']); + } + $rows = match ($kind) { + 'database' => $this->readDatabaseRecord(strval($context['category'] ?? ''), $index, $frame)['rows'], + 'npc' => $this->readNpc($map->mapId, $index, $frame)['rows'], + 'event' => $this->readInspector($map->mapId, strval($key['marker'] ?? ''), $frame)['rows'], + default => throw new SessionRefusal('Unknown map-placement owner.'), + }; + $row = array_find($rows, static fn(array $row): bool => MapPlacement::areValuesEqual($row['key'] ?? null, $key)); + $placement = $row['mapPlacement'] ?? null; + if ($placement === null || !MapPlacement::areValuesEqual($placement, $expected) || ($row['kind'] ?? 'info') === 'info') { + throw new SessionRefusal('The authored placement changed or is read-only; open it again.'); + } + $preview = $this->requireCurrentMap($previewMap, $previewRevision); + try { + $preview->assertSourcesUnchanged(); + } catch (\RuntimeException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if (($placement['issue'] ?? null) !== null) { + throw new SessionRefusal($placement['issue']); + } + if (!isset($placement['points'][$point]) || $point < 0) { + throw new SessionRefusal('Choose an authored point first.'); + } + if ($x < 0 || $y < 0 || $x >= $preview->getWidth() || $y >= $preview->getHeight()) { + throw new SessionRefusal('Choose a cell inside the displayed map.'); + } + if ($origin !== null && (!array_is_list($origin) || count($origin) !== 2 || !is_int($origin[0]) || !is_int($origin[1]) + || $origin[0] < 0 || $origin[1] < 0 || $origin[0] >= $preview->getWidth() || $origin[1] >= $preview->getHeight())) { + throw new SessionRefusal('Choose a preview origin inside the displayed map.'); + } + if ($placement['kind'] === 'event-transfer') { + return $this->setEventDestination($map->mapId, $context['revision'], $key['marker'], $previewMap, $x, $y); + } + try { + $values = MapPlacement::getChanges($placement, $point, $x, $y, $origin, $previewMap); + if ($placement['kind'] === 'route' && $origin !== null) { + $selected = $placement['points'][$point]; + $shift = $selected['faceOnly'] ? [0, 0] : [$x - $origin[0] - $selected['point'][0], $y - $origin[1] - $selected['point'][1]]; + foreach ($placement['points'] as $i => $step) { + $px = $origin[0] + $step['point'][0] + ($i >= $point ? $shift[0] : 0); + $py = $origin[1] + $step['point'][1] + ($i >= $point ? $shift[1] : 0); + if ($px < 0 || $py < 0 || $px >= $preview->getWidth() || $py >= $preview->getHeight()) { + throw new \InvalidArgumentException(sprintf('Step %d would leave the preview map; adjust its steps or preview origin first.', $i + 1)); + } + } + } + } catch (\InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $changes = []; + foreach ($values as $field => $value) { + // An event's data rows are named by their path, its record rows (and every other owner's) by their field. + $found = array_find($rows, static fn(array $candidate): bool => $kind === 'event' && ($candidate['key']['target'] ?? null) !== EventRecordPane::TARGET + ? json_encode($candidate['key']['path'] ?? null, JSON_THROW_ON_ERROR) === $field + : ($candidate['key']['field'] ?? null) === $field); + if ($found === null || ($found['kind'] ?? 'info') === 'info') { + throw new SessionRefusal('That coordinate is not editable through the shared inspector.'); + } + if (($found['raw'] ?? $found['value'] ?? null) === $value) { + continue; + } + $changes[] = ['key' => $found['key'], 'value' => $value]; + } + if ($kind === 'database') { + return $this->applyDatabaseRecordValues($context['category'], $index, $changes, 'Place on map'); + } + $commands = []; + try { + foreach ($changes as $change) { + if ($kind === 'npc') { + $inspector = new NpcInspector($map, graphical: true, references: new ReferenceCatalog($this->workspace, $map)); + $field = array_find($this->collectNpcFields($inspector, $index, $frame), + static fn(array $field): bool => ($field['field'] ?? null) === $change['key']['field']); + $command = (new NpcAuthoring($this->workspace))->applyField($inspector, $index, $frame, $field, $change['value'])->command; + } else { + $field = $this->requireInspectorField($map, $change['key']); + $command = $this->createMapInspector($map)->apply($map, $field, $change['value']); + } + if ($command !== null) { + $commands[] = $command; + } + } + } catch (\Throwable $error) { + foreach (array_reverse($commands) as $command) { + $command->undo(); + } + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($commands !== []) { + $this->history->record(new CommandGroup('Place on map', $commands)); + } + return ['changed' => $commands !== [], 'revision' => $this->getMapRevision($map)]; + } + + /** + * The map a relative point's subject is read on, as its owner's references are chosen: an event's or NPC's own + * map, or a cinematic's declared start map. A reusable script names none. + * + * @return array{MapSubjectCells|null, string} The map's subjects, and why there are none. + */ + private function readPlacementSubjects(array $owner): array + { + if ($owner['kind'] !== 'database') { + return [MapSubjectCells::readMap($this->requireMap($owner['map'])), '']; + } + $type = CutsceneType::findByRecordCategory($owner['category']); + if ($type !== CutsceneType::CINEMATIC) { + return [null, 'This script runs on whichever map calls it; its relative points are shown from that map only.']; + } + $id = $this->workspace->cutscenes?->ids($type)[$owner['index']] ?? null; + $asset = $id === null ? null : $this->workspace->cutscenes?->find($type, $id); + $map = $asset === null ? null : new ReferenceCatalog($this->workspace, null, $asset)->getSubjectMap(); + + return $map === null + ? [null, 'Declare the cinematic\'s Start Map to show where its relative points land.'] + : [MapSubjectCells::readMap($map), '']; + } + + /** Attach a review of the actual owner, never a second editable model. */ + private function describePlacementRows(array $rows, array $owner): array + { + $hasPlacement = array_any($rows, static fn(array $row): bool => isset($row['mapPlacement'])); + if (!$hasPlacement && $owner['kind'] === 'database' && CutsceneType::findByRecordCategory($owner['category']) === null) { + return $rows; + } + $prefix = []; + $stripRoot = null; + $version = null; + $paths = []; + $document = null; + $sourceIssue = null; + try { + if ($owner['kind'] === 'database') { + $database = $this->requireRecordDatabase($owner['category']); + $record = $database->getRecordByIndex($owner['index']) + ?? throw new SessionRefusal('That placement record is gone.'); + $type = CutsceneType::findByRecordCategory($owner['category']); + if ($type !== null) { + $id = $this->workspace->cutscenes?->ids($type)[$owner['index']] ?? null; + $asset = ($id === null ? null : $this->workspace->cutscenes?->find($type, $id)) + ?? throw new SessionRefusal('That placement asset is gone.'); + $asset->assertSourcesUnchanged(); + $paths = $asset->paths(); + $sourcePath = $paths[array_key_last($paths)]; + $stripRoot = 'commands'; + $version = [spl_object_id($asset), $asset->stateVersion()]; + } else { + $sourcePath = $record->sourcePath ?? throw new SessionRefusal('This placement has no supported source-file owner.'); + $paths = [$sourcePath]; + $stripRoot = $database->schema->listPayloadKey; + $version = [spl_object_id($database), $database->getContentVersion(), $record->recordId]; + // A late external rewrite is not part of the loaded record. + $record->file?->composeContents($record->file->payload, checkDisk: true); + } + } else { + $map = $this->requireMap($owner['map']); + $map->assertSourcesUnchanged(); + $sourcePath = $map->dataPath; + $paths = [$sourcePath]; + $prefix = $owner['kind'] === 'npc' ? ['npcs', $owner['index']] : ['events', $owner['marker']]; + } + if (!$hasPlacement) { + return $rows; + } + $document = PhpArraySourceDocument::parse((string) file_get_contents($sourcePath)); + } catch (\Throwable $error) { + if (!$hasPlacement) { + throw new SessionRefusal('Authoring cannot safely read its current source: ' . $error->getMessage(), previous: $error); + } + $sourceIssue = 'Map placement cannot safely read its current source: ' . $error->getMessage(); + } + $sources = []; + foreach ($paths as $path) { + $sources[$path] = is_file($path) ? hash_file('sha256', $path) : null; + } + $subjects = null; + foreach ($rows as &$row) { + if (!isset($row['mapPlacement'])) { + continue; + } + if (array_any($row['mapPlacement']['points'] ?? [], static fn(array $point): bool => isset($point['relative']))) { + $subjects ??= $this->readPlacementSubjects($owner); + $row['mapPlacement'] = MapPlacement::resolveRelativePoints($row['mapPlacement'], ...$subjects); + } + $placement = &$row['mapPlacement']; + $placement['review'] = ['version' => $version, 'sources' => $sources]; + $sourcePaths = array_map(static function (array $path) use ($prefix, $stripRoot): array { + if ($stripRoot !== null && ($path[0] ?? null) === $stripRoot) { + array_shift($path); + } + return [...$prefix, ...$path]; + }, $placement['sourcePaths'] ?? []); + $issue = $sourceIssue ?? ($document === null ? null : MapPlacement::getSourceIssue($document, $sourcePaths)); + $placement['issue'] ??= $issue; + unset($placement); + } + unset($row); + return $rows; + } +} diff --git a/src/Session/PlacedPieceSession.php b/src/Session/PlacedPieceSession.php new file mode 100644 index 00000000..05517ebc --- /dev/null +++ b/src/Session/PlacedPieceSession.php @@ -0,0 +1,114 @@ +>} + * @throws SessionRefusal When the map is unknown or its tiles cannot be read. + */ + public function findPlacedPieces(string $mapId, int $x, int $y): array + { + $map = $this->requireMap($mapId); + try { + $found = PlacedPieces::findAt($map, $x, $y); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + + return ['map' => $map->mapId, 'revision' => $this->getMapRevision($map), + 'pieces' => array_map(static fn(array $copy): array => PlacedPieces::describe($map, $copy['piece'], $copy['x'], $copy['y']), $found)]; + } + + /** + * Removes a placed piece as one undo step: its glyphs, its tiles and, + * with `$footprint`, the collision its physical footprint set, which goes + * back to what the terminal layers left there say. + * + * @return array{revision: int, freed: int} + * @throws SessionRefusal When the map is stale or read-only, or no whole copy stands there. + */ + public function removePlacedPiece(string $mapId, int $revision, string $pieceId, int $x, int $y, bool $footprint): array + { + $map = $this->requireEditablePieceMap($mapId, $revision); + $piece = $this->requirePiece($map, $pieceId); + try { + $removed = PlacedPieces::remove($map, $piece, $x, $y, $footprint, sprintf('Remove %s', $piece->name)); + } catch (MapSourceRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $this->history->record($removed['command']); + + return ['revision' => $this->getMapRevision($map), 'freed' => $removed['cells']]; + } + + /** + * Moves a placed piece so its top-left stands on another cell, as one + * undo step, with its footprint when asked and the entrances at its doors. + * + * @return array{revision: int, entrances: list} + * @throws SessionRefusal When the map is stale or read-only, or the piece cannot be moved whole there. + */ + public function movePlacedPiece(string $mapId, int $revision, string $pieceId, array $from, array $to, bool $footprint, ?string $color): array + { + $map = $this->requireEditablePieceMap($mapId, $revision); + $piece = $this->requirePiece($map, $pieceId); + try { + $moved = PlacedPieces::move($map, $piece, $from[0], $from[1], $to[0], $to[1], $footprint, $color); + } catch (MapSourceRefusal|EventRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $this->history->record($moved['command']); + + return ['revision' => $this->getMapRevision($map), 'entrances' => $moved['entrances']]; + } + + /** + * Places a new entrance on the cell a placed piece's door is entered + * from, as one undo step. Its destination is set as any transfer's is. + * + * @param int $door The door's place in the piece's painted doors, from 0. + * @return array{revision: int, marker: string, cell: array{0: int, 1: int}} + * @throws SessionRefusal When the map is stale or read-only, the door is unknown or already has an entrance. + */ + public function addPlacedPieceEntrance(string $mapId, int $revision, string $pieceId, int $x, int $y, int $door): array + { + $map = $this->requireEditablePieceMap($mapId, $revision); + $piece = $this->requirePiece($map, $pieceId); + try { + $added = PlacedPieces::addEntrance($map, $piece, $x, $y, $door); + } catch (MapSourceRefusal|EventRefusal $refusal) { + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $this->history->record($added['command']); + + return ['revision' => $this->getMapRevision($map), 'marker' => $added['marker'], 'cell' => $added['cell']]; + } + + /** @throws SessionRefusal When the map is unknown, stale or read-only. */ + private function requireEditablePieceMap(string $mapId, int $revision): \Ichiloto\Editor\ProjectMap + { + $map = $this->requireCurrentMap($mapId, $revision); + if ($map->getGridSourceIssue() !== null) { + throw new SessionRefusal(sprintf('%s is read-only: %s', $mapId, $map->getGridSourceIssue())); + } + + return $map; + } +} diff --git a/src/Session/SessionHost.php b/src/Session/SessionHost.php new file mode 100644 index 00000000..e187c39b --- /dev/null +++ b/src/Session/SessionHost.php @@ -0,0 +1,1070 @@ +input)) !== false) { + $line = trim($line); + if ($line === '') { + continue; + } + $this->write($this->handle($line)); + } + // The editor has gone: nothing it started may outlive it. + $this->close(); + } + + /** Closes the session: nothing it started outlives it, and a change set it left open is undone. */ + public function close(): void + { + $this->session?->close(); + $this->session = null; + } + + /** + * Answers one request line. + * + * @return array + */ + public function handle(string $line): array + { + try { + $request = json_decode($line, true, 64, JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + return ['id' => null, 'error' => ['kind' => 'request', 'message' => 'Not JSON: ' . $error->getMessage()]]; + } + $id = is_array($request) ? ($request['id'] ?? null) : null; + $method = is_array($request) && is_string($request['method'] ?? null) ? $request['method'] : ''; + $params = is_array($request) && is_array($request['params'] ?? null) ? $request['params'] : []; + + try { + if (in_array($method, ['tileset.setOccupancy', 'occupancy.previewPiece', 'occupancy.stampPiece'], true)) { + // Preserve JSON object/list identity for this strict mask contract. + $wire = json_decode($line, false, 64, JSON_THROW_ON_ERROR); + foreach (['expected', 'value'] as $key) { + if (($wire->params ?? null) instanceof \stdClass && property_exists($wire->params, $key)) { + $params[$key] = $wire->params->{$key}; + } + } + } + return ['id' => $id, 'result' => $this->dispatch($method, $params)]; + } catch (SessionRefusal $refusal) { + return ['id' => $id, 'error' => ['kind' => 'refusal', 'message' => $refusal->getMessage()]]; + } catch (InvalidRequest $invalid) { + return ['id' => $id, 'error' => ['kind' => 'request', 'message' => $invalid->getMessage()]]; + } catch (Throwable $failure) { + fwrite($this->diagnostics, sprintf("[session-host] %s: %s\n%s\n", $failure::class, $failure->getMessage(), + $failure->getTraceAsString())); + + return ['id' => $id, 'error' => ['kind' => 'failure', 'message' => $failure->getMessage()]]; + } + } + + /** + * @param array $params + * @return array|list> + */ + private function dispatch(string $method, array $params): array + { + if ($method === 'hello') { + $protocol = $params['protocol'] ?? null; + if ($protocol !== self::PROTOCOL) { + throw new InvalidRequest(sprintf('This host speaks protocol %d, not %s.', self::PROTOCOL, var_export($protocol, true))); + } + $this->session = EditorSession::open(self::requireString($params, 'project')); + + return ['protocol' => self::PROTOCOL, 'project' => $this->session->describeProject()]; + } + + // Creating a project needs none open: an editor with no project offers it. + if ($method === 'project.create') { + try { + return ['root' => new ProjectCreator(ConsoleBinary::discover())->createProject( + self::requireString($params, 'title'), + self::requireString($params, 'directory'), + self::readOptionalString($params, 'hero'), + self::readOptionalString($params, 'battleEngine'), + ($params['install'] ?? false) === true, + )]; + } catch (RuntimeException $refused) { + throw new SessionRefusal($refused->getMessage(), previous: $refused); + } + } + + $session = $this->session ?? throw new InvalidRequest('Say hello with a project first.'); + + return match ($method) { + 'project.search' => $session->searchProject(self::requireString($params, 'query')), + 'maps.list' => $session->describeMaps(), + 'maps.kinds' => $session->listMapKinds(), + 'map.create' => $session->createMap( + is_string($params['name'] ?? null) ? $params['name'] : null, + is_string($params['kind'] ?? null) ? $params['kind'] : null, + self::requireInt($params, 'width'), + self::requireInt($params, 'height'), + self::readOptionalString($params, 'id'), + ), + 'map.duplicate' => $session->duplicateMap(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::readOptionalString($params, 'to')), + 'map.move' => $session->moveMap( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::readOptionalString($params, 'destination'), ($params['confirm'] ?? false) === true, + self::readOptionalString($params, 'to'), + ), + 'map.delete' => $session->deleteMap(self::requireString($params, 'map'), ($params['confirm'] ?? false) === true), + 'map.insertLines' => $session->insertMapLines( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'axis'), self::requireInt($params, 'at'), self::requireInt($params, 'count'), + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm'), + ), + 'map.removeLines' => $session->removeMapLines( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'axis'), self::requireInt($params, 'at'), self::requireInt($params, 'count'), + is_array($params['landing'] ?? null) ? $params['landing'] : null, + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm'), + ), + 'trees.types' => $session->listTreeTypes(self::requireString($params, 'map')), + 'trees.draw' => $session->drawTrees(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireArray($params, 'cells'), is_array($params['types'] ?? null) ? array_values(array_filter($params['types'], is_string(...))) : [], + ($params['erase'] ?? false) === true), + 'map.read' => $session->readMap(self::requireString($params, 'map')), + 'worldObjects.read' => $session->readWorldObject(self::requireString($params, 'map'), self::requireString($params, 'id')), + 'worldObjects.create' => $session->createWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::readOptionalString($params, 'resource'), self::readOptionalString($params, 'text')), + 'saveCompatibility.appendStep' => $session->registerSaveMigration(self::requireString($params, 'class')), + 'saveCompatibility.retireMap' => $session->retireMap(self::requireString($params, 'map'), self::requireString($params, 'into'), + self::requireCell($params, 'to'), self::readOptionalLandings($params)), + 'saveCompatibility.alias' => $session->addSaveAlias(self::requireString($params, 'category'), self::requireString($params, 'from'), + self::requireString($params, 'to')), + 'areas.read' => $session->readAreas(self::requireString($params, 'map')), + 'areas.create' => $session->createArea(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::readOptionalString($params, 'id'), EditorSession::readAreaShape($params)), + 'areas.setArea' => $session->setArea(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), EditorSession::readAreaShape($params)), + 'areas.rename' => $session->renameArea(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireString($params, 'to')), + 'areas.delete' => $session->deleteArea(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id')), + 'worldObjects.delete' => $session->deleteWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireString($params, 'id')), + 'worldObjects.apply' => $session->applyWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireArray($params, 'key'), self::requireString($params, 'value')), + 'worldObjects.add', 'worldObjects.remove' => $session->changeWorldObjectItem(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireArray($params, 'key'), $method === 'worldObjects.remove'), + 'worldObjects.place' => $session->placeWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + is_bool($params['coverage'] ?? false) ? ($params['coverage'] ?? false) : throw new InvalidRequest('"coverage" must be a boolean.')), + 'worldObjects.move' => $session->moveWorldObject(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireInt($params, 'dx'), self::requireInt($params, 'dy'), self::readChoices($params)), + 'worldObjects.moveVariant' => $session->moveWorldObjectVariant(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'id'), self::requireString($params, 'variant'), self::requireInt($params, 'offset')), + 'canvas.exchange' => $session->exchangeAuthoringCanvas(self::requireString($params, 'map'), self::requireLines($params, 'events'), + isset($params['variants']) ? self::requireArray($params, 'variants') : [], isset($params['seconds']) ? self::requireNumber($params, 'seconds') : 0, + self::readOptionalString($params, 'sessionId')), + 'canvas.close' => $session->closeAuthoringCanvas(self::requireString($params, 'map')), + 'map.world' => $session->readWorld(self::requireString($params, 'map'), + is_bool($params['tileShadows'] ?? false) ? ($params['tileShadows'] ?? false) : throw new InvalidRequest('"tileShadows" must be a boolean.')), + 'tiles.palette' => $session->readTilePalette(self::requireString($params, 'map')), + 'tilesets.preview' => $session->readTilesetPreview(self::requireInt($params, 'index')), + 'tileset.setOccupancy' => $session->setTilesetOccupancy( + self::requireInt($params, 'index'), self::requireFootprintId($params, 'tileset'), self::requireFootprintId($params, 'piece'), + self::requireFootprint($params, 'expected'), self::requireFootprint($params, 'value'), + ), + 'tilesets.mark' => array_key_exists('tiles', $params) + ? $session->setTilesetMarks(self::requireInt($params, 'index'), self::requireString($params, 'mark'), self::requireTileList($params), + self::readOptionalBool($params, 'marked', true)) + : $session->toggleTilesetMark( + self::requireInt($params, 'index'), + self::requireString($params, 'mark'), + self::requireInt($params, 'tile'), + ), + 'tilesets.appearance' => $session->setTileAppearance( + self::requireInt($params, 'index'), + self::requireString($params, 'tileset'), + self::requireSelectors($params), + self::readOptionalString($params, 'appearance'), + ), + 'assets.import' => $session->importAsset(self::requireString($params, 'source'), self::requireString($params, 'destination'), + self::readOptionalBool($params, 'replace')), + 'tilesets.freeCells' => $session->readTilesetFreeCells(self::requireInt($params, 'index'), self::requireString($params, 'tileset')), + 'tilesets.imageCells' => $session->readImageCells(self::requireInt($params, 'index'), self::requireString($params, 'tileset'), + self::requireString($params, 'source')), + 'tilesets.copyCells' => $session->copyTilesetCells(self::requireInt($params, 'index'), self::requireString($params, 'tileset'), + self::requireString($params, 'source'), self::requireCell($params, 'from'), self::requireCell($params, 'size'), + self::requireInt($params, 'tile')), + 'tilesets.createPiece' => $session->createTilesetPiece(self::requireInt($params, 'index'), self::requireString($params, 'tileset'), + self::requireString($params, 'name'), self::requireString($params, 'layer'), self::requireString($params, 'tileLayer'), + self::requireTileRows($params)), + 'tilesets.setPieceGlyph' => $session->setTilesetPieceGlyph(self::requireInt($params, 'index'), self::requireString($params, 'tileset'), + self::requireString($params, 'piece'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::requireString($params, 'glyph'), self::readOptionalString($params, 'color')), + 'tilesets.setPieceDoor' => $session->setTilesetPieceDoor(self::requireInt($params, 'index'), self::requireString($params, 'tileset'), + self::requireString($params, 'piece'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::readOptionalString($params, 'entrance')), + 'audio.play' => $session->playAudio(self::requireString($params, 'kind'), self::requireString($params, 'name')), + 'audio.stop' => $session->stopAudio(), + 'audio.status' => $session->describeAudio(), + 'tiles.read' => $session->readTiles(self::requireString($params, 'map')), + 'tiles.stamp' => $session->stampTiles( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireTileCells($params), + is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', + self::readTileChoices($params), + ), + 'tiles.paint' => $session->paintTiles( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireCells($params), + self::requireInt($params, 'tile'), + is_string($params['label'] ?? null) ? $params['label'] : 'Place tiles', + self::readTileChoices($params), + ), + 'canvas.shape' => $session->getToolShape( + self::requireString($params, 'map'), + self::requireString($params, 'tool'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + self::requireInt($params, 'size'), + array_key_exists('path', $params) ? self::requireCells(['cells' => $params['path']]) : [], + ), + 'canvas.fill' => $session->getFillRegion( + self::requireString($params, 'map'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::readOptionalString($params, 'layer'), + self::readOptionalString($params, 'tileLayer'), + ), + 'canvas.fillOccupancy' => $session->getOccupancyFillRegion( + self::requireString($params, 'map'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::requireInt($params, 'revision'), + ), + 'pieces.list' => $session->listPieces(self::requireString($params, 'map')), + 'pieces.preview' => $session->previewPiece( + self::requireString($params, 'map'), + self::requireString($params, 'piece'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + self::readOptionalString($params, 'color'), + ), + 'pieces.place' => array_key_exists('origins', $params) + ? $session->placePieceCopies(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'piece'), self::requireCells(['cells' => $params['origins']]), self::readOptionalString($params, 'color')) + : $session->placePiece( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'piece'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + self::readOptionalString($params, 'color'), + self::readOptionalBool($params, 'footprint'), + ), + 'map.review' => $session->writeMapReview(self::requireString($params, 'map'), self::requireString($params, 'directory'), + self::readOptionalRegion($params), array_key_exists('player', $params) && $params['player'] !== null ? self::requireCell($params, 'player') : null, + self::readOptionalString($params, 'heading')), + 'pieces.findPlaced' => $session->findPlacedPieces(self::requireString($params, 'map'), self::requireInt($params, 'x'), + self::requireInt($params, 'y')), + 'pieces.remove' => $session->removePlacedPiece(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'piece'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::readOptionalBool($params, 'footprint')), + 'pieces.move' => $session->movePlacedPiece(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'piece'), self::requireCell($params, 'from'), self::requireCell($params, 'to'), + self::readOptionalBool($params, 'footprint'), self::readOptionalString($params, 'color')), + 'pieces.addEntrance' => $session->addPlacedPieceEntrance(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'piece'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::requireInt($params, 'door')), + 'kinds.list' => $session->listKinds(self::requireString($params, 'map')), + 'kinds.preview' => $session->previewKind( + self::requireString($params, 'map'), + self::requireString($params, 'kind'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + ), + 'kinds.paint' => array_key_exists('cells', $params) + ? $session->paintKindCells(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'kind'), self::requireCells($params)) + : $session->paintKind( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'kind'), + self::requireCell($params, 'from'), + self::requireCell($params, 'to'), + ), + 'kinds.at' => $session->readKindAt(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y')), + 'kinds.previewConversion' => $session->previewKindConversion(self::requireString($params, 'map'), + ($params['mapping'] ?? []) === [] ? [] : self::requireKindMapping($params)), + 'kinds.convert' => $session->convertToKinds(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireKindMapping($params)), + 'map.pieceAt' => $session->readPieceAt(self::requireString($params, 'map'), self::requireString($params, 'layer'), + self::requireInt($params, 'x'), self::requireInt($params, 'y')), + 'selection.copy' => $session->copySelection(self::requireString($params, 'map'), self::requireString($params, 'layer'), + self::requireInt($params, 'x'), self::requireInt($params, 'y'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'selection.cut' => $session->cutSelection(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'selection.paste' => $session->pasteSelection(self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), self::readChoices($params)), + 'selection.clipboard' => $session->describeClipboard(), + 'map.coverage' => $session->readCoverage(self::requireString($params, 'map')), + 'map.passageAgreement' => $session->readPassageAgreement(self::requireString($params, 'map')), + 'map.measure' => $session->measureWalk(self::requireString($params, 'map'), self::requireCells(['cells' => $params['points'] ?? null])), + 'layer.drawTiles' => $session->drawLayerTiles( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::readChoices($params), + ), + 'map.paint' => $session->paint( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireCells($params), + self::requireString($params, 'symbol'), + array_key_exists('color', $params) && (is_string($params['color']) || $params['color'] === null) ? $params['color'] : null, + self::readChoices($params), + is_string($params['label'] ?? null) ? $params['label'] : 'Paint', + ), + 'map.paintRegion' => $session->paintRegion( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::requireLines($params, 'rows'), + array_key_exists('color', $params) && (is_string($params['color']) || $params['color'] === null) ? $params['color'] : null, + self::readChoices($params), + is_string($params['label'] ?? null) ? $params['label'] : 'Paint region', + ), + 'map.migrateOccupancy' => $session->migratePhysicalOccupancy( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + ), + 'map.paintOccupancy' => $session->paintOccupancy( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireCells($params), + self::requireInt($params, 'collision'), + array_key_exists('label', $params) ? self::requireString($params, 'label') : 'Paint collision', + ), + 'occupancy.previewPiece' => $session->previewOccupancyPiece( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'tileset'), self::requireString($params, 'piece'), + self::requireRecipe($params), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + ), + 'occupancy.stampPiece' => $session->stampOccupancyPiece( + self::requireString($params, 'map'), self::requireInt($params, 'revision'), + self::requireString($params, 'tileset'), self::requireString($params, 'piece'), + self::requireRecipe($params), self::requireInt($params, 'x'), self::requireInt($params, 'y'), + ), + 'layer.create' => $session->createLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + is_bool($params['decoration'] ?? false) ? ($params['decoration'] ?? false) : throw new InvalidRequest('"decoration" must be a boolean.'), + ), + 'layer.rename' => $session->renameLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + self::requireString($params, 'name'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'layer.remove' => $session->removeLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'layer.reorder' => $session->moveLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + is_int($params['order'] ?? null) || ! isset($params['order']) ? ($params['order'] ?? null) : throw new InvalidRequest('"order" must be an integer.'), + is_string($params['direction'] ?? null) || ! isset($params['direction']) ? ($params['direction'] ?? null) : throw new InvalidRequest('"direction" must be "above" or "below".'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'layer.decoration' => $session->setLayerDecoration( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'layer'), + is_bool($params['decoration'] ?? null) ? $params['decoration'] : throw new InvalidRequest('"decoration" must be a boolean.'), + is_bool($params['confirm'] ?? false) ? ($params['confirm'] ?? false) : throw new InvalidRequest('"confirm" must be a boolean.'), + ), + 'tileLayer.create' => $session->createTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + ), + 'tileLayer.rename' => $session->renameTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + self::requireString($params, 'newName'), + ), + 'tileLayer.remove' => $session->removeTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + ), + 'tileLayer.reorder' => $session->moveTileLayer( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + is_int($params['order'] ?? null) || ! isset($params['order']) ? ($params['order'] ?? null) : throw new InvalidRequest('"order" must be an integer.'), + is_string($params['direction'] ?? null) || ! isset($params['direction']) ? ($params['direction'] ?? null) : throw new InvalidRequest('"direction" must be "above" or "below".'), + ), + 'tileLayer.settings' => $session->setTileLayerSettings( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'name'), + is_array($params['offset'] ?? null) && array_is_list($params['offset']) && count($params['offset']) === 2 + && array_all($params['offset'], static fn(mixed $value): bool => is_int($value) || is_float($value)) + ? $params['offset'] : throw new InvalidRequest('"offset" must be two numbers, across and down.'), + array_key_exists('movesWith', $params) && (is_string($params['movesWith']) || $params['movesWith'] === null) + ? $params['movesWith'] : throw new InvalidRequest('"movesWith" must be a gameplay layer name or null.'), + ), + 'map.save' => $session->saveMap(self::requireString($params, 'map'), + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm')), + 'mapPlacement.apply' => $session->applyMapPlacement( + is_array($params['context'] ?? null) ? $params['context'] : throw new InvalidRequest('A placement needs its owner.'), + self::requireKey($params), + is_array($params['expected'] ?? null) ? $params['expected'] : throw new InvalidRequest('A placement needs its descriptor.'), + self::requireString($params, 'previewMap'), self::requireInt($params, 'point'), + self::requireInt($params, 'x'), self::requireInt($params, 'y'), + isset($params['origin']) ? self::requireCell($params, 'origin') : null, + self::requireInt($params, 'previewRevision'), + ), + 'inspector.read' => $session->readInspector( + self::requireString($params, 'map'), + is_string($params['event'] ?? null) ? $params['event'] : null, + is_array($params['frame'] ?? []) ? ($params['frame'] ?? []) : throw new InvalidRequest('"frame" must be a list of indexes and keys.'), + ), + 'inspector.apply' => $session->applyInspector( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key the inspector gave.'), + is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + self::readOptionalString($params, 'answer'), + ), + 'inspector.add' => $session->addInspectorListEntry( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireKey($params), + ), + 'inspector.remove' => $session->removeInspectorListEntry( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireKey($params), + ), + 'event.types' => $session->listEventTypes(), + 'event.create' => $session->createEvent( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireCells($params), + self::requireString($params, 'type'), + self::readOptionalString($params, 'marker'), + ), + 'event.delete' => $session->deleteEvent( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + ), + 'event.move' => $session->moveEvent( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + self::requireInt($params, 'dx'), + self::requireInt($params, 'dy'), + ), + 'event.subject' => $session->setEventSubject( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + array_key_exists('subject', $params) && (is_string($params['subject']) || is_array($params['subject']) || $params['subject'] === null) + ? $params['subject'] : throw new InvalidRequest('"subject" must be {kind, id}, a world object id, or null to unbind.'), + array_key_exists('cells', $params) ? self::requireCells($params) : [], + ), + 'event.bounds' => $session->setEventBounds( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::requireInt($params, 'width'), + self::requireInt($params, 'height'), + ), + 'event.destination' => $session->setEventDestination( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + self::requireString($params, 'destination'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + self::readOptionalString($params, 'heading'), + ), + 'database.records' => $session->listDatabaseRecords(self::requireString($params, 'category')), + 'troops.page' => $session->readTroopPage(self::requireInt($params, 'index'), + is_string($params['arena'] ?? null) ? $params['arena'] : null, + is_array($params['moving'] ?? null) ? [self::requireInt($params['moving'], 'member'), + self::requireNumber($params['moving'], 'x'), self::requireNumber($params['moving'], 'depth')] : null), + 'troops.addMember' => $session->addTroopMember(self::requireInt($params, 'index'), self::requireString($params, 'enemy')), + 'troops.removeMember' => $session->removeTroopMember(self::requireInt($params, 'index'), self::requireInt($params, 'member')), + 'troops.clearMembers' => $session->clearTroopMembers(self::requireInt($params, 'index')), + 'troops.autoName' => $session->nameTroopAfterMembers(self::requireInt($params, 'index')), + 'arenas.preview' => $session->readArenaPreview(self::requireInt($params, 'index'), + is_int($params['troop'] ?? null) ? $params['troop'] : null, + is_array($params['ground'] ?? null) ? $params['ground'] : [], + is_array($params['footing'] ?? null) ? $params['footing'] : []), + 'arenas.setFooting' => $session->setArenaFooting(self::requireInt($params, 'index'), + match ($params['habitat'] ?? null) { + 'land' => EnemyHabitat::LAND, + 'water' => EnemyHabitat::WATER, + default => throw new InvalidRequest('"habitat" must be "land" or "water".'), + }, self::requireArray($params, 'points')), + 'troops.align' => $session->alignTroop(self::requireInt($params, 'index')), + 'troops.arrangeAutomatically' => $session->arrangeTroopAutomatically(self::requireInt($params, 'index')), + 'troops.placeMember' => $session->placeTroopMember(self::requireInt($params, 'index'), self::requireInt($params, 'member'), + self::requireNumber($params, 'x'), self::requireNumber($params, 'depth')), + 'actors.preview' => $session->readActorPreview(self::requireInt($params, 'index'), + is_string($params['arena'] ?? null) ? $params['arena'] : null), + 'enemies.preview' => $session->readEnemyPreview(self::requireInt($params, 'index'), + is_string($params['arena'] ?? null) ? $params['arena'] : null), + 'database.record' => $session->readDatabaseRecord( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['frame'] ?? []) ? ($params['frame'] ?? []) : throw new InvalidRequest('"frame" must be a list of indexes and keys.'), + ), + 'database.apply' => $session->applyDatabaseRecord( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key database.record gave.'), + is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + self::readOptionalString($params, 'answer'), + self::readOptionalString($params, 'confirm'), + ), + 'database.applyMany' => $session->applyDatabaseRecordValues( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['changes'] ?? null) ? array_values($params['changes']) : throw new InvalidRequest('"changes" must be a list of {key, value}.'), + self::requireString($params, 'label'), + ), + 'database.add' => $session->addDatabaseItem( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be a row key database.record gave, or {"frame": [...]}.'), + is_bool($params['child'] ?? false) ? ($params['child'] ?? false) : throw new InvalidRequest('"child" must be a boolean.'), + ), + 'database.remove' => $session->removeDatabaseItem( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key database.record gave.'), + ), + 'database.create' => $session->createDatabaseRecord(self::requireString($params, 'category'), self::readOptionalString($params, 'identity')), + 'database.duplicate' => $session->duplicateDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index'), + self::readOptionalString($params, 'identity')), + 'database.delete' => $session->deleteDatabaseRecord(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'mastery.page' => $session->readMasteryPage(self::requireInt($params, 'index')), + 'mastery.placeNode' => $session->placeMasteryNode(self::requireInt($params, 'index'), self::requireString($params, 'node'), + self::requireNumber($params, 'x'), self::requireNumber($params, 'y')), + 'mastery.toggleRequirement' => $session->toggleMasteryRequirement(self::requireInt($params, 'index'), + self::requireString($params, 'node'), self::requireString($params, 'requires')), + 'mastery.adopt' => $session->adoptMasterySkills(self::requireString($params, 'actor'), + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm')), + 'skills.retire' => $session->retireSkill(self::requireInt($params, 'index'), self::readOptionalString($params, 'replacement'), + self::readOptionalString($params, 'answer'), self::readOptionalString($params, 'confirm')), + 'database.move' => $session->moveDatabaseRecord( + self::requireString($params, 'category'), + self::requireInt($params, 'index'), + in_array($params['direction'] ?? null, ['up', 'down'], true) ? $params['direction'] : throw new InvalidRequest('"direction" must be "up" or "down".'), + ), + 'database.save' => $session->saveDatabase(self::requireString($params, 'category')), + 'cutscenes.timeline' => $session->describeCutsceneTimeline(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'cutscenes.presentation' => $session->selectCutscenePresentation(self::requireString($params, 'category'), + self::requireInt($params, 'index'), self::requireString($params, 'presentation')), + 'cutscenes.separate' => $session->separateCutsceneSequences(self::requireString($params, 'category'), self::requireInt($params, 'index')), + 'cutscenes.stage' => $session->setCutsceneStage(self::requireString($params, 'category'), self::requireInt($params, 'index'), + is_bool($params['present'] ?? null) ? $params['present'] : throw new InvalidRequest('"present" must be true or false.')), + 'cutscenes.preview' => $session->readCutscenePreview(self::requireString($params, 'category'), self::requireInt($params, 'index'), + self::requireInt($params, 'frame'), self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'cutscenes.cinematicPreview' => $session->startCinematicPreview(self::requireInt($params, 'index'), self::requireInt($params, 'width'), + self::requireInt($params, 'height'), ($params['play'] ?? false) === true, ($params['keep'] ?? false) === true), + 'cutscenes.cinematicControl' => $session->controlCinematicPreview(self::requireString($params, 'action'), + is_int($params['seconds'] ?? null) || is_float($params['seconds'] ?? null) ? (float) $params['seconds'] : 0.0, + is_int($params['delta'] ?? null) ? $params['delta'] : 0), + 'cutscenes.cinematicStop' => $session->stopCinematicPreview(), + 'cutscenes.cinematicScene' => $session->exchangeCinematicScene(self::requireLines($params, 'events'), self::readOptionalString($params, 'sessionId')), + 'cutscenes.cinematicSceneDetach' => $session->detachCinematicScene(), + 'cutscenes.effectField' => $session->showEffectOnField(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, + self::requireInt($params, 'width'), self::requireInt($params, 'height')), + 'cutscenes.effectFieldScene' => $session->exchangeEffectFieldScene(self::requireLines($params, 'events'), self::readOptionalString($params, 'sessionId')), + 'cutscenes.effectFieldClose' => $session->closeEffectField(), + 'cutscenes.effectBattlePreview' => $session->readEffectBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, + ($params['reducedMotion'] ?? false) === true, + EffectPresentation::tryFrom(is_string($params['presentation'] ?? null) ? $params['presentation'] : 'graphical') + ?? throw new InvalidRequest('"presentation" must be terminal or graphical.'), + is_int($params['authoredFrame'] ?? null) ? $params['authoredFrame'] : null, + is_int($params['binding'] ?? null) ? $params['binding'] : 0), + 'cutscenes.battlePreview' => $session->readSummonBattlePreview(self::requireInt($params, 'index'), is_int($params['frame'] ?? null) ? $params['frame'] : 0, + ($params['reducedMotion'] ?? false) === true, + EffectPresentation::tryFrom(is_string($params['presentation'] ?? null) ? $params['presentation'] : 'graphical') + ?? throw new InvalidRequest('"presentation" must be terminal or graphical.'), + is_int($params['authoredFrame'] ?? null) ? $params['authoredFrame'] : null), + 'references.list' => $session->listReferences( + is_string($params['map'] ?? null) ? $params['map'] : null, + self::requireString($params, 'category'), + is_array($params['record'] ?? null) ? $params['record'] : null, + ), + 'conditions.grammar' => $session->describeWorldStateGrammar(), + 'conditions.encode' => $session->encodeWorldState( + self::requireString($params, 'codec'), + is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), + is_array($params['writeTypes'] ?? null) ? array_values(array_filter($params['writeTypes'], is_string(...))) : null, + ), + 'battlers.describe' => $session->describeBattlerArt(self::requireString($params, 'side'), self::requireString($params, 'identity')), + 'animations.conversion' => $session->describeAnimationConversion(self::requireInt($params, 'index')), + 'animations.convert' => $session->convertAnimation( + self::requireInt($params, 'index'), + self::requireString($params, 'timeline'), + self::requireString($params, 'cadence'), + is_int($params['fps'] ?? null) ? $params['fps'] : null, + self::requireInt($params, 'ticksPerFrame'), + self::requireInt($params, 'restFrame'), + is_bool($params['includeFlash'] ?? true) ? ($params['includeFlash'] ?? true) : throw new InvalidRequest('"includeFlash" must be a boolean.'), + self::readOptionalString($params, 'binding'), + self::readOptionalString($params, 'answer'), + self::readOptionalString($params, 'confirm'), + ), + 'affinities.vocabulary' => $session->describeAffinityVocabulary(), + 'affinities.encode' => $session->encodeAffinities( + is_array($params['entries'] ?? null) ? $params['entries'] : throw new InvalidRequest('"entries" must be a list.'), + ), + 'npc.create' => $session->createNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + is_string($params['name'] ?? null) ? $params['name'] : '', + self::readOptionalString($params, 'id'), + ), + 'npc.move' => $session->moveNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + self::requireInt($params, 'x'), + self::requireInt($params, 'y'), + ), + 'npc.duplicate' => $session->duplicateNpc(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), + 'npc.delete' => $session->deleteNpc(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index')), + 'npc.assignId' => $session->assignNpcId(self::requireString($params, 'map'), self::requireInt($params, 'revision'), self::requireInt($params, 'index'), + self::readOptionalString($params, 'id')), + 'npc.read' => $session->readNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'index'), + is_array($params['frame'] ?? []) ? ($params['frame'] ?? []) : throw new InvalidRequest('"frame" must be a list of indexes and keys.'), + ), + 'npc.apply' => $session->applyNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key npc.read gave.'), + is_scalar($params['value'] ?? null) ? (string) $params['value'] : throw new InvalidRequest('"value" must be a string, number or boolean.'), + ), + 'npc.add' => $session->addNpcItem( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be a row key npc.read gave, or {"frame": [...]}.'), + ), + 'npc.adoptEvent' => $session->adoptEventOnNpc( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireString($params, 'marker'), + self::readOptionalString($params, 'npc'), + ), + 'npc.reorder' => $session->moveNpcItem( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key npc.read gave.'), + self::requireInt($params, 'step'), + ), + 'npc.remove' => $session->removeNpcItem( + self::requireString($params, 'map'), + self::requireInt($params, 'revision'), + self::requireInt($params, 'index'), + is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key npc.read gave.'), + ), + 'history.undo' => $session->undo(), + 'history.redo' => $session->redo(), + 'project.dirty' => ['dirty' => $session->hasUnsavedChanges(), 'unsaved' => $session->listUnsavedChanges()], + 'project.saveAll' => $session->saveAll(), + 'map.validate' => $session->validateMap(self::requireString($params, 'map')), + 'project.validate' => $session->validateProject(array_key_exists('maps', $params) ? self::requireLines($params, 'maps') : null), + 'changes.begin' => $session->beginChanges(), + 'changes.commit' => $session->commitChanges( + is_bool($params['relocateSaves'] ?? false) ? ($params['relocateSaves'] ?? false) : throw new InvalidRequest('"relocateSaves" must be a boolean.'), + is_bool($params['check'] ?? false) ? ($params['check'] ?? false) : throw new InvalidRequest('"check" must be a boolean.'), + ), + 'changes.cancel' => $session->cancelChanges(), + 'playtest.start' => self::startPlaytest($session, $params), + 'playtest.options' => $session->describePlaytestOptions(), + 'playtest.status' => $session->describePlaytest(), + 'playtest.stop' => $session->stopPlaytest(), + 'battleTest.describe' => $session->describeBattleTest(match (true) { + ! array_key_exists('battleTest', $params) => null, + is_array($params['battleTest']) => $params['battleTest'], + default => throw new InvalidRequest('"battleTest" must be a battle test object.'), + }), + 'battleTest.apply' => $session->applyBattleTest(is_array($params['battleTest'] ?? null) ? $params['battleTest'] + : throw new InvalidRequest('"battleTest" must be a battle test object.')), + 'battleTest.start' => $session->startBattleTest(array_key_exists('troop', $params) ? self::requireInt($params, 'troop') : null), + 'battleTest.status' => $session->describeBattleTestRun(), + 'battleTest.keepTroops' => $session->addKeptBattleTestTroops(), + 'battleTest.stop' => $session->stopBattleTest(), + default => throw new InvalidRequest(sprintf('Unknown method "%s".', $method)), + }; + } + + /** @param array $response */ + private function write(array $response): void + { + fwrite($this->output, json_encode($response, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"); + fflush($this->output); + } + + /** @param array $params */ + private static function requireFootprintId(array $params, string $key): string + { + $id = self::requireString($params, $key); + if (preg_match('/\A[a-z0-9][a-z0-9_-]*\z/', $id) !== 1) { + throw new InvalidRequest(sprintf('"%s" must be a stable lowercase tileset or piece id.', $key)); + } + + return $id; + } + + /** @param array $params @return list>|null */ + private static function requireFootprint(array $params, string $key): ?array + { + if (! array_key_exists($key, $params)) { + throw new InvalidRequest(sprintf('"%s" must be supplied as footprint rows or null.', $key)); + } + if ($params[$key] === null) { return null; } + try { + $rows = \Ichiloto\Editor\Maps\PhysicalFootprintCodec::decodeRows($params[$key]); + + return \Ichiloto\Editor\Maps\PhysicalFootprintCodec::exportRows($rows); + } catch (\InvalidArgumentException $error) { + throw new InvalidRequest(sprintf('"%s": %s', $key, $error->getMessage()), previous: $error); + } + } + + /** + * @param array $params + * @return list + */ + private static function requireSelectors(array $params): array + { + $selectors = $params['selectors'] ?? null; + if (! is_array($selectors) || ! array_is_list($selectors) || ! array_all($selectors, static fn(mixed $selector): bool + => is_int($selector) || is_string($selector))) { + throw new InvalidRequest('"selectors" must be a list of tile identities and sheet names.'); + } + + return $selectors; + } + + /** @param array $params */ + private static function requireString(array $params, string $key): string + { + return is_string($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be a string.', $key)); + } + + /** + * A playtest from a cell names its map and cell; one from the title names neither. A renderer, when named, is + * one the session offers. + * + * @param array $params + */ + private static function startPlaytest(EditorSession $session, array $params): array + { + $start = PlaytestStart::tryFrom(is_string($params['start'] ?? null) ? $params['start'] : PlaytestStart::CELL->value) + ?? throw new InvalidRequest('"start" must be "cell" or "title".'); + $renderer = $params['renderer'] ?? null; + if ($renderer !== null && ! is_string($renderer)) { + throw new InvalidRequest('"renderer" must be a renderer id.'); + } + if ($start === PlaytestStart::TITLE) { + return $session->startPlaytest(null, null, null, $start, $renderer); + } + + return $session->startPlaytest(self::requireString($params, 'map'), self::requireInt($params, 'x'), self::requireInt($params, 'y'), $start, $renderer); + } + + /** @param array $params */ + private static function requireInt(array $params, string $key): int + { + return is_int($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be an integer.', $key)); + } + + private static function requireArray(array $params, string $key): array + { + return is_array($params[$key] ?? null) ? $params[$key] : throw new InvalidRequest(sprintf('"%s" must be structured data.', $key)); + } + + private static function requireNumber(array $params, string $key): float + { + $value = $params[$key] ?? null; + return (is_int($value) || is_float($value)) && is_finite((float) $value) + ? (float) $value : throw new InvalidRequest(sprintf('"%s" must be a finite number.', $key)); + } + + private static function requireRecipe(array $params): array + { + return self::requireFootprint($params, 'expected') + ?? throw new InvalidRequest('"expected" must be the non-null physical footprint from pieces.list.'); + } + + /** @param array $params */ + /** + * A conversion's mapping: terrain kind ids by old plain glyph. + * + * @param array $params + * @return array + */ + private static function requireKindMapping(array $params): array + { + $mapping = $params['mapping'] ?? null; + if (! is_array($mapping) || $mapping === [] || array_is_list($mapping) && count($mapping) > 1 + || ! array_all($mapping, static fn(mixed $kind): bool => is_string($kind))) { + throw new InvalidRequest('"mapping" must map old glyphs to terrain kind ids, such as {";": "grassland"}.'); + } + + return array_combine(array_map(strval(...), array_keys($mapping)), array_values($mapping)); + } + + /** + * A rectangle of map cells as [x, y, width, height], when given. + * + * @param array $params + * @return array{0: int, 1: int, 2: int, 3: int}|null + */ + private static function readOptionalRegion(array $params): ?array + { + $region = $params['region'] ?? null; + if ($region === null) { + return null; + } + + return is_array($region) && array_is_list($region) && count($region) === 4 && array_all($region, static fn(mixed $value): bool => is_int($value)) + ? $region : throw new InvalidRequest('"region" must be [x, y, width, height] in whole cells.'); + } + + /** @param array $params */ + private static function readOptionalBool(array $params, string $key, bool $default = false): bool + { + $value = $params[$key] ?? $default; + + return is_bool($value) ? $value : throw new InvalidRequest(sprintf('"%s" must be a boolean when given.', $key)); + } + + /** + * A list of tile identities. + * + * @param array $params + * @return list + */ + private static function requireTileList(array $params): array + { + $tiles = $params['tiles'] ?? null; + + return is_array($tiles) && array_is_list($tiles) && array_all($tiles, static fn(mixed $tile): bool => is_int($tile)) + ? $tiles : throw new InvalidRequest('"tiles" must be a list of tile identities.'); + } + + /** + * A rectangle of tile identities, row by row. + * + * @param array $params + * @return list> + */ + private static function requireTileRows(array $params): array + { + $rows = $params['rows'] ?? null; + if (! is_array($rows) || ! array_is_list($rows) || $rows === [] || ! array_all($rows, static fn(mixed $row): bool => is_array($row) + && array_is_list($row) && $row !== [] && array_all($row, static fn(mixed $tile): bool => is_int($tile)))) { + throw new InvalidRequest('"rows" must be rows of tile identities.'); + } + + return $rows; + } + + private static function readOptionalString(array $params, string $key): ?string + { + return match (true) { + ($params[$key] ?? null) === null => null, + is_string($params[$key]) => $params[$key], + default => throw new InvalidRequest(sprintf('"%s" must be a string when given.', $key)), + }; + } + + /** + * @param array $params + * @return array + */ + private static function requireKey(array $params): array + { + return is_array($params['key'] ?? null) ? $params['key'] : throw new InvalidRequest('"key" must be the row key the inspector gave.'); + } + + /** + * @param array $params + * @return list + */ + private static function requireCells(array $params): array + { + $cells = $params['cells'] ?? null; + if (! is_array($cells) || ! array_is_list($cells) || ! array_all($cells, static fn(mixed $cell): bool => + is_array($cell) && count($cell) === 2 && is_int($cell[0] ?? null) && is_int($cell[1] ?? null))) { + throw new InvalidRequest('"cells" must be a list of [x, y] integer pairs.'); + } + + return $cells; + } + + /** + * @param array $params + * @return list + */ + private static function requireLines(array $params, string $key): array + { + $lines = array_key_exists($key, $params) ? $params[$key] : []; + if (! is_array($lines) || ! array_is_list($lines) || ! array_all($lines, static fn(mixed $line): bool => is_string($line))) { + throw new InvalidRequest(sprintf('"%s" must be a list of strings.', $key)); + } + + return $lines; + } + + /** + * @param array $params + * @return array{0: int, 1: int} + */ + private static function requireCell(array $params, string $key): array + { + $cell = $params[$key] ?? null; + + return is_array($cell) && array_is_list($cell) && count($cell) === 2 && is_int($cell[0]) && is_int($cell[1]) + ? $cell + : throw new InvalidRequest(sprintf('"%s" must be an [x, y] integer pair.', $key)); + } + + /** + * A map retirement's landing rectangles, when given: a list of objects, each read by the Engine. + * + * @param array $params + * @return list>|null + */ + private static function readOptionalLandings(array $params): ?array + { + $landings = $params['landings'] ?? null; + if ($landings === null) { + return null; + } + + return is_array($landings) && array_is_list($landings) && array_all($landings, static fn(mixed $landing): bool => is_array($landing)) + ? $landings + : throw new InvalidRequest('"landings" must be a list of {x, y, width, height, to, map?} rectangles when given.'); + } + + /** + * @param array $params + * @return list + */ + private static function requireTileCells(array $params): array + { + $cells = $params['cells'] ?? null; + if (! is_array($cells) || ! array_is_list($cells) || ! array_all($cells, static fn(mixed $cell): bool => is_array($cell) && count($cell) === 3 + && is_int($cell[0] ?? null) && is_int($cell[1] ?? null) && is_int($cell[2] ?? null))) { + throw new InvalidRequest('"cells" must be a list of [x, y, tile] integer triples.'); + } + + return $cells; + } + + /** + * @param array $params + * @return array + */ + private static function readChoices(array $params): array + { + $choices = $params['choices'] ?? []; + if (! is_array($choices) || ! array_all($choices, static fn(mixed $choice): bool => is_string($choice) || $choice === null)) { + throw new InvalidRequest('"choices" must map glyphs to a role key or null.'); + } + + return $choices; + } + + /** + * The role key the author chose for each tile that could stand for + * several glyphs, by tile entry. A tile that stands for a glyph always + * takes one, so there is no answer for none. + * + * @param array $params + * @return array + */ + private static function readTileChoices(array $params): array + { + $choices = $params['choices'] ?? []; + if (! is_array($choices) || ! array_all($choices, static fn(mixed $choice): bool => is_string($choice))) { + throw new InvalidRequest('"choices" must map tiles to a role key.'); + } + + return array_combine(array_map(strval(...), array_keys($choices)), $choices); + } +} diff --git a/src/Session/SessionRefusal.php b/src/Session/SessionRefusal.php new file mode 100644 index 00000000..75f2ea20 --- /dev/null +++ b/src/Session/SessionRefusal.php @@ -0,0 +1,15 @@ +workspace->projectRoot, $source, $destination, $replace); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + if ($import['plan'] !== null) { + $this->writeAssetPlan(new AssetFileCommand(sprintf('Import %s', $import['path']), $import['plan'])); + } + + return ['path' => $import['path'], 'width' => $import['width'], 'height' => $import['height'], 'changed' => $import['plan'] !== null]; + } + + /** + * The cells of a tileset record's plain sheets that hold no art yet, + * unsaved sheet choices included, by tile identity: where copied cells + * can go. A sheet that cannot be read is named instead. + * + * @return array{tileset: string, free: list, issues: list} + * @throws SessionRefusal When the record changed or the Engine would refuse the tileset. + */ + public function readTilesetFreeCells(int $index, string $tilesetId): array + { + $tileset = $this->requireEditedTileset($index, $tilesetId); + $assets = rtrim($this->workspace->projectRoot, '/') . '/assets'; + $free = []; + $issues = []; + foreach (TilesetSheetCells::describeSheets($tileset) as $sheet) { + try { + foreach (TilesetSheetCells::readFreeCells($tileset, $sheet['sheet'], $assets)['free'] as $row => $cells) { + foreach ($cells as $column => $isFree) { + if ($isFree) { + $free[] = $sheet['ids'][$row][$column]; + } + } + } + } catch (InvalidArgumentException $error) { + $issues[] = $error->getMessage(); + } + } + + return ['tileset' => $tileset->id, 'free' => $free, 'issues' => $issues]; + } + + /** + * An image cut into cells of the tileset's tile size, for choosing cells + * to copy: its size in cells and which cells are empty. + * + * @return array{source: string, tileSize: int, columns: int, rows: int, empty: list} + * @throws SessionRefusal When the image cannot be read or the tileset has no readable sheet to size its cells by. + */ + public function readImageCells(int $index, string $tilesetId, string $source): array + { + $tileset = $this->requireEditedTileset($index, $tilesetId); + $assets = rtrim($this->workspace->projectRoot, '/') . '/assets'; + $tileSize = $tileset->getUsableSheets($assets)['tileSize'] + ?? throw new SessionRefusal(sprintf('Tileset %s has no sheet that can be read yet, so its cells have no size. Choose its sheets first.', $tileset->name)); + try { + AssetImport::resolveDestination($assets, $source); + $bytes = is_file($assets . '/' . $source) ? file_get_contents($assets . '/' . $source) : false; + $image = PngPixels::decode($bytes === false ? '' : $bytes, $source); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + [$columns, $rows] = [intdiv($image->width, $tileSize), intdiv($image->height, $tileSize)]; + $empty = []; + for ($row = 0; $row < $rows; $row++) { + for ($column = 0; $column < $columns; $column++) { + if ($image->isTransparent($column * $tileSize, $row * $tileSize, $tileSize, $tileSize)) { + $empty[] = [$column, $row]; + } + } + } + + return ['source' => $source, 'tileSize' => $tileSize, 'columns' => $columns, 'rows' => $rows, 'empty' => $empty]; + } + + /** + * Copies a rectangle of an image's cells into free cells of one of the + * tileset's plain sheets, its top-left on the cell of a tile + * ({@see TilesetSheetCells::planCopy()}), as one undo step written at once. + * + * @param array{0: int, 1: int} $from The image cell the rectangle starts at. + * @param array{0: int, 1: int} $size Its width and height in cells. + * @param int $tile The plain tile whose sheet cell the rectangle's top-left lands on. + * @return array{sheet: string, asset: string, tiles: list>} The tile identities the copied cells now have. + * @throws SessionRefusal When the cells cannot be copied there, saying why; nothing is written then. + */ + public function copyTilesetCells(int $index, string $tilesetId, string $source, array $from, array $size, int $tile): array + { + $tileset = $this->requireEditedTileset($index, $tilesetId); + try { + [$sheet, $column, $row] = TileId::getPlainTileCell($tile); + $copy = TilesetSheetCells::planCopy($this->workspace->projectRoot, $tileset, $sheet->value, $source, $from, $size, [$column, $row]); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $this->writeAssetPlan(new AssetFileCommand(sprintf('Copy cells into sheet %s', $copy['sheet']), $copy['plan'])); + + return ['sheet' => $copy['sheet'], 'asset' => $copy['asset'], 'tiles' => $copy['tiles']]; + } + + /** + * A new stamped piece from a rectangle of sheet tiles, as one undo step: + * its id made from its name, its glyphs on the gameplay layer named, a + * blank glyph in every cell until the author gives each its own, and the + * tiles on the tile layer named. Collision, glyphs and doors are the + * author's to set; nothing is read from the art. + * + * @param list> $rows The tile identities, row by row, as the sheet selection holds them. + * @return array{id: string, records: list} + * @throws SessionRefusal When the record changed, a name is missing or the piece would be refused. + */ + public function createTilesetPiece(int $index, string $tilesetId, string $name, string $layer, string $tileLayer, array $rows): array + { + $this->requireEditedTileset($index, $tilesetId); + $width = count($rows[0] ?? []); + if ($width === 0 || ! array_is_list($rows) || array_any($rows, static fn(mixed $row): bool => ! is_array($row) || count($row) !== $width + || array_any($row, static fn(mixed $tile): bool => ! is_int($tile) || ! ($tile === TileId::EMPTY || TileId::isValid($tile))))) { + throw new SessionRefusal('Select a rectangle of sheet cells for the piece.'); + } + foreach (['name' => $name, 'glyph layer' => $layer, 'tile layer' => $tileLayer] as $what => $value) { + if (trim($value) === '') { + throw new SessionRefusal(sprintf('Give the piece a %s.', $what)); + } + } + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index); + $data = (array) $record?->toArray(); + $pieces = is_array($data['pieces'] ?? null) ? $data['pieces'] : []; + try { + $id = TilesetPieceAuthoring::createPieceId($name, array_map(strval(...), array_keys($pieces))); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + $piece = 'piece' . count($pieces); + $database = $this->requireCategory('tilesets'); + $made = []; + $change = function (callable $edit) use (&$made): void { + /** @var RecordChange $applied */ + $applied = $edit(); + if ($applied->command !== null) { + $made[] = $applied->command; + } + }; + try { + $change(static fn(): RecordChange => $database->addItem($index, [], null, false)); + foreach ([ + 'Id' => $id, + 'Name' => trim($name), + 'Layer' => trim($layer), + 'Glyphs' => implode("\n", TilesetPieceAuthoring::createBlankRows($width, count($rows))), + ] as $field => $value) { + $change(static fn(): RecordChange => $database->applyField($index, [], $piece . $field, $value, 'New piece')); + } + $change(static fn(): RecordChange => $database->addItem($index, [], $piece . 'Name', true)); + $change(static fn(): RecordChange => $database->applyField($index, [], $piece . 'Tiles0Layer', trim($tileLayer), 'New piece')); + $change(static fn(): RecordChange => $database->applyField($index, [], $piece . 'Tiles0Rows', + implode("\n", array_map(static fn(array $row): string => implode(' ', $row), $rows)), 'New piece')); + TilesetPiece::fromArray($id, (array) ((array) $record->toArray())['pieces'][$id], 'New piece'); + } catch (RecordRefusal|InvalidArgumentException|SessionRefusal $refusal) { + foreach (array_reverse($made) as $command) { + $command->undo(); + } + throw new SessionRefusal($refusal->getMessage(), previous: $refusal); + } + $this->history->record(new CommandGroup(sprintf('New piece %s', trim($name)), $made)); + + return ['id' => $id, 'records' => $database->getRecordLabels()]; + } + + /** + * Sets one cell's terminal glyph in a stamped piece, as one undo step + * through its Glyph Rows, every other cell keeping its source + * ({@see TilesetPieceAuthoring::rewriteGlyph()}). A space clears the cell. + * + * @param string|null $color The cell's colour by the brush contract: null keeps its own, '' none. + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the piece is gone, connected, or the glyph cannot be used. + */ + public function setTilesetPieceGlyph(int $index, string $tilesetId, string $pieceId, int $x, int $y, string $symbol, ?string $color): array + { + [$piece, $prefix] = $this->requireStampedPiece($index, $tilesetId, $pieceId); + try { + $rows = TilesetPieceAuthoring::rewriteGlyph(array_map(strval(...), (array) ($piece['glyphs'] ?? [])), $x, $y, $symbol, $color); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal($error->getMessage(), previous: $error); + } + + return $this->applyDatabaseRecord('tilesets', $index, ['field' => $prefix . 'Glyphs', 'frame' => []], implode("\n", $rows)); + } + + /** + * Sets the painted door at one cell of a stamped piece, `on` (walked onto) + * or `front` (entered from the cell in front), or with null takes it away, + * as one undo step through its Painted Doors row. + * + * @return array{changed: bool, records: list} + * @throws SessionRefusal When the piece is gone or connected, the cell is outside it, or the entrance is not one. + */ + public function setTilesetPieceDoor(int $index, string $tilesetId, string $pieceId, int $x, int $y, ?string $entrance): array + { + [$piece, $prefix] = $this->requireStampedPiece($index, $tilesetId, $pieceId); + $relation = $entrance === null ? null : (DoorwayEntrance::tryFrom($entrance) + ?? throw new SessionRefusal(sprintf('%s is not a door entrance; a door is entered on it or from its front.', $entrance))); + $glyphs = (array) ($piece['glyphs'] ?? []); + if ($y < 0 || $x < 0 || ! isset($glyphs[$y]) || $x >= count(\Ichiloto\Engine\IO\Console\TerminalText::parseSourceCells((string) $glyphs[$y]))) { + throw new SessionRefusal(sprintf('The piece has no cell %d, %d.', $x, $y)); + } + try { + $value = TilesetPieceAuthoring::setDoor($piece['doorway'] ?? null, $x, $y, $relation); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal('Repair the piece\'s Painted Doors row first: ' . $error->getMessage(), previous: $error); + } + + return $this->applyDatabaseRecord('tilesets', $index, ['field' => $prefix . 'Doorway', 'frame' => []], $value); + } + + /** + * The tileset record at an index, as the Engine reads it with its unsaved edits. + * + * @throws SessionRefusal When the record is not that tileset any more, or the Engine would refuse it. + */ + private function requireEditedTileset(int $index, string $tilesetId): Tileset + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + if ($record->recordId !== $tilesetId) { + throw new SessionRefusal('That tileset record changed; read the tileset list again.'); + } + try { + return Tileset::fromArray($record->recordId, (array) $record->toArray()); + } catch (InvalidArgumentException $error) { + throw new SessionRefusal(sprintf('Tileset %s cannot be read: %s', $tilesetId, $error->getMessage()), previous: $error); + } + } + + /** + * A stamped piece of a tileset record as authored, with the prefix of its record rows. + * + * @return array{0: array, 1: string} + * @throws SessionRefusal When the record changed or the piece is gone or connected. + */ + private function requireStampedPiece(int $index, string $tilesetId, string $pieceId): array + { + $record = $this->requireRecordDatabase('tilesets')->getRecordByIndex($index) + ?? throw new SessionRefusal(sprintf('tilesets has no record %d.', $index)); + if ($record->recordId !== $tilesetId) { + throw new SessionRefusal('That tileset record changed; read the tileset list again.'); + } + $pieces = ((array) $record->toArray())['pieces'] ?? []; + $position = is_array($pieces) ? array_search($pieceId, array_map(strval(...), array_keys($pieces)), true) : false; + if ($position === false || ! is_array($pieces[$pieceId])) { + throw new SessionRefusal(sprintf('%s is no longer in tileset %s; choose a piece again.', $pieceId, $tilesetId)); + } + if (($pieces[$pieceId]['connects'] ?? null) !== null) { + throw new SessionRefusal(sprintf('%s is drawn as connected cells; its glyphs are set per shape in the inspector.', $pieceId)); + } + + return [$pieces[$pieceId], 'piece' . $position]; + } + + /** Writes an artwork file at once and keeps the write in history, or refuses having written nothing. */ + private function writeAssetPlan(AssetFileCommand $command): void + { + try { + $command->execute(); + } catch (RuntimeException $failure) { + throw new SessionRefusal($failure->getMessage(), previous: $failure); + } + $this->history->record($command); + } +} diff --git a/src/Status/StatusLevel.php b/src/Status/StatusLevel.php index ff2114df..5f36ae45 100644 --- a/src/Status/StatusLevel.php +++ b/src/Status/StatusLevel.php @@ -9,10 +9,18 @@ /** * Classifies footer status messages so feedback severities are visually * distinct and expire on their own schedule. + * + * Colour carries meaning as much as the words do. Green reads as saved, so + * SUCCESS is only for something that reached disk (a save, or a command that + * writes files at once) or a check that passed. An edit held in memory until + * the author saves is INFO, however complete it is: the unsaved-changes + * marker says it is not yet saved. */ enum StatusLevel: string { + /** Blue: edits held until saved, navigation, previews and other updates. */ case INFO = 'info'; + /** Green: written to disk, or a check passed. */ case SUCCESS = 'success'; case WARN = 'warn'; case ERROR = 'error'; @@ -25,7 +33,7 @@ enum StatusLevel: string public function color(): Color { return match ($this) { - self::INFO => Color::WHITE, + self::INFO => Color::LIGHT_BLUE, self::SUCCESS => Color::LIGHT_GREEN, self::WARN => Color::YELLOW, self::ERROR => Color::LIGHT_RED, diff --git a/src/Storage/FileSetTransaction.php b/src/Storage/FileSetTransaction.php index e3189e1a..3b996d4a 100644 --- a/src/Storage/FileSetTransaction.php +++ b/src/Storage/FileSetTransaction.php @@ -57,6 +57,9 @@ final class FileSetTransaction */ private array $original = []; + /** @var array Read dependencies checked before any write. */ + private array $expectedSources = []; + /** * @var string[] Folders this transaction created, deepest last. */ @@ -94,6 +97,7 @@ public function __construct( */ public function write(string $path, string $contents): void { + $this->assertUndeclared($path); $this->targets[] = ['path' => $path, 'intent' => self::INTENT_WRITE, 'contents' => $contents]; } @@ -102,9 +106,59 @@ public function write(string $path, string $contents): void */ public function remove(string $path): void { + $this->assertUndeclared($path); $this->targets[] = ['path' => $path, 'intent' => self::INTENT_REMOVE, 'contents' => null]; } + /** + * Requires the source used to build this proposal to remain unchanged. + * Two documents of one set that read the same file must have read the + * same bytes. + */ + public function expectSource(string $path, ?string $contents, bool $allowMissing = false): void + { + $expected = ['contents' => $contents, 'allowMissing' => $allowMissing]; + + if (isset($this->expectedSources[$path]) && $this->expectedSources[$path]['contents'] !== $contents) { + throw new FileSetTransactionFailure(sprintf('%s was read twice with different contents', basename($path))); + } + + $this->expectedSources[$path] = $expected; + } + + /** Whether any source is required to remain unchanged. */ + public function hasExpectedSources(): bool + { + return $this->expectedSources !== []; + } + + /** A path is one target: two proposals for one file would race each other. */ + private function assertUndeclared(string $path): void + { + if (in_array($path, $this->paths(), true)) { + throw new FileSetTransactionFailure(sprintf('%s is written twice in one transaction', basename($path))); + } + } + + /** Checks loaded bytes without staging, creating folders or adopting new baselines. */ + public function assertSourcesUnchanged(): void + { + foreach ($this->expectedSources as $path => $expected) { + clearstatcache(true, $path); + $metadata = $this->files->isFile($path) ? $this->files->metadata($path) : null; + $contents = $this->files->read($path); + clearstatcache(true, $path); + $after = $this->files->isFile($path) ? $this->files->metadata($path) : null; + // Restore only a read-induced access-time change, never another author's metadata. + if ($metadata !== null && $after !== null && $after['accessedAt'] !== $metadata['accessedAt'] + && array_diff_assoc($after, $metadata) === ['accessedAt' => $after['accessedAt']] + && ! $this->files->restoreMetadata($path, $metadata)) { + throw new FileSetTransactionFailure(sprintf('%s access metadata could not be preserved during source review', $path)); + } + $this->assertExpectedSource($path, $expected, $contents); + } + } + /** * Whether anything has been declared. */ @@ -168,12 +222,7 @@ private function stageReserved(): array throw new FileSetTransactionFailure(sprintf('%s already exists', $this->folder)); } - foreach ($this->targets as $target) { - $path = $target['path']; - - if (isset($this->original[$path])) { - continue; - } + foreach (array_unique([...array_keys($this->expectedSources), ...$this->paths()]) as $path) { if ($this->files->isFile($path)) { // Capture metadata before reading: even a read can update the @@ -199,6 +248,10 @@ private function stageReserved(): array $this->original[$path] = ['contents' => null, 'metadata' => null]; } + foreach ($this->expectedSources as $path => $expected) { + $this->assertExpectedSource($path, $expected, $this->original[$path]['contents']); + } + foreach ($foldersToCreate as $missing) { if (! $this->files->makeDirectory($missing)) { $this->discard(); @@ -264,6 +317,16 @@ public function commit( $this->stage(); } + // Callers may evaluate staged files before accepting them. Refuse an + // external edit made during that review, before installing anything. + try { + $this->assertSourcesUnchanged(); + } catch (Throwable $error) { + $this->isFinished = true; + $this->discard(); + throw $error; + } + if ($backup !== null) { // Once, before any destructive work, for the whole set. The // accepted backup policy normally absorbs its own failures; a @@ -290,6 +353,15 @@ public function commit( } } + // A backup callback may yield to an external author too. + try { + $this->assertSourcesUnchanged(); + } catch (Throwable $error) { + $this->isFinished = true; + $this->discard(); + throw $error; + } + $installed = []; foreach ($this->targets as $target) { @@ -358,6 +430,24 @@ public function rollBack(): void $this->discard(); } + /** @param array{contents: ?string, allowMissing: bool} $expected */ + private function assertExpectedSource(string $path, array $expected, ?string $contents): void + { + if (($contents !== null && $contents === $expected['contents']) + || ($contents === null && $expected['contents'] === null + && ! $this->files->isFile($path) && ! $this->files->isDirectory($path)) + || ($contents === null && $expected['allowMissing'] + && ! $this->files->isFile($path) && ! $this->files->isDirectory($path)) + ) { + return; + } + + throw new FileSetTransactionFailure(sprintf( + '%s changed after opening; reload before editing, saving, copying or moving', + $path, + )); + } + /** * Restores everything already installed, then refuses. * @@ -471,8 +561,8 @@ private function acquireReservations(): void } $directories = array_values(array_unique(array_map( - static fn(array $target): string => rtrim(dirname($target['path']), DIRECTORY_SEPARATOR), - $this->targets, + static fn(string $path): string => rtrim(dirname($path), DIRECTORY_SEPARATOR), + [...$this->paths(), ...array_keys($this->expectedSources)], ))); if ($directories === []) { diff --git a/src/Storage/PreparedSave.php b/src/Storage/PreparedSave.php new file mode 100644 index 00000000..43b4531d --- /dev/null +++ b/src/Storage/PreparedSave.php @@ -0,0 +1,92 @@ +): void)|null $verify Proves the staged bytes, given each written + * path's staged copy; it throws to refuse the whole set. + * @param (Closure(): void)|null $adopt Adopts the written state once every file of the set is installed. + */ + public function __construct( + public readonly string $label, + private readonly Closure $declare, + private readonly ?Closure $verify = null, + private readonly ?Closure $adopt = null, + ) { + } + + /** + * A save with nothing to write that still moves the document's baseline: + * an edit that came back to what the file holds. + * + * @param Closure(): void $adopt + */ + public static function adoptOnly(string $label, Closure $adopt): self + { + return new self($label, static function (): void { + }, adopt: $adopt); + } + + /** + * The same save, followed by more once its state is adopted: an owner + * forgetting what the save took away. + * + * @param Closure(): void $after + */ + public function withAdoption(Closure $after): self + { + $adopt = $this->adopt; + + return new self($this->label, $this->declare, $this->verify, static function () use ($adopt, $after): void { + if ($adopt !== null) { + $adopt(); + } + $after(); + }); + } + + /** Declares this document's files in a transaction. */ + public function declare(FileSetTransaction $transaction): void + { + ($this->declare)($transaction); + } + + /** + * Proves the staged bytes before anything is installed. + * + * @param array $staged Staged copies by destination path. + */ + public function verify(array $staged): void + { + if ($this->verify !== null) { + ($this->verify)($staged); + } + } + + /** Adopts the written state. */ + public function adopt(): void + { + if ($this->adopt !== null) { + ($this->adopt)(); + } + } +} diff --git a/src/Storage/SaveSet.php b/src/Storage/SaveSet.php new file mode 100644 index 00000000..360c4a57 --- /dev/null +++ b/src/Storage/SaveSet.php @@ -0,0 +1,113 @@ + $saves The prepared saves. + * @param callable(string ...$paths): void|null $backup Receives the existing files about to be replaced or removed. + * @return array> The paths each document wrote or removed, by its label; a document + * whose save only adopted what its files already held writes none. + * @throws SaveSetRefusal When a document refuses or a file cannot be installed; nothing is left written. + * @throws SaveSetAdoptionFailure When every file was written but a document could not take up its files. + */ + public static function commit(string $folder, array $saves, ?callable $backup = null, ?FileSetOperations $files = null, bool $reserveFolder = false): array + { + $transaction = new FileSetTransaction($folder, $files ?? new FilesystemFileSetOperations(), $reserveFolder); + $written = []; + + foreach ($saves as $save) { + $before = count($transaction->paths()); + + try { + $save->declare($transaction); + } catch (Throwable $failure) { + throw new SaveSetRefusal($save->label, $failure); + } + + $written[$save->label] = [...$written[$save->label] ?? [], ...array_slice($transaction->paths(), $before)]; + } + + if (! $transaction->isEmpty() || $transaction->hasExpectedSources()) { + try { + $staged = $transaction->stage(); + } catch (Throwable $failure) { + throw new SaveSetRefusal(null, $failure); + } + + foreach ($saves as $save) { + try { + $save->verify($staged); + } catch (Throwable $failure) { + $transaction->rollBack(); + + throw new SaveSetRefusal($save->label, $failure); + } + } + + try { + $transaction->commit($backup); + } catch (Throwable $failure) { + throw new SaveSetRefusal(null, $failure); + } + } + + // Every file is on disk: each document takes up what it wrote, and + // one that cannot does not keep the others from it. + $failures = []; + + foreach ($saves as $save) { + try { + $save->adopt(); + } catch (Throwable $failure) { + $failures[$save->label] = $failure; + } + } + + if ($failures !== []) { + throw new SaveSetAdoptionFailure($written, $failures); + } + + return $written; + } + + /** + * Writes one document's prepared save alone, refusing as the document + * itself refuses. + * + * @param callable(string ...$paths): void|null $backup + * @return list Every path written or removed. + * @throws Throwable Whatever the document or the transaction refused with. + */ + public static function commitOne(string $folder, PreparedSave $save, ?callable $backup = null, ?FileSetOperations $files = null, bool $reserveFolder = false): array + { + try { + return array_merge(...array_values(self::commit($folder, [$save], $backup, $files, $reserveFolder))); + } catch (SaveSetRefusal $refusal) { + throw $refusal->getPrevious() ?? $refusal; + } + } +} diff --git a/src/Storage/SaveSetAdoptionFailure.php b/src/Storage/SaveSetAdoptionFailure.php new file mode 100644 index 00000000..24fc655b --- /dev/null +++ b/src/Storage/SaveSetAdoptionFailure.php @@ -0,0 +1,31 @@ +> $written The paths each document wrote or removed, by its label. + * @param array $failures Why each document could not take up its files, by its label. + */ + public function __construct(public readonly array $written, public readonly array $failures) + { + parent::__construct(sprintf( + 'Every file was written, but %s could not take up what was written (%s); reload the project before editing further.', + implode(', ', array_keys($failures)), + implode('; ', array_map(static fn(Throwable $failure): string => rtrim($failure->getMessage(), '.'), $failures)), + ), 0, array_values($failures)[0] ?? null); + } +} diff --git a/src/Storage/SaveSetRefusal.php b/src/Storage/SaveSetRefusal.php new file mode 100644 index 00000000..abbbfe7d --- /dev/null +++ b/src/Storage/SaveSetRefusal.php @@ -0,0 +1,34 @@ + Paths left in an unknown state when a rollback itself failed. */ + public readonly array $unrestored; + + /** + * @param string|null $label The document that refused, or null when installing the set failed. + */ + public function __construct(public readonly ?string $label, Throwable $previous) + { + $this->wasRolledBack = ! $previous instanceof FileSetTransactionFailure || $previous->wasRolledBack; + $this->unrestored = $previous instanceof FileSetTransactionFailure ? array_values($previous->unrestoredPaths) : []; + + parent::__construct($label === null ? $previous->getMessage() : sprintf('%s: %s', $label, $previous->getMessage()), 0, $previous); + } +} diff --git a/src/Storage/SourceSetPlan.php b/src/Storage/SourceSetPlan.php new file mode 100644 index 00000000..9f31ac07 --- /dev/null +++ b/src/Storage/SourceSetPlan.php @@ -0,0 +1,235 @@ + $watched Every source the plan read, by path. + * @param array $originals The bytes of each changed file, null for a + * file the plan creates: it must not exist until applied, and reverting removes it again. + * @param array $proposals The planned bytes of each changed file. + * @param array $beforeValues What each changed file reads back as now. + * @param array $afterValues What each changed file must read back as once written. + * @param Closure(): list $findWatchedPaths The paths of the file family the plan read, as they are on disk now. + * @param Closure(string, string): mixed $readBack Reads a staged file (destination path, staged path) as a comparable value. + * @param string $subject What the plan does, for refusals (an actor migration). + * @param string $family The files it reads, for refusals (Project actor/reference files). + */ + public function __construct( + private readonly string $root, + private readonly array $watched, + private readonly array $originals, + private readonly array $proposals, + private readonly array $beforeValues, + private readonly array $afterValues, + private readonly Closure $findWatchedPaths, + private readonly Closure $readBack, + private readonly string $subject, + private readonly string $family, + ) {} + + /** + * One plan that writes what each of several plans writes, together: each + * file is read back the way the plan that changes it reads it. The plans + * must change different files. + * + * @param list $plans The plans, none applied. + * @throws RuntimeException When two plans change the same file, or one is already applied. + */ + public static function combine(string $subject, string $family, array $plans): self + { + $watched = $originals = $proposals = $before = $after = $owners = []; + $root = null; + foreach ($plans as $plan) { + if ($plan->applied) { + throw new RuntimeException("The {$plan->subject} is already applied."); + } + $root ??= $plan->root; + foreach ($plan->proposals as $path => $source) { + if (isset($owners[$path])) { + throw new RuntimeException("{$path} is changed by both the {$owners[$path]->subject} and the {$plan->subject}."); + } + $owners[$path] = $plan; + $originals[$path] = $plan->originals[$path]; + $proposals[$path] = $source; + $before[$path] = $plan->beforeValues[$path]; + $after[$path] = $plan->afterValues[$path]; + } + $watched += $plan->watched; + } + + return new self( + (string) $root, + $watched, + $originals, + $proposals, + $before, + $after, + static fn(): array => array_values(array_unique(array_merge(...array_map( + static fn(SourceSetPlan $plan): array => ($plan->findWatchedPaths)(), $plans)))), + static fn(string $path, string $staged): mixed => ($owners[$path]->readBack)($path, $staged), + $subject, + $family, + ); + } + + /** @return list */ + public function getChangedPaths(): array { return array_keys($this->proposals); } + + /** @return array Null for a file the plan creates. */ + public function getOriginalSources(): array { return $this->originals; } + + /** @return array */ + public function getProposedSources(): array { return $this->proposals; } + + /** + * What the plan is exactly: every source it read and every byte it would + * write. An author who reviewed a plan confirms this; a plan made again + * from changed choices or changed files has another. + */ + public function getFingerprint(): string + { + return hash('sha256', serialize([$this->root, $this->watched, $this->originals, $this->proposals])); + } + + /** @return list Every changed path. */ + public function apply(): array + { + if (! $this->applied) { + $this->installSources($this->proposals, $this->afterValues); + $this->applied = true; + } + return $this->getChangedPaths(); + } + + /** + * Works out applying the plan without writing it, for {@see SaveSet} to + * install with the rest of a change set: every proposed file, every + * source it read required unchanged, and each staged file proven to read + * back as planned. Once installed, the plan counts as applied, so + * {@see revert()} undoes it. + */ + public function prepareApply(string $label): PreparedSave + { + if ($this->applied) { + throw new RuntimeException("The {$this->subject} is already applied."); + } + $this->assertSourcesUnchanged(); + + return new PreparedSave( + $label, + function (FileSetTransaction $transaction): void { + foreach ($this->watched as $path => $source) { + $transaction->expectSource($path, $source); + } + foreach ($this->proposals as $path => $source) { + if ($this->originals[$path] === null) { + // A file the plan creates must not exist until it does. + $transaction->expectSource($path, null); + } + $transaction->write($path, $source); + } + }, + function (array $staged): void { + foreach ($this->proposals as $path => $source) { + $value = ($this->readBack)($path, $staged[$path]); + if (serialize($value) !== serialize($this->afterValues[$path])) { + throw new RuntimeException("{$path} would not read back as the planned {$this->subject}; nothing was written."); + } + } + }, + function (): void { + $this->applied = true; + }, + ); + } + + public function revert(): void + { + if ($this->applied) { + $this->installSources($this->originals, $this->beforeValues); + $this->applied = false; + // A folder only the plan's created file lived in goes with it. + foreach (array_keys(array_filter($this->originals, static fn(?string $source): bool => $source === null)) as $path) { + $folder = dirname($path); + if (is_dir($folder) && (scandir($folder) ?: []) === ['.', '..']) { + rmdir($folder); + } + } + } + } + + /** @param array $sources Null removes a file. */ + private function installSources(array $sources, array $expectedValues): void + { + $this->assertSourcesUnchanged(); + if ($sources === []) { return; } + $transaction = new FileSetTransaction($this->root . '/assets'); + foreach ($sources as $path => $source) { + if ($source === null) { + $transaction->remove($path); + } else { + $transaction->write($path, $source); + } + } + try { + $staged = $transaction->stage(); + $this->assertSourcesUnchanged(); + foreach ($staged as $path => $temporary) { + $value = ($this->readBack)($path, $temporary); + if (serialize($value) !== serialize($expectedValues[$path])) { + throw new RuntimeException("{$path} would not read back as the planned {$this->subject}; nothing was written."); + } + } + $transaction->commit(); + } catch (Throwable $failure) { + $transaction->rollBack(); + throw $failure; + } + } + + private function assertSourcesUnchanged(): void + { + foreach ($this->originals as $path => $source) { + if ($source === null && is_file($path) !== $this->applied) { + throw new RuntimeException("{$path} " . ($this->applied ? 'was removed' : 'appeared') . " outside the {$this->subject}; reload and plan again."); + } + if ($source === null && $this->applied && file_get_contents($path) !== $this->proposals[$path]) { + throw new RuntimeException("{$path} changed outside the {$this->subject}; reload and plan again."); + } + } + foreach ($this->watched as $path => $source) { + $expected = $this->applied ? ($this->proposals[$path] ?? $source) : $source; + if (! is_file($path) || file_get_contents($path) !== $expected) { + throw new RuntimeException("{$path} changed outside the {$this->subject}; reload and plan again."); + } + } + $current = ($this->findWatchedPaths)(); + $expected = array_keys($this->watched); + sort($current, SORT_STRING); + sort($expected, SORT_STRING); + if ($current !== $expected) { + throw new RuntimeException("{$this->family} changed outside the {$this->subject}; reload and plan again."); + } + } +} diff --git a/src/Storage/WorkspaceSave.php b/src/Storage/WorkspaceSave.php new file mode 100644 index 00000000..edd3fe1e --- /dev/null +++ b/src/Storage/WorkspaceSave.php @@ -0,0 +1,231 @@ + $madeMaps Maps made since the last save, such as in an open change set: no save stands on + * them, whatever their blank files on disk hold. + * @return list + * @throws RuntimeException When a document cannot take its edits, a map would move its folder, or a map needs + * save relocations that were not allowed. Nothing is written. + */ + public static function prepareAll(ProjectWorkspace $workspace, ?Closure $recordRelocation = null, array $madeMaps = []): array + { + $saves = []; + // Categories editing through one settings file (Terms and Configuration write `config.php` + // through the project configuration) are prepared once, by that file, wherever they are listed. + $writers = []; + + foreach (SharedFileTransaction::groupByPath($workspace->listSaveableDatabases()) as $group) { + $dirty = array_filter($group, static fn(object $database): bool => $database->isDirty()); + + if ($dirty === []) { + continue; + } + + $shared = reset($dirty); + + if ($shared instanceof ProjectRecordDatabase && $shared->sharesBackingFile()) { + $saves[] = SharedFileTransaction::prepare(array_values($dirty)); + continue; + } + + foreach ($dirty as $database) { + $writer = $database instanceof ProjectRecordDatabase ? $database->getFileWriter() : $database; + if (isset($writers[spl_object_id($writer)])) { + continue; + } + $writers[spl_object_id($writer)] = true; + $saves = [...$saves, ...match (true) { + $database instanceof ProjectActorDatabase => $database->prepareSaves(), + $database instanceof ProjectRecordDatabase, $database instanceof ProjectSettingsFile => [$database->prepareSave()], + default => throw new LogicException(sprintf('%s cannot be saved with a change set.', get_debug_type($database))), + }]; + } + } + + $saves = [...$saves, ...($workspace->cutscenes?->prepareSaves() ?? [])]; + + $dirtyMaps = array_values(array_filter($workspace->maps, static fn(ProjectMap $map): bool => $map->isDirty())); + + foreach ($dirtyMaps as $map) { + if ($map->willMoveOnSave()) { + throw new RuntimeException(sprintf('%s would move its folder when saved; save it on its own.', $map->mapId)); + } + } + + // One manifest step holds every map's relocations: the manifest is written once. A map made since the last + // save holds no save, whatever stands on its blank files. + $relocation = SaveRelocationPlanner::planAll(array_values(array_filter($dirtyMaps, + static fn(ProjectMap $map): bool => ! in_array($map->mapId, $madeMaps, true))), $workspace->projectRoot); + + if ($relocation !== null) { + if ($recordRelocation === null) { + throw new RuntimeException(sprintf('Saving %s moves existing saves; allow the relocations first.', $relocation->mapId)); + } + + $saves[] = $relocation->prepareApply($recordRelocation); + } + + foreach ($dirtyMaps as $map) { + $saves[] = $map->prepareSave(); + } + + return array_values(array_filter($saves)); + } + + public static function saveAll(ProjectWorkspace $workspace, BackupWriter $backups): WorkspaceSaveResult + { + $backupFailures = []; + $backup = static function (string ...$paths) use ($backups, &$backupFailures): void { + if ($backups->isEnabled() && $paths !== []) { + array_push($backupFailures, ...$backups->backup(...$paths)['failed']); + } + }; + $savedMaps = 0; + $savedDatabases = 0; + $skippedRenames = []; + $skippedRelocations = []; + $failures = []; + $warnings = []; + $errors = []; + $mapsById = []; + + foreach ($workspace->maps as $map) { + $mapsById[$map->mapId] = $map; + } + + $inventory = MapValidator::readInventoryCatalog($workspace); + + // Categories sharing one file are saved together, so the file is + // written once with every dirty part folded in rather than once per + // category, each rewriting what the last just wrote. + foreach (SharedFileTransaction::groupByPath($workspace->listSaveableDatabases()) as $group) { + $dirty = array_filter($group, static fn(object $database): bool => $database->isDirty()); + + if ($dirty === []) { + continue; + } + + try { + // One file, one backup, however many categories of it are being written. + $backup(...array_values(array_unique(array_merge(...array_map( + static fn(object $database): array => $database->getBackupPaths(), + array_values($dirty), + ))))); + $shared = reset($dirty); + + if ($shared instanceof ProjectRecordDatabase && $shared->sharesBackingFile()) { + // One write for the file, with every dirty category's + // edits composed against one snapshot of it first. + SharedFileTransaction::commit(array_values($dirty)); + } else { + foreach ($dirty as $database) { + $database->save(); + } + } + + $savedDatabases += count($dirty); + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', implode(', ', array_keys($dirty)), $throwable->getMessage()); + } + } + + // Cutscenes: each dirty asset as its own paired transaction. + $cutscenes = $workspace->cutscenes?->saveAll($backup) ?? ['saved' => [], 'failed' => []]; + + foreach ($cutscenes['failed'] as $asset => $reason) { + $failures[] = sprintf('%s: %s', $asset, $reason); + } + + // Maps last: what they name (a field resource, a cinematic, an item) is on disk before they are. + foreach ($workspace->maps as $map) { + if (! $map->isDirty()) { + continue; + } + + if ($map->willMoveOnSave()) { + $skippedRenames[] = $map->mapId; + continue; + } + + // Saves that would load inside what the map now makes solid are moved only once the author has seen where. + try { + $relocation = SaveRelocationPlanner::plan($map, $workspace->projectRoot); + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); + continue; + } + if ($relocation !== null) { + $skippedRelocations[] = $map->mapId; + continue; + } + + $issues = MapValidator::validate($map, $mapsById, $inventory); + foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::WARNING)) as $warning) { + $warnings[] = sprintf('%s: %s', $map->mapId, $warning); + } + foreach (MapValidator::describeIssues(MapValidator::selectSeverity($issues, Severity::ERROR)) as $error) { + $errors[] = sprintf('%s: %s', $map->mapId, $error); + } + + try { + $map->save($backup); + $savedMaps++; + } catch (Throwable $throwable) { + $failures[] = sprintf('%s: %s', $map->mapId, $throwable->getMessage()); + } + } + + return new WorkspaceSaveResult( + savedMaps: $savedMaps, + savedDatabases: $savedDatabases, + savedCutscenes: count($cutscenes['saved']), + skippedRenames: $skippedRenames, + skippedRelocations: $skippedRelocations, + failures: $failures, + warnings: $warnings, + backupFailures: array_values(array_unique($backupFailures)), + errors: $errors, + ); + } +} diff --git a/src/Storage/WorkspaceSaveResult.php b/src/Storage/WorkspaceSaveResult.php new file mode 100644 index 00000000..2236c412 --- /dev/null +++ b/src/Storage/WorkspaceSaveResult.php @@ -0,0 +1,44 @@ + sprintf( + 'Saved %d map%s, %d database%s and %d cutscene%s.', + $this->savedMaps, + $this->savedMaps === 1 ? '' : 's', + $this->savedDatabases, + $this->savedDatabases === 1 ? '' : 's', + $this->savedCutscenes, + $this->savedCutscenes === 1 ? '' : 's', + ); + } + + /** + * @param list $skippedRenames Maps left unsaved because saving would move their folder. + * @param list $skippedRelocations Maps left unsaved because saving them moves existing saves, which their own save reviews. + * @param list $failures Each document that could not be written, with why. + * @param list $warnings What validation noticed in the maps written, which never blocks a save. + * @param list $backupFailures Files whose backup failed; their save still ran. + * @param list $errors Defects the player would meet in the maps written, each with its fix: the maps + * saved as drafts, and validation fails until they are fixed. + */ + public function __construct( + public readonly int $savedMaps, + public readonly int $savedDatabases, + public readonly int $savedCutscenes, + public readonly array $skippedRenames, + public readonly array $skippedRelocations, + public readonly array $failures, + public readonly array $warnings, + public readonly array $backupFailures, + public readonly array $errors = [], + ) { + } +} diff --git a/src/UI/Modal.php b/src/UI/Modal.php index 74464db3..2e05c2c4 100644 --- a/src/UI/Modal.php +++ b/src/UI/Modal.php @@ -33,6 +33,11 @@ enum Modal: string * The folder-move confirmation raised by a renaming save. */ case RENAME_CONFIRMATION = 'rename_confirmation'; + case LAYER_EDIT = 'layer_edit'; + /** + * The count prompt for inserting rows or columns into the map. + */ + case MAP_LINES = 'map_lines'; /** * The Ctrl+P command palette. Opens above the Database screen and the * main shell, below the safety modals. @@ -42,6 +47,8 @@ enum Modal: string * The `?` help overlay generated from the input binding tables. */ case HELP = 'help'; + /** Shared option and confirmation pickers can open above the Database. */ + case EVENT_OPTION_DIALOG = 'event_option_dialog'; /** * The Database screen. Consumes all input while open (below the safety * modals and the global Ctrl+E shortcut). @@ -64,10 +71,6 @@ enum Modal: string * The loot picker dialog. */ case LOOT_DIALOG = 'loot_dialog'; - /** - * The generic event-option picker dialog. - */ - case EVENT_OPTION_DIALOG = 'event_option_dialog'; /** The brush colour picker owns mouse input above the shared interceptor. */ case COLOR_PICKER = 'color_picker'; /** diff --git a/src/UI/SettingsPaneLayout.php b/src/UI/SettingsPaneLayout.php index 48cb37b4..0764f336 100644 --- a/src/UI/SettingsPaneLayout.php +++ b/src/UI/SettingsPaneLayout.php @@ -268,7 +268,9 @@ public static function wrap(string $text, int $firstWidth, int $restWidth): arra /** * Wraps a plain line of prose to a width, keeping its leading indent on - * every continuation line. + * every continuation line. Every line fits the width and no text is + * lost: an indent deeper than the pane allows is shortened, so the text + * keeps at least half the width (deeply nested source in a narrow pane). * * @param string $line The line, possibly indented. * @param int $contentWidth The pane's writable width. @@ -277,13 +279,13 @@ public static function wrap(string $text, int $firstWidth, int $restWidth): arra public static function wrapProse(string $line, int $contentWidth): array { $contentWidth = max(1, $contentWidth); - $indent = strlen($line) - strlen(ltrim($line, ' ')); $text = ltrim($line, ' '); if ($text === '' || mb_strwidth($line) <= $contentWidth) { return [$line]; } + $indent = min(strlen($line) - strlen($text), intdiv($contentWidth, 2)); $width = max(1, $contentWidth - $indent); $pad = str_repeat(' ', $indent); diff --git a/src/Validation/ActorReferenceValidator.php b/src/Validation/ActorReferenceValidator.php new file mode 100644 index 00000000..f417746f --- /dev/null +++ b/src/Validation/ActorReferenceValidator.php @@ -0,0 +1,80 @@ +actorDatabase->getActors() as $actor) { + if ($actor->hasDefinitionId()) { $ids[] = $actor->getDefinitionId(); } + } + $payloads = ['assets/Data/system.php' => ['startingParty' => $workspace->getSystemField('startingParty') ?? []]]; + foreach (['skits', 'troops', 'common_events', 'battle_entry_rules'] as $category) { + $database = $workspace->getRecordDatabase($category); + if ($database === null) { continue; } + foreach ($database->getRecords() as $index => $record) { + $path = $database->schema->relativePath . ':' . $index; + if ($category === 'skits') { + $path = $record->sourcePath ?? $database->path . '/' . $record->recordId . '.php'; + $root = rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if (str_starts_with($path, $root)) { $path = substr($path, strlen($root)); } + } + $payloads[$path] = $record->toArray(); + } + } + $mapIds = []; + foreach ($workspace->maps as $map) { + $payloads[$map->dataPath] = $map->getEditableData(); + $mapIds[$map->dataPath] = $map->mapId; + } + // Summons as they stand, unsaved edits included; their wielders name actors. + $root = rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + foreach ($workspace->cutscenes?->assets(CutsceneType::SUMMON) ?? [] as $summon) { + if (! $summon->isDeleted() && str_starts_with($summon->dataPath(), $root)) { + $payloads[substr($summon->dataPath(), strlen($root))] = $summon->payload(); + } + } + $issues = []; + foreach (['battle', 'dialogue', 'menus'] as $name) { + $relative = 'assets/Data/Presentation/' . $name . '.php'; + $path = $workspace->projectRoot . '/' . $relative; + if (! is_file($path)) { continue; } + try { $payloads[$relative] = PhpDataFile::evaluateIsolated($path, $workspace->projectRoot); } + catch (Throwable $failure) { + $issues[] = Issue::error($relative, 'Actor presentation references could not be checked: ' . $failure->getMessage()); + } + } + foreach ($payloads as $where => $payload) { + foreach (ActorReferenceInventory::getReferences($payload, $where) as $reference) { + // Legacy speaker is display text, not an explicit actor reference. + if ($reference['kind'] === 'speaker') { continue; } + $value = $reference['reference']; + $known = $ids; + if (is_string($value) && ($reference['caseInsensitive'] ?? false)) { + $value = strtolower(trim($value)); + $known = array_map(static fn(string $id): string => strtolower(trim($id)), $ids); + } + if (! is_string($value) || ! in_array($value, $known, true)) { + $issues[] = new Issue(Severity::ERROR, $where . ':' . implode('.', $reference['path']), + 'Unresolved actor reference ' . var_export($reference['reference'], true) . '; use an explicit stable actor id.', + 'Review and confirm the project actor identity migration, or select an existing actor.', + self::UNRESOLVED_ACTOR_REFERENCE, $mapIds[$where] ?? null); + } + } + } + return $issues; + } +} diff --git a/src/Validation/AnimationReferenceValidator.php b/src/Validation/AnimationReferenceValidator.php new file mode 100644 index 00000000..25643c1f --- /dev/null +++ b/src/Validation/AnimationReferenceValidator.php @@ -0,0 +1,139 @@ +getRecordDatabase('animations')?->getRecords() ?? []; + $ids = array_map(static fn($animation): mixed => $animation->get('id'), $animations); + $names = array_map(static fn($animation): string => strval($animation->get('name')), $animations); + $issues = []; + + foreach ($workspace->getRecordDatabase('skills')?->getRecords() ?? [] as $record) { + try { + $skill = SkillRecord::readSkill((array) $record->toArray()); + } catch (InvalidArgumentException) { + // A record the game cannot read is the skill catalogue's finding. + continue; + } + + $where = sprintf('assets/Data/%s/%s.php: %s', SkillCatalog::DIRECTORY, $record->recordId, $skill->name); + if ($skill->animationId !== null) { + array_push($issues, ...$this->checkId($skill->animationId, $ids, $where)); + continue; + } + + // A basic skill's effect follows the attacker's weapon role, and a + // summon plays its cutscene; neither is chosen by an animation name. + if ($skill instanceof BasicSkill) { + continue; + } + + $magicEffectType = $skill instanceof MagicSkill ? $skill->effectType : null; + $candidates = ActionAnimationResolver::getSkillCandidateNames($skill->name, $magicEffectType); + if (array_intersect($candidates, $names) !== []) { + $issues[] = Issue::warning( + $where, + 'Animation selection by name is deprecated.', + 'Choose an Animation in the picker to store its stable id; name fallback remains supported.', + ); + } + } + + foreach ($workspace->getRecordDatabase('items')?->getRecords() ?? [] as $item) { + $id = $item->get('animationId'); + if ($id !== null) { + array_push($issues, ...$this->checkId($id, $ids, 'assets/Data/items.php: ' . strval($item->get('name')))); + } + } + + return [...$issues, ...$this->checkRoles($workspace)]; + } + + /** + * Checks animation roles as the runtime reads them: a role it does not + * support makes it skip the whole animation, a role held twice plays + * neither, and a role the project's battles reach with no animation + * bound to it plays no effect. + * + * @return Issue[] + */ + private function checkRoles(ProjectWorkspace $workspace): array + { + $where = 'assets/Data/animations.php'; + $supported = ActionAnimationResolver::getSupportedRoles(); + $holders = []; + $issues = []; + + foreach ($workspace->getRecordDatabase('animations')?->getRecords() ?? [] as $animation) { + $name = strval($animation->get('name')); + foreach ((array) ($animation->get('roles') ?? []) as $role) { + if (! in_array($role, $supported, true)) { + $issues[] = Issue::error("{$where}: {$name}", sprintf('Its role "%s" is not one the Engine supports, so the whole animation is skipped.', strval($role)), + 'Choose its roles in the Roles picker, which offers only supported roles.'); + continue; + } + $holders[$role][] = $name; + } + } + + foreach ($holders as $role => $names) { + if (count($names) > 1) { + $issues[] = Issue::error($where, sprintf('Role %s is bound to %s, so neither plays for it.', $role, implode(' and ', $names)), + 'Keep the role on one animation.'); + } + } + + // Every battle reaches the neutral attack (enemies) and the unarmed one + // (a party member with no weapon), and each weapon type the project's + // weapons use. + $reached = ['attack' => 'enemy and unclassified attacks', 'attack-unarmed' => 'attacks with no weapon equipped']; + + foreach ($workspace->getRecordDatabase('weapons')?->getRecords() ?? [] as $weapon) { + $type = $weapon->get('equipmentType'); + $type = $type instanceof WeaponType ? $type->value : (is_string($type) ? $type : null); + + if ($type !== null && $type !== '') { + $reached['attack-' . strtolower($type)] ??= sprintf('attacks with a %s such as %s', strtolower($type), strval($weapon->get('name'))); + } + } + + foreach ($reached as $role => $reason) { + if (! isset($holders[$role])) { + $issues[] = Issue::warning($where, sprintf('No animation holds role %s, so %s play no effect.', $role, $reason), + 'Pick the role in the Roles of the animation that should play.'); + } + } + + return $issues; + } + + /** @param int[] $ids @return Issue[] */ + private function checkId(mixed $id, array $ids, string $where): array + { + if (is_int($id) && in_array($id, $ids, true)) { + return []; + } + + return [Issue::warning( + $where, + 'Its animationId does not identify an authored animation.', + 'Choose a current Animation in the picker. Gameplay still works; an explicit missing id never falls back by name.', + )]; + } +} diff --git a/src/Validation/BattleTestValidator.php b/src/Validation/BattleTestValidator.php new file mode 100644 index 00000000..a75eecf9 --- /dev/null +++ b/src/Validation/BattleTestValidator.php @@ -0,0 +1,80 @@ +getRecordDatabase('system')?->getRecordByIndex(0); + $entry = $system?->get(ProjectBattleTest::SYSTEM_KEY); + if ($entry === null) { + return []; + } + try { + $test = ProjectBattleTest::fromArray(is_array($entry) ? $entry : throw new InvalidArgumentException('it is not an entry.')); + } catch (InvalidArgumentException $invalid) { + return [Issue::warning(self::WHERE, 'The battle test cannot be read: ' . $invalid->getMessage(), + 'Set it again from the Battle Test dialog, or remove it to use the starting party.')]; + } + + $issues = []; + $troops = array_map(static fn($record): string => trim(strval($record->get('name'))), + $workspace->getRecordDatabase('troops')?->getRecords() ?? []); + if ($test->troop !== null && !in_array(strtolower($test->troop), array_map(strtolower(...), $troops), true)) { + $issues[] = Issue::warning(self::WHERE, sprintf('The battle test troop %s is not among the troops.', $test->troop), + 'Choose one of the project\'s troops.'); + } + $arena = $test->setup?->arena ?? $test->arena; + if ($arena !== null) { + try { + $arenas = $workspace->catalogs->loadBattlePresentation()?->getArenaChoices() ?? []; + } catch (Throwable) { + $arenas = null; + } + if ($arenas !== null && !array_key_exists($arena, $arenas)) { + $issues[] = Issue::warning(self::WHERE, sprintf('The battle test arena %s is not one the battle presentation declares.', $arena), + 'Choose one of its arenas, or none for its default.'); + } + } + if ($test->setup === null) { + return $issues; + } + + return [...$issues, ...ProjectDirectoryContext::run($workspace->projectRoot, static function () use ($workspace, $test): array { + EngineDataBootstrap::ensure($workspace->projectRoot); + $items = ConfigStore::has(ItemStore::class) ? ConfigStore::get(ItemStore::class) : null; + if (!$items instanceof ItemStore) { + return []; + } + $problems = $test->setup->getProblems($workspace->actorDatabase->createActorStore(), $items, + $workspace->loadSkillCatalog(), new SummonCutsceneLibrary($workspace->projectRoot . '/assets/Cutscenes/Summons')); + + return array_map(static fn(string $problem): Issue => Issue::warning(self::WHERE, $problem, + 'Fix the member in the Battle Test dialog; the battle test will not start until it can be set up.'), $problems); + })]; + } +} diff --git a/src/Validation/ChestRewardValidator.php b/src/Validation/ChestRewardValidator.php new file mode 100644 index 00000000..c54db436 --- /dev/null +++ b/src/Validation/ChestRewardValidator.php @@ -0,0 +1,73 @@ + $data The chest event's `data`, as currently authored. + * @return Issue[] The issues found. + */ + public function validate(string $mapId, string $marker, array $data): array + { + $where = sprintf('%s event %s', $mapId, $marker); + + return array_map( + static fn(MapValidationIssue $issue): Issue => Issue::error($where, $issue->message, $issue->hint, $issue->rule), + MapCompletenessValidator::validateChestReward( + $data, + $mapId, + $this->resolveInventoryReference(...), + $marker, + ['events', $marker, 'data'], + ), + ); + } + + /** + * Resolves an inventory reference for the Engine's check: the stable id, + * null when nothing answers to it, and a failure when more than one does. + * The map completeness check gives the Engine the same resolver. + */ + public function resolveInventoryReference(string $reference): ?string + { + return $this->inventory->isAmbiguous($reference) + ? $this->inventory->requireDefinitionId($reference, 'validating chest reward') + : $this->inventory->definitionIdFor($reference); + } +} diff --git a/src/Validation/CounterAttackValidator.php b/src/Validation/CounterAttackValidator.php new file mode 100644 index 00000000..68fd773f --- /dev/null +++ b/src/Validation/CounterAttackValidator.php @@ -0,0 +1,52 @@ +loadSkillCatalog(); + $issues = []; + + foreach ($workspace->actorDatabase->getActors() as $actor) { + array_push($issues, ...$this->check($actor->getData()['counterAttack'] ?? null, $catalog, + sprintf('assets/Data/Actors: %s', $actor->getName()))); + } + foreach (['enemies' => 'assets/Data/Enemies', 'states' => 'assets/Data/states.php', 'skills' => 'assets/Data/Skills'] as $category => $where) { + foreach ($workspace->getRecordDatabase($category)?->getRecords() ?? [] as $record) { + array_push($issues, ...$this->check($record->get('counterAttack'), $catalog, + sprintf('%s: %s', $where, strval($record->get('name'))))); + } + } + + return $issues; + } + + /** @return Issue[] */ + private function check(mixed $grant, SkillCatalog $catalog, string $where): array + { + try { + CounterAttackRule::fromArray($grant)?->resolveSkill($catalog); + } catch (InvalidArgumentException $refused) { + return [Issue::error($where, $refused->getMessage(), 'Pick its Counter Attack from the list, which offers only skills a counter may use, or choose no counter.')]; + } + + return []; + } +} diff --git a/src/Validation/CutsceneValidation.php b/src/Validation/CutsceneValidation.php index c0996380..5f1cdea1 100644 --- a/src/Validation/CutsceneValidation.php +++ b/src/Validation/CutsceneValidation.php @@ -9,8 +9,10 @@ use Ichiloto\Editor\Cutscenes\CutsceneLibrary; use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Animations\Timelines\EffectPresentation; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicCommandSchema; use Ichiloto\Engine\Cutscenes\Cinematics\CinematicScriptValidator; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Throwable; /** @@ -58,7 +60,9 @@ protected function checkCutscenes(ProjectWorkspace $workspace): array $issues[] = Issue::error( sprintf('%s %s', $type->noun(), basename($finding['folder'])), $finding['message'], - sprintf('A %s is one folder holding .data.php and %s, named after its folder.', $type->noun(), $type->partnerSuffix()), + $type->hasDataFile() + ? sprintf('A %s is one folder holding .data.php and %s, named after its folder.', $type->noun(), $type->partnerSuffix()) + : sprintf('An %s is one folder holding %s, named after its folder.', $type->noun(), $type->partnerSuffix()), ); } @@ -89,6 +93,18 @@ protected function checkCutsceneAsset(ProjectWorkspace $workspace, CutsceneAsset return []; } + if ($asset->type === CutsceneType::EFFECT) { + // An effect's id is its folder alone. Where it is used, and so in + // which context it must play, the effect validation checks. + try { + $asset->hydrate(); + } catch (Throwable $throwable) { + return [Issue::error($where, $throwable->getMessage(), 'Fix the timeline until each presentation plays in battle or on the field.')]; + } + + return []; + } + $data = $asset->data(); $declared = trim(strval($data['id'] ?? '')); @@ -110,10 +126,14 @@ protected function checkCutsceneAsset(ProjectWorkspace $workspace, CutsceneAsset return [...$issues, ...$this->checkCinematicReferences($workspace, $asset, $definition->commands, $definition->finalizer)]; } - try { - $asset->compiledSummon(); - } catch (Throwable $throwable) { - return [...$issues, Issue::error($where, $throwable->getMessage(), 'The Engine refuses this summon as it stands; the message names what it needs.')]; + // The terminal and the graphical presentation each compile the summon as they play it. + foreach (EffectPresentation::cases() as $presentation) { + try { + $asset->compiledSummon($presentation); + } catch (Throwable $throwable) { + return [...$issues, Issue::error($where, sprintf('%s presentation: %s', ucfirst($presentation->value), $throwable->getMessage()), + 'The Engine refuses this summon as it stands; the message names what it needs.')]; + } } return $issues; @@ -168,19 +188,50 @@ protected function checkCinematicReferences(ProjectWorkspace $workspace, Cutscen } } - $context = ['castIds' => $castIds, 'checkpoints' => $checkpoints, 'npcIds' => $npcIds, 'mapId' => $startMap !== '' ? $startMap : null, 'npcIdsByMap' => $npcIdsByMap, 'seenCheckpoints' => []]; + $context = ['castIds' => $castIds, 'checkpoints' => $checkpoints, 'map' => ScriptMapState::startOn($startMap !== '' ? $startMap : null, $npcIds), 'npcIdsByMap' => $npcIdsByMap, 'seenCheckpoints' => [], + 'attachmentsByMap' => $this->readAttachmentsByMap($workspace), 'graphicalMaps' => $this->listGraphicalMaps($workspace)]; + foreach ((array) ($data['cast'] ?? []) as $entry) { + if (is_array($entry) && is_array($entry['at'] ?? null)) { + $issues = [...$issues, ...$this->checkCinematicSubject(['subject' => $entry['at']], $where . ' cast', $context)]; + } + if (is_array($entry) && strtolower(trim(strval($entry['kind'] ?? 'staged_actor'))) === 'staged_actor') { + $issues = [...$issues, ...$this->checkStagedActorLook($entry, $where . ' cast', $context)]; + } + } $issues = [...$issues, ...$this->walkCinematicCommands($commands, $where, $known, $context)]; $issues = [...$issues, ...$this->walkCinematicCommands($finalizer, $where . ' finalizer', $known, $context)]; return $issues; } + /** + * The attachments each map's world objects offer, by map and object id, as their field resources declare them. + * + * @return array>> + */ + protected function readAttachmentsByMap(ProjectWorkspace $workspace): array + { + $byMap = []; + foreach ($workspace->maps as $map) { + try { + $entries = \Ichiloto\Editor\Field\WorldObjectAuthoring::readEntries($map); + } catch (\InvalidArgumentException) { + continue; + } + foreach ($entries as $entry) { + $found = is_string($entry['id'] ?? null) ? \Ichiloto\Editor\Field\WorldObjectAuthoring::findAttachments($map, $entry['id']) : null; + $byMap[$map->mapId][strval($entry['id'] ?? '')] = array_map(static fn($attachment): string => $attachment->id, $found['attachments'] ?? []); + } + } + return $byMap; + } + /** * Walks a cinematic command list, nested blocks and lanes included. * * @param array $commands * @param array $known - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[], attachmentsByMap: array>>} $context * @return Issue[] */ protected function walkCinematicCommands( @@ -201,9 +252,21 @@ protected function walkCinematicCommands( $type = strval($command['type'] ?? ''); if (! in_array($type, CinematicCommandSchema::COMMAND_TYPES, true)) { - continue; // The Engine already refused the tree. + // The Engine already refused an unknown or malformed command; + // what is left to check of a registered one is what it names. + $registered = ScriptCommandRegistry::getCatalog()->findDefinition($type); + + if ($registered !== null) { + $issues = [...$issues, ...$this->checkRegisteredReferences($registered, $command, $where, $known)]; + } + + continue; } + $issues = [...$issues, ...$this->checkSharedCommandSemantics( + $command, $where, $context['map']->npcIds, $context['map']->description, cinematic: true, + )]; + $named = match ($type) { 'give_item' => ['inventory', 'item', 'item'], 'play_music' => ['bgm', 'music', 'track'], @@ -213,7 +276,7 @@ protected function walkCinematicCommands( 'transfer' => ['maps', 'map', 'map'], 'start_battle' => ['troops', 'troop', 'troop'], 'common_event' => ['common_events', 'id', 'common event'], - 'show_animation', 'field_animation' => ['animations', 'animation', 'animation'], + 'show_animation' => ['animations', 'animation', 'animation'], default => null, }; @@ -222,6 +285,10 @@ protected function walkCinematicCommands( $issues = [...$issues, ...$this->checkReference(strval($command[$key] ?? ''), $category, $noun, $where, $known)]; } + if ($type === 'field_animation') { + $issues = [...$issues, ...$this->checkFieldAnimationReference($command, $where, $known)]; + } + if ($type === 'checkpoint') { $name = trim(strval($command['name'] ?? '')); @@ -234,12 +301,18 @@ protected function walkCinematicCommands( } } + // A relative position names its subject as the command runs: before this actor is staged. + foreach (self::listRelativeSubjects($command) as $reference) { + $issues = [...$issues, ...$this->checkCinematicSubject(['subject' => $reference], $where, $context)]; + } + if ($type === 'stage_actor') { $id = trim(strval($command['id'] ?? $command['actorId'] ?? '')); if ($id !== '') { $context['castIds'][] = $id; } + $issues = [...$issues, ...$this->checkStagedActorLook($command, $where, $context)]; } if (in_array($type, ['remove_actor', 'show_actor', 'hide_actor'], true)) { @@ -270,10 +343,20 @@ protected function walkCinematicCommands( } if ($type === 'parallel') { - foreach ((array) ($command['lanes'] ?? []) as $lane) { - $laneCommands = is_array($lane) && array_is_list($lane) ? $lane : (is_array($lane) ? (array) ($lane['commands'] ?? []) : []); - $issues = [...$issues, ...$this->walkCinematicCommands($laneCommands, $where, $known, $context, $commonEventStack)]; - } + // Lanes share the stage; only the map follows the Engine's lane timing. + [$laneIssues, $context['map']] = $this->walkParallelLanes( + $command, + $context['map'], + $context['npcIdsByMap'], + $commonEventStack, + function (array $laneCommands, ScriptMapState $laneStart) use (&$context, $where, $known, $commonEventStack): array { + $context['map'] = $laneStart; + $laneIssues = $this->walkCinematicCommands($laneCommands, $where, $known, $context, $commonEventStack); + + return [$laneIssues, $context['map']]; + }, + ); + $issues = [...$issues, ...$laneIssues]; } foreach (['then', 'else', 'cancel'] as $arm) { @@ -289,15 +372,32 @@ protected function walkCinematicCommands( } if ($type === 'transfer') { - $destination = trim(strval($command['map'] ?? '')); - $context['mapId'] = $destination !== '' ? $destination : $context['mapId']; - $context['npcIds'] = $destination !== '' && array_key_exists($destination, $context['npcIdsByMap']) ? $context['npcIdsByMap'][$destination] : null; + $context['map'] = $context['map']->transferTo(trim(strval($command['map'] ?? '')), $context['npcIdsByMap']); } } return $issues; } + /** + * The map subjects a command's relative positions name: each relative waypoint's, or an independent staged + * actor's. + * + * @param array $command + * @return list> + */ + protected static function listRelativeSubjects(array $command): array + { + $points = match ($command['type'] ?? null) { + 'move_route' => (array) ($command['waypoints'] ?? []), + 'stage_actor' => [is_array($command['actor'] ?? null) ? $command['actor'] : $command], + default => [], + }; + + return array_values(array_map(static fn(array $point): array => $point['at'], + array_filter($points, static fn(mixed $point): bool => is_array($point) && is_array($point['at'] ?? null)))); + } + /** * Validates and follows one Common Event in the same cinematic session. * @@ -306,7 +406,7 @@ protected function walkCinematicCommands( * Event files, cast ids, current-map NPCs, and recursion safety. * * @param array $known - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[], attachmentsByMap: array>>} $context * @param string[] $commonEventStack * @return Issue[] */ @@ -329,24 +429,25 @@ protected function walkCinematicCommonEvent( $commands = $this->commonEventScripts[$eventId]; $eventWhere = sprintf('%s common event %s', $where, $eventId); + $issues = []; try { CinematicScriptValidator::validate($commands, sprintf('%s:common_event:%s', $where, $eventId)); } catch (Throwable $throwable) { - return [Issue::error( + $issues[] = Issue::error( $eventWhere, $throwable->getMessage(), 'The Engine refuses this Common Event in a cinematic session; the message names the command path.', - )]; + ); } - return $this->walkCinematicCommands( + return [...$issues, ...$this->walkCinematicCommands( $commands, $eventWhere, $known, $context, [...$commonEventStack, $eventId], - ); + )]; } /** @@ -354,7 +455,7 @@ protected function walkCinematicCommonEvent( * NPC on the map the cinematic is on. * * @param array $command - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[], attachmentsByMap: array>>} $context * @return Issue[] */ protected function checkCinematicSubject(array $command, string $where, array $context): array @@ -363,6 +464,16 @@ protected function checkCinematicSubject(array $command, string $where, array $c $kind = is_array($subject) ? strtolower(trim(strval($subject['kind'] ?? ''))) : strtolower(trim(strval($subject ?? ''))); $fields = is_array($subject) ? $subject : $command; + if ($kind === 'world_object' && is_string($fields['attachment'] ?? null)) { + $objectId = trim(strval($fields['id'] ?? '')); + foreach ($context['map']->mapIds ?? [] as $mapId) { + if (! in_array($fields['attachment'], $context['attachmentsByMap'][$mapId][$objectId] ?? [], true)) { + return [Issue::error($where, sprintf('It sits on attachment "%s" of world object "%s", which %s does not offer.', $fields['attachment'], $objectId, $mapId), + 'Choose an attachment the object\'s field resource declares, or declare it on the resource.')]; + } + } + } + if ($kind === 'staged_actor') { return $this->checkStagedActorReference(trim(strval($fields['actorId'] ?? $fields['id'] ?? '')), $where, $context); } @@ -370,8 +481,8 @@ protected function checkCinematicSubject(array $command, string $where, array $c if ($kind === 'npc') { $npcId = trim(strval($fields['npcId'] ?? $fields['id'] ?? '')); - if ($npcId !== '' && $context['npcIds'] !== null && ! in_array($npcId, $context['npcIds'], true)) { - return [Issue::error($where, sprintf('It addresses NPC "%s", which is not on %s.', $npcId, $context['mapId'] ?? '(unknown map)'), 'Use a stable NPC id from the map the cinematic is on at that point.')]; + if ($npcId !== '' && $context['map']->npcIds !== null && ! in_array($npcId, $context['map']->npcIds, true)) { + return [Issue::error($where, sprintf('It addresses NPC "%s", which is not on %s.', $npcId, $context['map']->description ?? '(unknown map)'), 'Use a stable NPC id from the map the cinematic is on at that point.')]; } } @@ -379,7 +490,45 @@ protected function checkCinematicSubject(array $command, string $where, array $c } /** - * @param array{castIds: string[], checkpoints: string[], npcIds: string[]|null, mapId: string|null, npcIdsByMap: array, seenCheckpoints: string[]} $context + * A staged actor on a map drawn from a tileset needs a graphical look + * ({@see CinematicCommandSchema::STAGED_ACTOR_GRAPHICAL_FIELDS}); without + * one the graphical field can only draw its terminal glyph there. + * + * @param array $entry A cast entry or stage_actor command. + * @param array{map: ScriptMapState, graphicalMaps?: list} $context + * @return Issue[] + */ + protected function checkStagedActorLook(array $entry, string $where, array $context): array + { + $looks = array_filter(array_intersect_key($entry, array_flip(CinematicCommandSchema::STAGED_ACTOR_GRAPHICAL_FIELDS)), + static fn(mixed $look): bool => $look !== null); + $maps = array_values(array_intersect($context['map']->mapIds ?? [], $context['graphicalMaps'] ?? [])); + if ($looks !== [] || $maps === []) { + return []; + } + + return [Issue::error($where, sprintf('Staged actor "%s" has no graphical look, so the graphical field draws its terminal glyph on %s.', + trim(strval($entry['id'] ?? $entry['actorId'] ?? '')), implode(', ', $maps)), + 'Give it sprites2d art, the subject it stands for, or the appearance of the map NPC it stands in for.', 'cinematic.actor-look')]; + } + + /** + * The maps drawn from a tileset: the graphical field shows them in art, + * so nothing on them may fall back to a terminal glyph. + * + * @return list + */ + protected function listGraphicalMaps(ProjectWorkspace $workspace): array + { + $maps = []; + foreach ($workspace->maps as $map) { + if ($map->getMapDataField(['tileset']) !== null) { $maps[] = $map->mapId; } + } + return $maps; + } + + /** + * @param array{castIds: string[], checkpoints: string[], map: ScriptMapState, npcIdsByMap: array, seenCheckpoints: string[], attachmentsByMap: array>>} $context * @return Issue[] */ protected function checkStagedActorReference(string $actorId, string $where, array $context): array @@ -452,7 +601,7 @@ protected function cutsceneKnownReferences(ProjectWorkspace $workspace): array $catalog = new \Ichiloto\Editor\Database\ReferenceCatalog($workspace); $known = []; - foreach (['quests', 'maps', 'troops', 'inventory', 'bgm', 'sfx', 'common_events', 'animations', 'cinematics', 'summons'] as $category) { + foreach (['quests', 'maps', 'troops', 'inventory', 'bgm', 'sfx', 'common_events', 'animations', 'effects', 'cinematics', 'summons'] as $category) { try { $known[$category] = $catalog->valuesFor($category); } catch (Throwable) { diff --git a/src/Validation/DashValidator.php b/src/Validation/DashValidator.php new file mode 100644 index 00000000..683ed977 --- /dev/null +++ b/src/Validation/DashValidator.php @@ -0,0 +1,46 @@ +getRecordDatabase('system')?->getRecordByIndex(0)?->toArray(); + if (is_array($system) && array_key_exists(DashSettings::KEY, $system)) { + try { + DashSettings::fromArray($system[DashSettings::KEY]); + } catch (InvalidArgumentException $invalid) { + $issues[] = Issue::error(self::WHERE, 'The game cannot read its dash: ' . $invalid->getMessage(), + sprintf('Set Dash and Dash Speed Multiplier (greater than 1, at most %g) in Database > System, and remove any other dash key.', + DashSettings::MAX_SPEED_MULTIPLIER)); + } + } + foreach ($workspace->maps as $map) { + try { + DashSettings::readMapDisablesDashing($map->getEditableData()); + } catch (InvalidArgumentException $invalid) { + $issues = [...$issues, ...Issue::attributeToMap([Issue::error(sprintf('%s: %s', $map->mapId, DashSettings::MAP_DISABLE_DASHING), + 'The map cannot be entered: ' . $invalid->getMessage(), 'Set Disable Dashing in the map\'s Inspector.')], $map->mapId)]; + } + } + + return $issues; + } +} diff --git a/src/Validation/DialoguePresentationValidator.php b/src/Validation/DialoguePresentationValidator.php new file mode 100644 index 00000000..1e5fdf7e --- /dev/null +++ b/src/Validation/DialoguePresentationValidator.php @@ -0,0 +1,183 @@ +getDialogueCatalogPayload(); + $issues = $whole === null ? [] : $this->checkSpeakerNames($whole); + try { + $catalogue = $references->getDialoguePresentationCatalog(); + } catch (Throwable $error) { + // A speaker name naming nothing is said above, by name. + return $issues !== [] ? $issues : [Issue::error(self::WHERE, 'The game refuses the dialogue catalogue: ' . $error->getMessage(), + 'Fix it under Dialogue Portraits and Speaker Names; until then no line shows a portrait.')]; + } + + return [...$issues, ...$this->checkArtwork($catalogue, $references->getAssetRoot()), ...$this->checkLines($workspace, $catalogue)]; + } + + /** @return Issue[] Every speaker name that binds no identity the catalogue holds. */ + private function checkSpeakerNames(array $whole): array + { + $identities = [...array_keys((array) ($whole['actors'] ?? [])), ...array_keys((array) ($whole['resources'] ?? []))]; + $issues = []; + foreach ((array) ($whole['speakers'] ?? []) as $name => $identity) { + if (! is_string($identity) || ! in_array($identity, array_map(strval(...), $identities), true)) { + $issues[] = Issue::error(self::WHERE . ':speakers.' . $name, + sprintf('Speaker name "%s" shows %s, which is no dialogue portrait.', $name, is_scalar($identity) ? '"' . $identity . '"' : 'nothing'), + 'Choose one of the Dialogue Portraits for it under Speaker Names, or remove the name. The game refuses the catalogue until then.'); + } + } + + return $issues; + } + + /** @return Issue[] Every portrait and bust the game cannot draw, base and emotion alike. */ + private function checkArtwork(DialoguePresentationCatalog $catalogue, string $assetRoot): array + { + $issues = []; + foreach ([...$catalogue->actors, ...$catalogue->resources] as $identity => $record) { + $sets = ['' => $record]; + foreach ((array) ($record['emotions'] ?? []) as $emotion => $roles) { + $sets[(string) $emotion] = is_string($roles) ? ['portrait' => $roles] : (array) $roles; + } + foreach ($sets as $emotion => $roles) { + foreach (['portrait' => 'dialogue portrait', 'bust' => 'skit bust'] as $role => $noun) { + $asset = $roles[$role] ?? null; + if (! is_string($asset)) { continue; } + try { + PngAssetPreflight::inspect($assetRoot, $asset); + } catch (Throwable $error) { + $issues[] = Issue::warning(self::WHERE . ':' . $identity . ($emotion === '' ? '' : '.' . $emotion) . '.' . $role, + sprintf('%s%s %s %s cannot be drawn: %s', $identity, $emotion === '' ? '\'s' : ' ' . $emotion . '\'s', $noun, $asset, $error->getMessage()), + 'Choose an image under Dialogue Portraits. Until then the game shows the Neutral or base artwork, or none.'); + } + } + } + } + + return $issues; + } + + /** @return Issue[] Every line whose emotion its speaker's identity does not have. */ + private function checkLines(ProjectWorkspace $workspace, DialoguePresentationCatalog $catalogue): array + { + $issues = []; + $check = function (string $where, DialogueLineSpeaker $rule, array $line, array $owner = []) use (&$issues, $catalogue): void { + if (! array_key_exists('emotion', $line)) { return; } + $warning = DialogueEmotionFields::describeWarning($line['emotion'], DialogueEmotionFields::describeChoices($rule, $line, $owner, $catalogue)); + if ($warning !== null) { + $issues[] = Issue::warning($where, $warning); + } + }; + $root = rtrim($workspace->projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $relative = static fn(string $path): string => str_starts_with($path, $root) ? substr($path, strlen($root)) : $path; + + foreach ($workspace->getRecordDatabase('skits')?->getRecords() ?? [] as $record) { + $skit = (array) $record->toArray(); + $where = $relative($record->sourcePath ?? 'assets/Data/Skits/' . $record->recordId . '.php'); + foreach (array_values((array) ($skit['beats'] ?? [])) as $index => $beat) { + if (is_array($beat)) { $check(sprintf('%s:beat %d', $where, $index + 1), DialogueLineSpeaker::forSkitBeat(), $beat); } + } + } + foreach ($workspace->getRecordDatabase('common_events')?->getRecords() ?? [] as $record) { + $this->visitTextCommands((array) $record->toArray(), $relative($record->sourcePath ?? 'assets/Events/' . $record->recordId . '.php'), $check); + } + foreach ($workspace->maps as $map) { + $found = count($issues); + $data = $map->getEditableData(); + $where = $relative($map->dataPath); + foreach (array_values((array) ($data['npcs'] ?? [])) as $index => $npc) { + if (! is_array($npc)) { continue; } + $npcWhere = sprintf('%s:NPC %s', $where, (string) ($npc['id'] ?? $npc['name'] ?? $index + 1)); + foreach ($this->listPages((array) ($npc['dialogue'] ?? [])) as $line => $page) { + $check(sprintf('%s line %d', $npcWhere, $line + 1), DialogueLineSpeaker::forNpcLine(), $page, $npc); + } + $this->visitTextCommands($npc, $npcWhere, $check); + } + foreach ((array) ($data['events'] ?? []) as $marker => $event) { + if (! is_array($event)) { continue; } + $eventWhere = sprintf('%s:event %s', $where, $marker); + $class = ltrim((string) ($event['class'] ?? ''), '\\'); + if (in_array($class, [DialogueEventTrigger::class, ShopEventTrigger::class], true)) { + foreach ($this->listPages((array) ($event['data']['dialogue'] ?? [])) as $line => $page) { + $check(sprintf('%s page %d', $eventWhere, $line + 1), DialogueLineSpeaker::forText(), $page); + } + } + $this->visitTextCommands($event, $eventWhere, $check); + } + array_splice($issues, $found, count($issues) - $found, Issue::attributeToMap(array_slice($issues, $found), $map->mapId)); + } + foreach ($workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [] as $cinematic) { + if (! $cinematic->isDeleted()) { + $this->visitTextCommands($cinematic->payload(), $relative($cinematic->dataPath()), $check); + } + } + + return $issues; + } + + /** + * Every page a dialogue list shows: its pages, or each conditional variant's lines, in order. + * + * @return list> + */ + private function listPages(array $dialogue): array + { + if (! ConditionalDialogue::isVariantList($dialogue)) { + return array_values(array_filter($dialogue, 'is_array')); + } + $pages = []; + foreach ($dialogue as $variant) { + foreach (is_array($variant) ? (array) ($variant['lines'] ?? []) : [] as $page) { + if (is_array($page)) { $pages[] = $page; } + } + } + + return $pages; + } + + /** + * Visits every `text` command anywhere under a payload: a script's, a branch's arms, a choice's options, a + * cinematic's blocks. + * + * @param callable(string, DialogueLineSpeaker, array): void $check + */ + private function visitTextCommands(array $payload, string $where, callable $check): void + { + if (($payload['type'] ?? null) === 'text') { + $check(sprintf('%s:text "%s"', $where, mb_strimwidth((string) ($payload['text'] ?? ''), 0, 40, '...')), DialogueLineSpeaker::forText(), $payload); + } + foreach ($payload as $value) { + if (is_array($value)) { $this->visitTextCommands($value, $where, $check); } + } + } +} diff --git a/src/Validation/EffectValidator.php b/src/Validation/EffectValidator.php new file mode 100644 index 00000000..9c78cf83 --- /dev/null +++ b/src/Validation/EffectValidator.php @@ -0,0 +1,280 @@ +catalogs; + ksort($uses); + + foreach ($uses as $effect => $contexts) { + foreach ($contexts as $context => $places) { + if ($context === 'stage') { + try { + $catalogs->loadEffectTimeline($effect, forStage: true); + $asset = $workspace->cutscenes?->find(CutsceneType::EFFECT, $effect) + ?? throw new \RuntimeException('The effect timeline could not be read.'); + $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: true); + } catch (Throwable $failure) { + $issues[] = Issue::error($places[0], sprintf('Effect %s cannot be played as a stage: %s', $effect, $failure->getMessage()), + sprintf('Fix assets/Animations/%s, or choose a stage timeline. Until then its consumer cannot show the stage.%s', $effect, + count($places) > 1 ? sprintf(' Also used by %d more.', count($places) - 1) : '')); + } + continue; + } + foreach (EffectPresentation::cases() as $presentation) { + try { + $catalogs->loadEffectTimeline($effect, $context === 'battle', $presentation); + } catch (Throwable $failure) { + $issues[] = Issue::error($places[0], sprintf('Effect %s cannot be played in %s for the %s presentation: %s', + $effect, $context, $presentation->value, $failure->getMessage()), + sprintf('Fix assets/Animations/%s. Until then that presentation keeps its fallback.%s', $effect, + count($places) > 1 ? sprintf(' Also used by %d more.', count($places) - 1) : '')); + } + } + } + } + + return $issues; + } + + /** + * Checks an effect a `field_animation` command plays and waits for, as + * the Engine plays it on the field for each presentation: it must load, + * and it must play once, since loops belong to a map's own effects. + * + * @param ProjectCatalogs $catalogs The project's catalogues, whose timelines are as the workspace holds them. + * @param string $effect The effect's stable id. + * @param string $where Where the command is. + * @return Issue[] + */ + public static function checkOneShotFieldEffect(ProjectCatalogs $catalogs, string $effect, string $where): array + { + $issues = []; + + foreach (EffectPresentation::cases() as $presentation) { + try { + $timeline = $catalogs->loadEffectTimeline($effect, false, $presentation); + } catch (Throwable $failure) { + $issues[] = Issue::error($where, sprintf('Its effect %s cannot be played on the field for the %s presentation: %s', + $effect, $presentation->value, $failure->getMessage()), + sprintf('Fix assets/Animations/%s. The runtime stops the script at this command.', $effect)); + continue; + } + + if ($timeline->defaults['playback']['loop'] ?? false) { + $issues[] = Issue::error($where, sprintf('Its effect %s loops in the %s presentation, and a field_animation waits for its effect to end.', + $effect, $presentation->value), + 'Play a looping effect from the map\'s field effects instead, or give this timeline once playback.'); + } + } + + return $issues; + } + + /** + * Returns where the project plays each effect, by context: battle + * animations' source and target effects in battle; the field + * presentation's cues and action prompt, maps' field effects and tileset + * pieces' effects on the field; and the rest stage of Sleep events, `inn` + * commands at any depth and the project's default inn presentation. + * + * @param Issue[] $issues Receives what could not be read. + * @return array, field?: list, stage?: list}> + */ + public static function findUses(ProjectWorkspace $workspace, array &$issues = []): array + { + $uses = []; + $use = static function (string $effect, bool $battle, string $where) use (&$uses): void { + $uses[$effect][$battle ? 'battle' : 'field'][] = $where; + }; + + foreach ($workspace->getRecordDatabase('animations')?->getRecords() ?? [] as $animation) { + foreach (['sourceEffect', 'targetEffect'] as $field) { + $effect = $animation->get($field); + if (is_string($effect) && $effect !== '') { + $use($effect, true, sprintf('assets/Data/animations.php: %s %s', strval($animation->get('name')), $field)); + } + } + } + + foreach (self::findStageUses($workspace, $issues) as $effect => $places) { + $uses[$effect]['stage'] = $places; + } + + try { + $catalog = new ReferenceCatalog($workspace)->getFieldPresentationCatalog(); + + foreach ($catalog->cues as $color => $cue) { + $use($cue['effect'], false, sprintf('%s: %s cue', self::FIELD_PRESENTATION, $color)); + } + + if ($catalog->actionPrompt !== null) { + $use($catalog->actionPrompt, false, self::FIELD_PRESENTATION . ': action prompt'); + } + } catch (Throwable $failure) { + $issues[] = Issue::error(self::FIELD_PRESENTATION, 'The field presentation could not be read: ' . $failure->getMessage(), + 'Until this is fixed, cues and the action prompt show their terminal glyphs.'); + } + + foreach ($workspace->maps as $map) { + try { + foreach (FieldEffectManager::readDeclarations($map->getEditableData()['fieldEffects'] ?? null) as $declaration) { + $use($declaration['effect'], false, sprintf('%s field effect %s', $map->mapId, $declaration['id'])); + } + } catch (Throwable $failure) { + $issues[] = new Issue(Severity::ERROR, $map->mapId, 'Its fieldEffects cannot be read: ' . $failure->getMessage(), + 'Until this is fixed, the map shows none of its field effects; gameplay is unchanged.', map: $map->mapId); + } + } + + $catalogs = $workspace->catalogs; + foreach ($catalogs->listTilesetIds() as $id) { + try { + $tileset = $catalogs->loadTileset($id); + } catch (Throwable) { + continue; // Map graphics validation reports an unreadable tileset. + } + + foreach ($tileset->pieces as $piece) { + if ($piece->effect !== null) { + $use($piece->effect, false, sprintf('assets/%s/%s.php piece %s', Tileset::DIRECTORY, $id, $piece->id)); + } + } + } + + return $uses; + } + + /** + * Returns where each rest stage timeline is named, as InnOffer reads it: + * a Sleep event's data, an `inn` command at any depth of a map event, + * event script or cinematic, and config's graphics.inn.presentation for + * an inn naming none. + * + * @param Issue[] $issues + * @param-out Issue[] $issues + * @return array> Timeline id to where it is named. + */ + private static function findStageUses(ProjectWorkspace $workspace, array &$issues): array + { + $uses = []; + $actors = new ReferenceCatalog($workspace)->valuesFor('actor_ids'); + $name = static function (mixed $presentation, string $where) use (&$uses, &$issues, $actors): void { + if (is_string($presentation) && trim($presentation) !== '') { + $uses[trim($presentation)][] = $where; + } elseif (is_array($presentation) || $presentation instanceof PartyStageSelection) { + try { + $selection = $presentation instanceof PartyStageSelection ? $presentation : PartyStageSelection::fromArray($presentation); + $bindings = $selection->toArray(); + $subjects = array_keys($bindings['leaders']); + foreach ($bindings['parties'] as $binding) { $subjects = [...$subjects, ...$binding['actors']]; } + foreach (array_unique(array_map('strval', $subjects)) as $actor) { + if (! in_array($actor, $actors, true)) { + $issues[] = Issue::error($where, sprintf('Its rest presentation names unknown actor identity %s.', $actor), + 'Choose a stable project actor identity; do not use a display name or guessed guest.'); + } + } + foreach ($selection->getTimelineIds() as $timeline) { + $uses[$timeline][] = $where; + } + } catch (Throwable $failure) { + $issues[] = Issue::error($where, 'Its rest presentation is invalid: ' . $failure->getMessage(), + 'Choose explicit leader or party treatment and valid bindings; no implicit fallback is used.'); + } + } + }; + + foreach ($workspace->maps as $map) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { + if (is_array($definition) && str_ends_with(strval($definition['class'] ?? ''), 'SleepEventTrigger')) { + $name(((array) ($definition['data'] ?? []))['presentation'] ?? null, sprintf('map %s event %s', $map->mapId, strval($marker))); + } + } + } + + ScriptCommandUses::visitCommands($workspace, $workspace->cutscenes?->assets(CutsceneType::CINEMATIC) ?? [], null, + static function (array $command, string $where) use ($name): void { + $definition = is_string($command['type'] ?? null) ? (ScriptCommandRegistry::getCatalog()->definitions[$command['type']] ?? null) : null; + foreach ($definition?->fields ?? [] as $field) { + if ($field->reference !== ScriptCommandReference::STAGE_TIMELINE) { continue; } + $value = $command; + foreach (explode('.', $field->key) as $segment) { $value = is_array($value) ? ($value[$segment] ?? null) : null; } + $name($value, $where); + } + }); + $name($workspace->config?->getRecord(ProjectConfig::INN_PRESENTATION)->get('value'), 'config.php: ' . ProjectConfig::INN_PRESENTATION); + + return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); + } + + /** + * Returns the scripts whose `field_animation` commands play each effect: + * map events, event scripts and cinematics, at any depth of branches, + * lanes and choices, since the command is the same wherever it is + * nested. What cannot be read names nothing here; validation reports it. + * + * @param iterable $cinematics The project's cinematics, as the editor holds them. + * @return array> Effect id to the scripts that play it. + */ + public static function findScriptUses(ProjectWorkspace $workspace, iterable $cinematics): array + { + $uses = []; + self::visitFieldAnimations($workspace, $cinematics, static function (array $command, string $where) use (&$uses): void { + if (is_string($command['effect'] ?? null) && $command['effect'] !== '') { + $uses[$command['effect']][] = $where; + } + }); + + return array_map(static fn(array $places): array => array_values(array_unique($places)), $uses); + } + + /** + * Visits every `field_animation` command a script plays: map events, + * event scripts and cinematics, at any depth of branches, lanes and + * choices, since the command is the same wherever it is nested. What + * cannot be read is not visited; validation reports it. + * + * @param iterable $cinematics The project's cinematics, as the editor holds them. + * @param \Closure(array, string): void $visit Given the command and where it is. + */ + public static function visitFieldAnimations(ProjectWorkspace $workspace, iterable $cinematics, \Closure $visit): void + { + ScriptCommandUses::visitCommands($workspace, $cinematics, 'field_animation', $visit); + } +} diff --git a/src/Validation/IncomeValidator.php b/src/Validation/IncomeValidator.php new file mode 100644 index 00000000..a91aa025 --- /dev/null +++ b/src/Validation/IncomeValidator.php @@ -0,0 +1,49 @@ +getRecordDatabase('income_schedules'); + if ($database === null || (! is_file($database->backingFilePath()) && ! $database->isDirty())) { + return []; + } + + $issues = []; + $ids = []; + foreach (array_values($database->getRecords()) as $index => $record) { + try { + $schedule = IncomeSchedule::fromArray($record->toArray(), sprintf('Schedule [%d]', $index)); + } catch (InvalidArgumentException $invalid) { + $issues[] = Issue::error(self::WHERE, 'The game cannot read an income schedule: ' . $invalid->getMessage(), + 'Correct it in Database > Income; the game will not start until it is fixed.'); + continue; + } + if (isset($ids[$schedule->id])) { + $issues[] = Issue::error(self::WHERE, sprintf('Two income schedules share the id "%s".', $schedule->id), + 'Scripts and saves name a schedule by its id, so each needs its own.'); + } + $ids[$schedule->id] = true; + } + + return $issues; + } +} diff --git a/src/Validation/Issue.php b/src/Validation/Issue.php index e5e664d4..a7d75b07 100644 --- a/src/Validation/Issue.php +++ b/src/Validation/Issue.php @@ -14,27 +14,60 @@ * @param string $where What it is about: a map id, a data file, a quest. * @param string $message What is wrong. * @param string $hint What to do about it. + * @param string|null $code Optional stable identity for consumers; never derived from message prose. + * @param string|null $map The id of the map it is about, when it is about one, so a check of some maps + * can select its issues without reading $where. + * @param list $cells The map cells it names, when it names any, so an editor can show them. */ public function __construct( public Severity $severity, public string $where, public string $message, public string $hint = '', + public ?string $code = null, + public ?string $map = null, + public array $cells = [], ) { } + /** + * Attributes issues to the map they were found on, keeping a map an issue + * already names. + * + * @param list $issues The issues found on the map. + * @param string $mapId The map's id. + * @return list The same issues, each naming its map. + */ + public static function attributeToMap(array $issues, string $mapId): array + { + return array_map(static fn(self $issue): self => $issue->map !== null ? $issue + : new self($issue->severity, $issue->where, $issue->message, $issue->hint, $issue->code, $mapId, $issue->cells), array_values($issues)); + } + + /** + * Describes the issue for a client of the editor session. + * + * @return array{severity: string, where: string, message: string, hint: string, code: ?string, map: ?string, cells: list} + */ + public function describe(): array + { + return ['severity' => $this->severity->value, 'where' => $this->where, 'message' => $this->message, + 'hint' => $this->hint, 'code' => $this->code, 'map' => $this->map, 'cells' => $this->cells]; + } + /** * Builds an error. * * @param string $where What it is about. * @param string $message What is wrong. * @param string $hint What to do about it. + * @param string|null $code Optional stable identity for consumers. * @return self The issue. */ - public static function error(string $where, string $message, string $hint = ''): self + public static function error(string $where, string $message, string $hint = '', ?string $code = null): self { - return new self(Severity::ERROR, $where, $message, $hint); + return new self(Severity::ERROR, $where, $message, $hint, $code); } /** diff --git a/src/Validation/MapCompletenessCheck.php b/src/Validation/MapCompletenessCheck.php new file mode 100644 index 00000000..1f386edc --- /dev/null +++ b/src/Validation/MapCompletenessCheck.php @@ -0,0 +1,140 @@ + The errors found, each naming the map. + */ + public static function validate(ProjectMap $map, ?InventoryCatalog $inventory): array + { + $resolve = $inventory instanceof InventoryCatalog + ? new ChestRewardValidator($inventory)->resolveInventoryReference(...) + : static fn(string $reference): ?string => null; + try { + $found = MapCompletenessValidator::validate( + $map->mapId, + $map->getEditableData(), + $map->getEventLayerCells(), + $map->getLayerSet(), + MapGraphicsValidator::readGraphics($map), + $map->getAssetRoot(), + $resolve, + // The resources as this session holds them: one added in the same change set resolves. + self::readFieldCatalog($map), + requiresGraphics: $map->listTilesetIds() !== [], + ); + } catch (Throwable $failure) { + // The map's own reading checks say why it cannot be read; nothing is judged as complete. + return [new Issue(Severity::ERROR, $map->mapId, 'Its completeness could not be checked: ' . $failure->getMessage(), + 'Repair what the other findings on this map name, then validate again.', 'map.unreadable', $map->mapId)]; + } + if ($map->getMapDataField(['tileset']) !== null) { + // A kind that cannot be read is reported with its reason by MapGraphicsValidator::validate(). + $found = array_values(array_filter($found, static fn(MapValidationIssue $issue): bool => $issue->rule !== 'graphics.missing')); + } + if ($inventory === null) { + $found = array_values(array_filter($found, static fn(MapValidationIssue $issue): bool => $issue->rule !== ChestRewardValidator::CODE)); + } + + return self::describeIssues($map, $found); + } + + /** + * The field presentation the map's world objects name resources from, as + * its workspace holds it; null when it names none, so a map of inline + * objects never depends on the catalogue. + */ + public static function readFieldCatalog(ProjectMap $map): ?FieldPresentationCatalog + { + $objects = $map->getEditableData()['worldObjects'] ?? []; + + return is_array($objects) && array_any($objects, static fn(mixed $entry): bool => is_array($entry) && array_key_exists('resource', $entry)) + ? $map->getFieldCatalog() : null; + } + + /** + * Words the Engine's findings for an author: one issue per finding, the + * findings that differ only by cell joined into one that names its cells. + * + * @param list $found + * @return list + */ + private static function describeIssues(ProjectMap $map, array $found): array + { + $groups = []; + foreach ($found as $issue) { + $key = implode("\0", [$issue->rule, $issue->message, $issue->marker ?? '', json_encode($issue->path), $issue->hint]); + $groups[$key] ??= ['issue' => $issue, 'cells' => []]; + array_push($groups[$key]['cells'], ...$issue->cells); + } + + $issues = []; + foreach ($groups as ['issue' => $issue, 'cells' => $cells]) { + $named = array_map(static fn(array $cell): string => "({$cell[0]}, {$cell[1]})", array_slice($cells, 0, self::CELLS_NAMED)); + $rest = count($cells) - count($named); + $message = $cells === [] ? $issue->message : sprintf('%s. At %s%s%s.', rtrim($issue->message, '.'), + count($cells) === 1 ? '' : sprintf('%d cells: ', count($cells)), implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''); + $where = $issue->marker === null ? $map->mapId : sprintf('%s event %s', $map->mapId, $issue->marker); + $issues[] = new Issue(self::isWarning($issue) ? Severity::WARNING : Severity::ERROR, $where, $message, + self::describeHint($map, $issue), $issue->rule, $map->mapId, array_map(static fn(array $cell): array => [$cell[0], $cell[1]], $cells)); + } + + return $issues; + } + + /** + * Findings an author weighs rather than errors the player meets: a glyph + * and tiles showing different terrain kinds, and cells of a kind whose + * art is still to be drawn. The map plays, saves and loads unchanged. + */ + private static function isWarning(MapValidationIssue $issue): bool + { + return in_array($issue->rule, [MapTerrainKindRule::DISAGREEMENT, MapTerrainKindRule::AWAITING_ART], true); + } + + /** The fix, in the author's terms where the Editor knows a better one than the Engine's. */ + private static function describeHint(ProjectMap $map, MapValidationIssue $issue): string + { + return match (true) { + $issue->rule === 'graphics.missing' + => sprintf('Set its Kind in the Inspector, one of the tilesets in assets/%s.', Tileset::DIRECTORY), + $issue->rule === 'graphics.glyph' + => 'Draw its tiles (T in the terminal editor, layer.drawTiles through the session), give the object it belongs to its art, or remove the glyph if it is obsolete.', + default => $issue->hint, + }; + } +} diff --git a/src/Validation/MapGraphicsValidator.php b/src/Validation/MapGraphicsValidator.php new file mode 100644 index 00000000..c4da5f1b --- /dev/null +++ b/src/Validation/MapGraphicsValidator.php @@ -0,0 +1,626 @@ + The issues found; empty for a map without graphics + * in a project without tilesets. + */ + public static function validate(ProjectMap $map): array + { + $sources = $map->getTileLayerSources(); + $tilesetId = $map->getMapDataField(['tileset']); + + // A map without a kind in a project that has tilesets shows its raw glyphs: the Engine's + // completeness rule reports that ({@see MapCompletenessCheck}). + if ($tilesetId === null && $sources === [] && ! is_dir($map->directory . '/' . MapGraphics::DIRECTORY)) { + return []; + } + + $issues = []; + $tileset = self::loadTileset($map, $tilesetId, $issues); + $layers = self::readLayers($map, $sources, $issues); + self::checkLayerOffsets($map, $sources, $issues); + + if ($tileset !== null) { + $issues = [ + ...$issues, + ...self::checkSheets($map, $tileset), + ...self::checkCornerSheets($map, $tileset, $layers), + ...self::checkProvidedSheets($map, $tileset, $layers), + ]; + } + + return $issues; + } + + /** + * Warns for what a map's art still lacks, judged by the Editor's own + * authoring knowledge of its tileset: tiles a tileset piece draws whose + * glyph is no longer there, cells showing the missing-art placeholder the + * author chose, and ground that can show the background through + * see-through edges. The game plays the same, so these never fail + * validation. + * + * What the player would see raw (a gameplay glyph with no graphical + * presentation, an NPC or world object without field art) is the + * Engine's rule, reported as errors by {@see MapCompletenessCheck}. + * Graphics the Engine refuses are {@see validate()}'s to report; only the + * readable tile layers count here. + * + * @return list + */ + public static function validateCoverage(ProjectMap $map): array + { + $coverage = self::readCoverage($map); + if ($coverage === null) { + return []; + } + [$tileset, $layers] = $coverage; + + return [...self::checkStalePieceTiles($map, $tileset, $layers), ...self::checkMissingArtTiles($map, $tileset, $layers), + ...self::checkSeeThroughGround($map, $tileset, $layers)]; + } + + /** + * The cells whose glyph still shows in the graphical field, by the + * Engine's glyph fallback rule ({@see MapGraphics::getShownGlyphCells()}), + * in row order. Null when the map has no kind, or its kind or tile layers + * cannot be read; {@see validate()} reports why. + * + * @return list|null + */ + public static function listShownGlyphCells(ProjectMap $map): ?array + { + return self::readCoverage($map)[2] ?? null; + } + + /** + * The map's tileset, its readable tile layers and the cells whose glyph + * still shows; null when they cannot be read. + * + * @return array{0: Tileset, 1: list, 2: list}|null + */ + private static function readCoverage(ProjectMap $map): ?array + { + $graphics = self::readGraphics($map); + if ($graphics === null) { + return null; + } + + // A world object that draws covers its glyph cells in the field, as the Engine's presentation applies it. + $covered = WorldObjectAuthoring::readRestingCoverage($map)['glyphs'] ?? []; + $shown = array_values(array_filter($graphics->getShownGlyphCells($map->getLayerSet(), $map->getAssetRoot()), + static fn(array $cell): bool => ! isset($covered[$cell['layer']][$cell['y']][$cell['x']]))); + + return [$graphics->tileset, $graphics->layers, $shown]; + } + + /** + * The map's graphics as the Engine reads them, unsaved edits included: + * its tileset, its readable tile layers and which gameplay layer each + * belongs to. Null when the map has no kind, or its kind or layers cannot + * be read; {@see validate()} reports why. + */ + public static function readGraphics(ProjectMap $map): ?MapGraphics + { + $ignored = []; + $tileset = is_string($tilesetId = $map->getMapDataField(['tileset'])) ? self::loadTileset($map, $tilesetId, $ignored) : null; + if ($tileset === null) { + return null; + } + $layers = self::readLayers($map, $map->getTileLayerSources(), $ignored); + + try { + $owners = MapGraphics::resolveLayerOwners($map->getMapDataField([MapGraphics::SETTINGS_KEY]), + array_map(static fn(MapTileLayer $layer): string => $layer->name, $layers), + array_column(array_filter($map->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name'), $tileset, $map->mapId, $map->isPaintedInKinds()); + } catch (Throwable) { + // The terminal layers' and tile layer settings' own checks report why. + return null; + } + + return new MapGraphics($tileset, $layers, owners: $owners); + } + + /** + * Warns for tiles a tileset piece draws that no glyph on the map accounts + * for, left behind when their glyph went without them (an edit outside the + * editor, or before the tileset had the piece). The graphical field then + * shows what the terminal no longer does. Tiles no piece draws, such as a + * house's walls over blank cells, are not judged. + * + * @param list $layers The readable tile layers. + * @return list + */ + private static function checkStalePieceTiles(ProjectMap $map, Tileset $tileset, array $layers): array + { + $drawn = []; + $placements = $tileset->getPlacements($map->isPaintedInKinds()); + // A stack lays its pieces' tiles itself, so no glyph carries them and none is left behind by one. + $stackLayers = $tileset->getStackLayers(); + foreach ($placements as $piece) { + foreach (PieceRole::readPiece($piece, $stackLayers) as $roles) { + foreach ($roles as $role) { + foreach ($role->tiles as $tileLayer => $cells) { + foreach ($cells as $cell) { + $drawn[$tileLayer][(int) $cell['entry']] = true; + } + } + } + } + } + // A tile a glyph-less piece also draws, such as a stone wall cap, may stand on any cell. + foreach (TileGlyphBinding::findFreeEntries($placements, $stackLayers) as $tileLayer => $entries) { + foreach ($entries as $entry => $_) { + unset($drawn[$tileLayer][(int) $entry]); + } + } + + // Every role a glyph on the map could play accounts for its tiles, + // including those of a piece's blank cells beside it. + $explained = []; + $kept = []; + $entries = []; + foreach ($layers as $tileLayer) { + $entries[$tileLayer->name] = $tileLayer->getEntries(); + } + foreach ($map->getLayers() as $layer) { + if ($layer['id'] === MapLayers::EVENT || $layer['decoration']) { + continue; + } + $planner = GlyphTilePlanner::fromPieces($placements, $layer['name'], $stackLayers); + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if (! $map->hasLayerCell($layer['id'], $x, $y) || ($glyph = $map->getLayerSymbol($layer['id'], $x, $y)) === ' ') { + continue; + } + foreach ($planner->getRoles($glyph) as $role) { + foreach ($role->tiles as $tileLayer => $cells) { + foreach ($cells as $cell) { + $explained[$tileLayer][$y + $cell['dy']][$x + $cell['dx']][(int) $cell['entry']] = true; + } + } + // A matching role explains any underlay it keeps, only at its owned cells. + if (array_filter($role->getOwnEntries(), static fn(string $entry, string $tileLayer): bool => + ($entries[$tileLayer][$y][$x] ?? (string) TileId::EMPTY) !== $entry, ARRAY_FILTER_USE_BOTH) !== []) { + continue; + } + foreach ($role->keeps as $tileLayer => $cells) { + foreach ($cells as $cell) { + [$cellX, $cellY] = [$x + $cell['dx'], $y + $cell['dy']]; + if ($map->hasLayerCell($layer['id'], $cellX, $cellY)) { + $kept[$tileLayer][$cellY][$cellX] = true; + } + } + } + } + } + } + } + + $issues = []; + foreach ($layers as $layer) { + $stale = []; + for ($y = 0; $y < $layer->height; $y++) { + foreach ($layer->getRowTiles($y) as $x => $id) { + if (isset($drawn[$layer->name][$id]) && ! isset($explained[$layer->name][$y][$x][$id]) + && ! isset($kept[$layer->name][$y][$x])) { + $stale[] = "({$x}, {$y})"; + } + } + } + if ($stale !== []) { + $named = array_slice($stale, 0, self::CELLS_NAMED); + $rest = count($stale) - count($named); + $issues[] = Issue::warning($map->mapId, + sprintf('%d %s on the %s tile layer %s drawn by a tileset piece whose glyph is no longer there: %s%s.', + count($stale), count($stale) === 1 ? 'tile' : 'tiles', $layer->name, count($stale) === 1 ? 'is' : 'are', + implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Paint the piece\'s glyph back and erase it, so its tiles go with it, or remove the tiles in the GUI editor.'); + } + } + + return $issues; + } + + /** + * Lists, by tile layer, the cells showing the tileset's missing-art + * placeholder: covered, but with art still to make. + * + * @param list $layers The readable tile layers. + * @return list + */ + private static function checkMissingArtTiles(ProjectMap $map, Tileset $tileset, array $layers): array + { + if ($tileset->missingArt === null) { + return []; + } + $issues = []; + foreach ($layers as $layer) { + $cells = []; + for ($y = 0; $y < $layer->height; $y++) { + foreach ($layer->getRowTiles($y) as $x => $id) { + if ($id === $tileset->missingArt) { + $cells[] = "({$x}, {$y})"; + } + } + } + if ($cells !== []) { + $named = array_slice($cells, 0, self::CELLS_NAMED); + $rest = count($cells) - count($named); + $issues[] = Issue::warning($map->mapId, + sprintf('%d %s on the %s tile layer %s the missing-art placeholder: %s%s.', + count($cells), count($cells) === 1 ? 'cell' : 'cells', $layer->name, count($cells) === 1 ? 'shows' : 'show', + implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Replace it with the art that belongs there once it is known.'); + } + } + + return $issues; + } + + /** + * Warns for cells where ground tiles (the A1 to A5 sheets) leave the background showing: a tile with see-through + * edges that blend into the ground beneath it, with no ground authored beneath it, shows the background through + * those edges as a dark seam in the game. Every animation frame counts, by the Engine's own tile coverage. Upper + * sheet props and world objects are see-through by design and are not judged. A corner-edged kind is judged by + * what it draws: its corner tiles' quarters, on its own cells and the cells beside them. + * + * @param list $layers The readable tile layers. + * @return list + */ + private static function checkSeeThroughGround(ProjectMap $map, Tileset $tileset, array $layers): array + { + // Tiles from a sheet the Engine cannot use draw nothing, so their glyphs show; checkSheets() says why. + $usable = array_keys($tileset->getUsableSheets($map->getAssetRoot())['sheets'] ?? []); + if ($usable === []) { + return []; + } + usort($layers, static fn(MapTileLayer $a, MapTileLayer $b): int => $a->order <=> $b->order); + $grids = array_map(static fn(MapTileLayer $layer): array => AutotileShape::resolveLayer($layer->readTiles()), $layers); + // A corner-edged kind's own tile draws nothing by itself; the corner tiles its sheet draws stand for it, or, + // when that sheet is unusable, its glyph shows and checkCornerSheets() says why. + $cornerTiles = $tileset->getCornerTiles(); + $cornerSheets = self::readCornerSheets($map, $tileset, $layers)['usable']; + $corners = array_map(static fn(MapTileLayer $layer): array => CornerShape::resolveLayer($layer->readTiles(), array_keys($cornerSheets), + $tileset->getCornerJoins()), $layers); + // One reader per pass, so replaced artwork is read as it is now. + $coverage = new TileCoverage($tileset, $map->getAssetRoot()); + $verdicts = []; + $cells = []; + foreach ($grids[0] ?? [] as $y => $row) { + foreach ($row as $x => $_) { + $stack = $shares = []; + foreach ($grids as $index => $grid) { + $tile = (int) ($grid[$y][$x] ?? TileId::EMPTY); + $sheet = $tile === TileId::EMPTY ? null : TileId::getSheet($tile); + if (in_array($sheet, self::GROUND_SHEETS, true) && in_array($sheet->value, $usable, true) + && ! CornerShape::isCornerTile($tile, $cornerTiles)) { + $stack[] = $tile; + } + foreach ($corners[$index][$y][$x] ?? [] as $cornerTile => $cases) { + $shares[] = [$cornerTile, $cases]; + } + } + if ($stack === [] && $shares === []) { + continue; + } + $signature = implode(',', $stack) . '|' . implode(',', array_map(static fn(array $share): string => implode(':', $share), $shares)); + if (! isset($verdicts[$signature])) { + $verdicts[$signature] = $coverage->findUncovered($stack, $shares); + if ($verdicts[$signature]['diagnostic'] !== null) { + // The artwork or GD cannot be read: say so once, never guess that the cells are covered. + return [Issue::warning($map->mapId, 'Its ground could not be checked for see-through edges: ' + . $verdicts[$signature]['diagnostic'], 'Install PHP GD, or repair the sheet the tileset names.')]; + } + } + if ($verdicts[$signature]['uncoveredPixels'] > 0) { + $cells[] = "({$x}, {$y})"; + } + } + } + if ($cells === []) { + return []; + } + $named = array_slice($cells, 0, self::CELLS_NAMED); + $rest = count($cells) - count($named); + + return [Issue::warning($map->mapId, + sprintf('%d %s the background through see-through ground tiles in some animation frame, a dark seam in the game: %s%s.', + count($cells), count($cells) === 1 ? 'cell can show' : 'cells can show', implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Paint ground beneath them on a lower tile layer, such as the map\'s base or the floor they stand on; the game draws nothing under a tile.')]; + } + + /** + * Checks the map data's tile layer settings as the Engine reads them. + * + * @param array $sources + * @param list $issues + */ + private static function checkLayerOffsets(ProjectMap $map, array $sources, array &$issues): void + { + $names = []; + foreach (array_keys($sources) as $path) { + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) === 1) { + $names[] = $matches['name']; + } + } + + try { + MapGraphics::readLayersMovingWith($map->getMapDataField([MapGraphics::SETTINGS_KEY]), $names, + array_column(array_filter($map->getLayers(), static fn(array $layer): bool => + $layer['id'] !== MapLayers::EVENT && ! $layer['decoration']), 'name'), $map->mapId); + } catch (InvalidArgumentException $error) { + $issues[] = Issue::error( + $map->mapId, + $error->getMessage(), + sprintf("Name a tile layer in graphics/ and give it 'offset' => [across, down] or 'movesWith' => a gameplay layer. %s", self::GLYPH_FALLBACK), + ); + } + } + + /** @param list $issues */ + private static function loadTileset(ProjectMap $map, mixed $tilesetId, array &$issues): ?Tileset + { + if ($tilesetId === null) { + $issues[] = Issue::error( + $map->mapId, + 'It has graphics/ but no kind.', + sprintf('Set its Kind in the Inspector, one of the tilesets in assets/%s, or remove graphics/. %s', Tileset::DIRECTORY, self::GLYPH_FALLBACK), + ); + + return null; + } + + if (! is_string($tilesetId)) { + $issues[] = Issue::error( + $map->mapId, + sprintf('Its tileset is %s, not a tileset id.', get_debug_type($tilesetId)), + sprintf('Name the file of a tileset in assets/%s without .php. %s', Tileset::DIRECTORY, self::GLYPH_FALLBACK), + ); + + return null; + } + + try { + // As the Tilesets page holds it, unsaved edits included. + return $map->loadTileset(); + } catch (Throwable $error) { + $issues[] = Issue::error( + $map->mapId, + $error->getMessage(), + sprintf('Repair or add assets/%s/%s.php. %s', Tileset::DIRECTORY, $tilesetId, self::GLYPH_FALLBACK), + ); + + return null; + } + } + + /** + * Reads every tile layer, reporting each one the Engine would refuse. + * + * @param array $sources + * @param list $issues + * @return list The readable layers. + */ + private static function readLayers(ProjectMap $map, array $sources, array &$issues): array + { + $hint = 'Repair the file by hand or in the GUI editor; the TUI does not repair tile layers. ' . self::GLYPH_FALLBACK; + + if (count($sources) > MapGraphics::MAX_LAYERS) { + $issues[] = Issue::error($map->mapId, sprintf('It has more than %d tile layers.', MapGraphics::MAX_LAYERS), $hint); + } + + try { + $layerSet = $map->getLayerSet(); + } catch (Throwable) { + // The terminal layers' own check reports why they disagree. + $layerSet = null; + } + + $layers = $orders = []; + + foreach ($sources as $path => $source) { + $displayPath = $map->mapId . '/' . MapGraphics::DIRECTORY . '/' . basename($path); + + if (preg_match(MapGraphics::FILENAME_PATTERN, basename($path), $matches) !== 1) { + $issues[] = Issue::error($map->mapId, "Tile layer {$displayPath} must be named NN.name.tiles.php.", $hint); + + continue; + } + + if (isset($orders[$matches['order']])) { + $issues[] = Issue::error($map->mapId, "Tile layer {$displayPath} repeats order {$matches['order']}.", $hint); + } + + $orders[$matches['order']] = true; + + try { + $layer = TileLayerSource::readLayer($source, $displayPath); + + if ($layerSet instanceof MapLayerSet) { + $layer->assertMatches($layerSet); + } + + $layers[] = $layer; + } catch (InvalidArgumentException $error) { + $issues[] = Issue::error($map->mapId, $error->getMessage(), $hint); + } + } + + return $layers; + } + + /** + * Warns for each sheet the Engine cannot draw from. + * + * @return list + */ + private static function checkSheets(ProjectMap $map, Tileset $tileset): array + { + $assetRoot = $map->getAssetRoot(); + $usable = $tileset->getUsableSheets($assetRoot)['sheets'] ?? []; + $issues = []; + + foreach (array_diff_key($tileset->sheets, $usable) as $sheet => $asset) { + $layout = TilesetSheet::from((string) $sheet); + + try { + $size = PngAssetPreflight::inspect($assetRoot, $asset); + $problem = sprintf( + '%s is %dx%d, which does not fit an %s sheet of %d x %d tiles in one even tile size shared by every sheet (%dx%d at 48 pixels).', + $asset, + $size['width'], + $size['height'], + $sheet, + $layout->getColumns(), + $layout->getRows(), + $layout->getColumns() * 48, + $layout->getRows() * 48, + ); + } catch (RuntimeException | InvalidArgumentException $error) { + $problem = $error->getMessage(); + } + + $issues[] = Issue::warning( + $map->mapId, + sprintf('Tileset %s sheet %s is unusable: %s', $tileset->id, $sheet, $problem), + 'Tiles from this sheet show their terminal glyphs. Replace the image or correct its path.', + ); + } + + return $issues; + } + + /** + * Warns for each corner-edged kind the map shows whose corner sheet the Engine cannot draw from. + * + * @param list $layers + * @return list + */ + private static function checkCornerSheets(ProjectMap $map, Tileset $tileset, array $layers): array + { + return array_map(static fn(string $problem): Issue => Issue::warning($map->mapId, $problem, + 'Those cells show their terminal glyphs. Replace the corner sheet, choose another in Database > Tilesets, or choose none to draw the kind\'s edges in each cell.'), + self::readCornerSheets($map, $tileset, $layers)['problems']); + } + + /** + * The corner sheets of the corner-edged kinds the map's tile layers show, read as the Engine reads them, and why + * any cannot be drawn from. + * + * @param list $layers + * @return array{usable: array, problems: list} + */ + private static function readCornerSheets(ProjectMap $map, Tileset $tileset, array $layers): array + { + $tileSize = $tileset->getUsableSheets($map->getAssetRoot())['tileSize'] ?? null; + $cornerTiles = $tileset->getCornerTiles(); + $shown = []; + foreach ($layers as $layer) { + foreach ($layer->getUsedIds() as $id) { + if (CornerShape::isCornerTile($id, $cornerTiles)) { + $shown[TileId::getFlagId($id)] = true; + } + } + } + $usable = $problems = []; + foreach ($tileSize === null ? [] : $tileset->kinds as $kind) { + if (! $kind->cornerEdged || ! isset($shown[$kind->art['tile']])) { + continue; + } + try { + $usable[$kind->art['tile']] = CornerSheet::read($kind, $map->getAssetRoot(), $tileSize); + } catch (RuntimeException | InvalidArgumentException $error) { + $problems[] = sprintf('Tileset %s kind %s corner sheet is unusable: %s', $tileset->id, $kind->id, $error->getMessage()); + } + } + + return ['usable' => $usable, 'problems' => $problems]; + } + + /** + * Warns for tile layers painting from sheets the tileset does not name. + * + * @param list $layers + * @return list + */ + private static function checkProvidedSheets(ProjectMap $map, Tileset $tileset, array $layers): array + { + $issues = []; + + foreach ($layers as $layer) { + $missing = []; + + foreach ($layer->getUsedIds() as $id) { + $sheet = TileId::getSheet($id)?->value; + + if ($sheet !== null && ! isset($tileset->sheets[$sheet])) { + $missing[$sheet] = $sheet; + } + } + + if ($missing !== []) { + sort($missing); + $issues[] = Issue::warning( + $map->mapId, + sprintf('Tile layer %s uses sheet%s %s, which tileset %s does not provide.', + $layer->path, count($missing) === 1 ? '' : 's', implode(', ', $missing), $tileset->id), + sprintf('Those cells show their terminal glyphs. Add the sheet to assets/%s/%s.php, or repaint the cells in the GUI editor.', + Tileset::DIRECTORY, $tileset->id), + ); + } + } + + return $issues; + } +} diff --git a/src/Validation/MapRoleCheck.php b/src/Validation/MapRoleCheck.php new file mode 100644 index 00000000..9cc9ad74 --- /dev/null +++ b/src/Validation/MapRoleCheck.php @@ -0,0 +1,123 @@ + $mapsById Every workspace map keyed by map id. + * @return list + */ + public static function validate(ProjectMap $map, array $mapsById): array + { + try { + $role = MapRoleCatalog::readMapRole($map->getEditableData()); + } catch (InvalidArgumentException $error) { + return [Issue::error($map->mapId, 'Role: ' . $error->getMessage(), 'Choose its role on the Role row.', 'map.role')]; + } + if ($role === null) { + return []; + } + try { + $roles = $map->loadMapRoles(); + } catch (InvalidArgumentException $error) { + return [Issue::warning($map->mapId, 'Its role was not checked: the map roles cannot be read: ' . $error->getMessage())]; + } + if (! $roles->hasRole($role)) { + return [Issue::error($map->mapId, sprintf('Its role %s is not a map role.', $role), + 'Choose one on the Role row, or make it on the Map Roles page.', 'map.role')]; + } + $allowed = $roles->getDestinationRoles($role); + if ($allowed === null) { + return []; + } + + $issues = []; + foreach (self::readRoutes($map) as $route) { + $destination = $mapsById[$route->destination] ?? null; + if (! $destination instanceof ProjectMap) { + continue; + } + try { + $destinationRole = MapRoleCatalog::readMapRole($destination->getEditableData()); + } catch (InvalidArgumentException) { + continue; // Reported on its own map. + } + $way = self::describeWay($route); + if ($destinationRole === null) { + $issues[] = Issue::warning($map->mapId, sprintf('%s leads to %s, which has no role, so it was not checked against %s.', + $way, $route->destination, $roles->getName($role)), + 'Give that map its role on its Role row.'); + continue; + } + if (! $roles->hasRole($destinationRole) || $roles->allowsDestination($role, $destinationRole)) { + continue; + } + $issues[] = Issue::error($map->mapId, sprintf('%s leads to %s, whose role is %s; %s maps lead only to %s maps.', + $way, $route->destination, $roles->getName($destinationRole), $roles->getName($role), + self::listNames($roles, $allowed)), + 'Lead it to a map of one of those roles, such as an outside map whose door enters this one.', 'map.role-destination'); + } + + return $issues; + } + + /** @return list The map's routes as the Engine reads them; none when its source cannot be read. */ + private static function readRoutes(ProjectMap $map): array + { + try { + $source = MapSourceReader::readDeclarations($map->mapId, $map->getEditableData(), $map->getLayerSet(), + $map->getEventLayerCells()); + $categories = null; + try { + $categories = $map->loadMapCategories(); + } catch (InvalidArgumentException) { + // Landmarks only name routes; the place categories are checked where they are authored. + } + + return MapRouteGraph::readMap($map->mapId, $source['data'], $map->getEventLayerCells(), $map->getAssetRoot(), $categories); + } catch (Throwable) { + return []; + } + } + + private static function describeWay(MapRoute $route): string + { + return match (true) { + str_starts_with($route->marker, '@legacy:') => sprintf('Edge trigger %s', substr($route->marker, strlen('@legacy:'))), + $route->npcId !== null => sprintf('NPC %s', $route->npcId), + default => sprintf('Event %s', $route->marker), + }; + } + + /** @param list $ids */ + private static function listNames(MapRoleCatalog $roles, array $ids): string + { + $names = array_map($roles->getName(...), $ids); + $last = array_pop($names); + + return $names === [] ? (string) $last : implode(', ', $names) . ' or ' . $last; + } +} diff --git a/src/Validation/MapValidator.php b/src/Validation/MapValidator.php index 10af274e..10de90b2 100644 --- a/src/Validation/MapValidator.php +++ b/src/Validation/MapValidator.php @@ -4,13 +4,26 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Database\InventoryCatalog; +use Ichiloto\Editor\Events\ChestLoot; +use Ichiloto\Editor\Field\MapAtlas; +use Ichiloto\Editor\Field\MapLandmarks; +use Ichiloto\Editor\Inspector\MapLandmarkFields; +use Ichiloto\Editor\Inspector\MapTerrainFields; use Ichiloto\Editor\ProjectMap; +use Ichiloto\Editor\ProjectWorkspace; +use InvalidArgumentException; +use Throwable; /** - * Runs the advisory pre-save checks over a map. + * Runs the pre-save checks over a map. * - * Findings are warnings only — a save is never blocked, the author is just - * told what will misbehave in the engine. + * A save is never blocked: the author's map saves as a draft and is told + * what is wrong. An error is a defect the player would meet (a raw glyph in + * the graphical field, a chest that gives nothing, a door to nowhere); a + * warning is something that works differently than written. Validation + * ({@see ProjectValidator}, `ichiloto validate`) fails on the errors until + * they are fixed. */ final class MapValidator { @@ -23,53 +36,127 @@ private function __construct() * * @param ProjectMap $map The map being saved. * @param array $mapsById Every workspace map keyed by map id. - * @return string[] Human-readable warnings; empty when the map is clean. + * @param InventoryCatalog|null $inventory The project's inventory, for what its chests give; null when it + * could not be read ({@see readInventoryCatalog()}), which is itself reported. + * @return list What is wrong, each naming the map; empty when the map is clean. */ - public static function validate(ProjectMap $map, array $mapsById): array + public static function validate(ProjectMap $map, array $mapsById, ?InventoryCatalog $inventory): array { - return [ - ...self::findMarkersWithoutDefinitions($map), - ...self::findNonRectangularMarkers($map), + return Issue::attributeToMap([ + ...MapCompletenessCheck::validate($map, $inventory), + ...self::findUncheckedChests($map, $inventory), ...self::findDanglingDestinations($map, $mapsById), + ...MapRoleCheck::validate($map, $mapsById), + ...PlaceIconCheck::validate($map, $mapsById), ...self::findOutOfRangeSpawnPoints($map, $mapsById), - ]; + ...self::findRetiredData($map), + ...self::findLandmarkProblems($map), + ...self::findAtlasProblems($map, $mapsById), + ...MapGraphicsValidator::validate($map), + ...MapGraphicsValidator::validateCoverage($map), + ...PassageAgreementCheck::validate($map), + ], $map->mapId); } /** - * Finds marker shapes the runtime cannot turn into one trigger area. + * Describes issues as one line each, the hint after the message. * - * @return string[] + * @param list $issues + * @return list */ - private static function findNonRectangularMarkers(ProjectMap $map): array + public static function describeIssues(array $issues): array { - $warnings = []; + return array_map(static fn(Issue $issue): string => $issue->hint === '' ? $issue->message : "{$issue->message} {$issue->hint}", + array_values($issues)); + } - foreach ($map->getPlacedEventMarkers() as $marker) { - if (! $map->isEventMarkerSolidRectangle($marker)) { - $warnings[] = sprintf('Event marker %s must occupy one solid rectangle.', $marker); - } + /** + * Selects the issues of one severity. + * + * @param list $issues + * @return list + */ + public static function selectSeverity(array $issues, Severity $severity): array + { + return array_values(array_filter($issues, static fn(Issue $issue): bool => $issue->severity === $severity)); + } + + + /** + * Finds an atlas scope or parent chain the engine would refuse, by its own check over the project's maps. + * + * @param array $mapsById + * @return list + */ + private static function findAtlasProblems(ProjectMap $map, array $mapsById): array + { + try { + $block = MapAtlas::readBlock($map); + MapAtlas::resolveGlobal($map, $block, static fn(string $id): ?ProjectMap => $mapsById[$id] ?? null, + $map->loadMapCategories()); + } catch (InvalidArgumentException $error) { + return [Issue::warning($map->mapId, 'Atlas: ' . $error->getMessage())]; } + // Terrain names the map's own source layers, which the parent chain does not read. + $terrain = MapTerrainFields::findProblem($map, $block); - return $warnings; + // The game refuses to prepare a map whose atlas terrain it cannot read, so the player would meet it. + return $terrain === null ? [] : [Issue::error($map->mapId, 'Atlas terrain: ' . $terrain)]; } /** - * Finds event markers painted on the grid with no event definition. + * Finds minimap landmarks the engine would refuse, by its own check. * - * @param ProjectMap $map The map being saved. - * @return string[] + * @return list */ - private static function findMarkersWithoutDefinitions(ProjectMap $map): array + private static function findLandmarkProblems(ProjectMap $map): array { - $warnings = []; + try { + $problem = MapLandmarkFields::findProblem($map, MapLandmarks::readMap($map)->entries, + $map->loadMapCategories()); + } catch (InvalidArgumentException $error) { + $problem = $error->getMessage(); + } - foreach ($map->getPlacedEventMarkers() as $marker) { - if ($map->getEventDefinition($marker) === null) { - $warnings[] = sprintf('Event marker %s has no definition.', $marker); - } + return $problem === null ? [] : [Issue::warning($map->mapId, 'Minimap landmarks: ' . $problem)]; + } + + /** + * Finds map data the engine no longer reads. + * + * @return list + */ + private static function findRetiredData(ProjectMap $map): array + { + return array_key_exists('tiles2d', $map->getEditableData()) + ? [Issue::warning($map->mapId, 'tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.')] + : []; + } + + /** + * Reads the project's inventory for {@see validate()}, or null when it + * cannot be read: a save is never blocked by it. + */ + public static function readInventoryCatalog(ProjectWorkspace $workspace): ?InventoryCatalog + { + try { + return InventoryCatalog::fromWorkspace($workspace); + } catch (Throwable) { + return null; } + } - return $warnings; + /** + * Says so when the map has chests and the inventory could not be read, + * so what they give was not checked. + * + * @return list + */ + private static function findUncheckedChests(ProjectMap $map, ?InventoryCatalog $inventory): array + { + return $inventory === null && array_filter(self::getEventDefinitions($map), ChestLoot::isChest(...)) !== [] + ? [Issue::warning($map->mapId, 'The inventory could not be read, so what the chests give was not checked.')] + : []; } /** @@ -77,11 +164,11 @@ private static function findMarkersWithoutDefinitions(ProjectMap $map): array * * @param ProjectMap $map The map being saved. * @param array $mapsById Every workspace map keyed by map id. - * @return string[] + * @return list */ private static function findDanglingDestinations(ProjectMap $map, array $mapsById): array { - $warnings = []; + $issues = []; foreach (self::getEventDefinitions($map) as $marker => $definition) { $destination = $definition['data']['destinationMap'] ?? null; @@ -91,11 +178,12 @@ private static function findDanglingDestinations(ProjectMap $map, array $mapsByI } if (! isset($mapsById[$destination])) { - $warnings[] = sprintf('%s: destination map %s does not exist.', $marker, $destination); + $issues[] = Issue::error($map->mapId, sprintf('%s: destination map %s does not exist.', $marker, $destination), + 'Walking through it crashes the game. Choose a map the project has.'); } } - return $warnings; + return $issues; } /** @@ -103,11 +191,11 @@ private static function findDanglingDestinations(ProjectMap $map, array $mapsByI * * @param ProjectMap $map The map being saved. * @param array $mapsById Every workspace map keyed by map id. - * @return string[] + * @return list */ private static function findOutOfRangeSpawnPoints(ProjectMap $map, array $mapsById): array { - $warnings = []; + $issues = []; foreach (self::getEventDefinitions($map) as $marker => $definition) { $spawnPoint = $definition['data']['spawnPoint'] ?? null; @@ -127,7 +215,7 @@ private static function findOutOfRangeSpawnPoints(ProjectMap $map, array $mapsBy : $map; if ($x < 0 || $y < 0 || $x >= $targetMap->getWidth() || $y >= $targetMap->getHeight()) { - $warnings[] = sprintf( + $issues[] = Issue::error($map->mapId, sprintf( '%s: spawn point (%d, %d) is outside %s (%d x %d).', $marker, $x, @@ -135,11 +223,11 @@ private static function findOutOfRangeSpawnPoints(ProjectMap $map, array $mapsBy $targetMap === $map ? 'this map' : $targetMap->mapId, $targetMap->getWidth(), $targetMap->getHeight(), - ); + ), 'The player would arrive off the map. Choose a cell inside it.'); } } - return $warnings; + return $issues; } /** diff --git a/src/Validation/MasteryValidator.php b/src/Validation/MasteryValidator.php new file mode 100644 index 00000000..c2da8975 --- /dev/null +++ b/src/Validation/MasteryValidator.php @@ -0,0 +1,150 @@ +loadSkillCatalog(); + $catalog = MasteryCatalog::load($workspace->projectRoot . '/assets', $skills); + $issues = [ + // The catalogue names the settings file from assets/, and a map by its file in the maps folder. + ...array_map(static fn(string $problem): Issue => Issue::error( + str_starts_with($problem, MasteryCatalog::SETTINGS) ? 'assets/' . MasteryCatalog::SETTINGS : sprintf('assets/%s/%s', MasteryCatalog::DIRECTORY, strstr($problem, ':', true)), + ltrim((string) strstr($problem, ':'), ': '), + 'Correct it in Database > Mastery Regions, Mastery Branches or Mastery Maps; until then the game reads no Mastery from it.'), + $catalog->problems), + ...$this->checkDrafts($skills), + ]; + try { + $elements = $workspace->getElementIdentities(); + } catch (InvalidArgumentException) { + // The elements' own check reports them. + $elements = null; + } + $tree = $catalog->tree; + foreach ($tree->branches as $branch) { + $where = sprintf('%s: branch %s', 'assets/' . MasteryCatalog::SETTINGS, $branch->id); + if ($branch->kind === MasteryBranchKind::ELEMENT && $elements !== null && ! in_array($branch->element, $elements, true)) { + $issues[] = Issue::error($where, sprintf('Branch %s practises element "%s", which the project does not have.', $branch->name, $branch->element), + 'Choose one of the project\'s elements (Database > Types) on the branch.'); + } + $unpriced = array_values(array_filter($branch->nodes, static fn($node): bool => $node->cost === null)); + if ($unpriced !== []) { + $issues[] = new Issue(Severity::WARNING, $where, sprintf('%s has no price yet for %s: only characters whose own map prices them can learn them.', + $branch->name, implode(', ', array_map(static fn($node): string => $node->label, $unpriced))), + 'Price them on the branch (Database > Mastery Branches), or in a character\'s own terms.', self::UNPRICED); + } + $foundation = $branch->findFoundation(); + if ($foundation !== null && $foundation->skill === null) { + $issues[] = new Issue(Severity::WARNING, $where, sprintf('The foundation %s grants no skill yet; learning it opens its branch and nothing more.', $foundation->label), + 'Choose the skill it grants once its author defines one.', self::UNGRANTED); + } + $issues = [...$issues, ...$this->checkLadder($tree, $branch->nodes, $where)]; + if (trim($branch->description) === '') { + $issues[] = new Issue(Severity::WARNING, $where, sprintf('%s has no summary: Info offers nothing on its family or its foundation.', $branch->name), + 'Write its Summary in Database > Mastery Branches.', self::UNSUMMARISED); + } + } + foreach ($tree->regions as $region) { + $regionWhere = sprintf('assets/%s: region %s', MasteryCatalog::SETTINGS, $region->id); + if (trim($region->description) === '') { + $issues[] = new Issue(Severity::WARNING, $regionWhere, sprintf('%s has no summary: Info offers nothing on it.', $region->name), + 'Write its Summary in Database > Mastery Regions.', self::UNSUMMARISED); + } + if ($region->nodes !== [] && trim($region->trunkDescription) === '') { + $issues[] = new Issue(Severity::WARNING, $regionWhere, sprintf('The %s trunk has no summary: Info offers nothing on the family its own nodes form.', $region->name), + 'Write its Trunk Summary in Database > Mastery Regions.', self::UNSUMMARISED); + } + $unpriced = array_values(array_filter($region->nodes, static fn($node): bool => $node->cost === null)); + if ($unpriced !== []) { + $issues[] = new Issue(Severity::WARNING, sprintf('assets/%s: region %s', MasteryCatalog::SETTINGS, $region->id), + sprintf('The %s trunk has no price yet for %s: only characters whose own map prices them can learn them.', $region->name, + implode(', ', array_map(static fn($node): string => $node->label, $unpriced))), + 'Price them on the region (Database > Mastery Regions), or in a character\'s own terms.', self::UNPRICED); + } + } + foreach ($catalog->getMaps() as $actor => $map) { + foreach ($map->findUnchainedTerms() as $id => $step) { + $node = $tree->nodes[$id]; + $branch = $tree->branches[$node->branch ?? ''] ?? null; + $issues[] = new Issue(Severity::WARNING, sprintf('assets/%s: %s', MasteryCatalog::DIRECTORY, $actor), + sprintf('%s learns %s without %s, the step before it in %s.', $actor, $node->label, $tree->nodes[$step]->label, $branch?->name ?? $node->region), + sprintf('Make %s require %s in %s\'s own terms (Database > Mastery Maps), or leave it if %s starts there.', $node->label, + $tree->nodes[$step]->label, $actor, $actor), self::UNCHAINED); + } + } + + return $issues; + } + + /** + * One warning per node whose terms on the tree undercut the step it + * builds on: a higher rung costs at least as much, and opens no earlier, + * than the rung below it. Characters' own terms may differ. + * + * @param list $nodes + * @return Issue[] + */ + private function checkLadder(MasteryTree $tree, array $nodes, string $where): array + { + $issues = []; + foreach ($nodes as $node) { + $step = $tree->findChainPredecessor($node); + if ($step === null || $node->cost === null || $step->cost === null) { + continue; + } + $level = $node->opensAtLevel ?? 1; + $stepLevel = $step->opensAtLevel ?? 1; + if ($node->cost < $step->cost || $level < $stepLevel) { + $issues[] = new Issue(Severity::WARNING, $where, sprintf('%s (cost %d, level %d) costs less or opens earlier than %s (cost %d, level %d), the step it builds on.', + $node->label, $node->cost, $level, $step->label, $step->cost, $stepLevel), + sprintf('Give %s a cost and opening level at or above %s\'s (Database > Mastery Branches).', $node->label, $step->label), self::LADDER); + } + } + + return $issues; + } + + /** @return Issue[] One warning per skill record still awaiting its author's values. */ + private function checkDrafts(SkillCatalog $skills): array + { + $issues = []; + foreach ($skills->getDrafts() as $name => $draft) { + $issues[] = new Issue(Severity::WARNING, 'assets/Data/' . ($skills->getSourceFile($name) ?? SkillCatalog::DIRECTORY), + sprintf('%s awaits author values (%s); nobody can learn, cast or be given it until they are assigned.', $name, implode(', ', $draft->unassigned)), + 'Assign the values in Database > Skills and clear Awaiting Author.', self::UNASSIGNED); + } + + return $issues; + } +} diff --git a/src/Validation/PassageAgreementCheck.php b/src/Validation/PassageAgreementCheck.php new file mode 100644 index 00000000..747d99fc --- /dev/null +++ b/src/Validation/PassageAgreementCheck.php @@ -0,0 +1,92 @@ + The warnings found, each naming the map and its rule. */ + public static function validate(ProjectMap $map): array + { + $issues = []; + foreach (self::inspect($map) as $issue) { + $cells = $issue->cells; + $named = array_map(static fn(array $cell): string => "({$cell[0]}, {$cell[1]})", array_slice($cells, 0, self::CELLS_NAMED)); + $rest = count($cells) - count($named); + $message = $cells === [] ? $issue->message : sprintf('%s At %s%s%s.', $issue->message, + count($cells) === 1 ? '' : sprintf('%d cells: ', count($cells)), implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''); + $issues[] = new Issue(Severity::WARNING, $map->mapId, $message, + $issue->hint . ' The collision view of the GUI editor marks every cell.', $issue->rule, $map->mapId, + array_map(static fn(array $cell): array => [$cell[0], $cell[1]], $cells)); + } + + return $issues; + } + + /** + * Every cell a finding names, with its rule, for a view that marks them. + * Null when the map cannot be judged: it has no graphics, or its graphics + * or collision cannot be read, which other checks report. + * + * @return list|null + */ + public static function listFlaggedCells(ProjectMap $map): ?array + { + if (MapGraphicsValidator::readGraphics($map) === null) { + return null; + } + $cells = []; + foreach (self::inspect($map) as $issue) { + foreach ($issue->cells as [$x, $y]) { + $cells["{$x},{$y},{$issue->rule}"] = ['x' => $x, 'y' => $y, 'rule' => $issue->rule]; + } + } + + return array_values($cells); + } + + /** @return list */ + private static function inspect(ProjectMap $map): array + { + try { + return MapCompletenessValidator::validatePassageAgreement( + $map->mapId, + $map->getEditableData(), + $map->getEventLayerCells(), + $map->getLayerSet(), + $map->getPhysicalOccupancy(), + MapGraphicsValidator::readGraphics($map), + $map->getAssetRoot(), + MapCompletenessCheck::readFieldCatalog($map), + ); + } catch (Throwable) { + // The map's own reading checks say why its layers, collision or objects cannot be read. + return []; + } + } +} diff --git a/src/Validation/PlaceIconCheck.php b/src/Validation/PlaceIconCheck.php new file mode 100644 index 00000000..dc907e5a --- /dev/null +++ b/src/Validation/PlaceIconCheck.php @@ -0,0 +1,98 @@ + $mapsById Every workspace map keyed by map id. + * @return list + */ + public static function validate(ProjectMap $map, array $mapsById): array + { + try { + $type = PlaceTypeCatalog::readMapPlaceType($map->getEditableData()); + } catch (InvalidArgumentException $error) { + return [Issue::error($map->mapId, 'Place type: ' . $error->getMessage(), 'Choose its type on the Place Type row.', 'map.place-type')]; + } + try { + $types = $map->loadPlaceTypes(); + } catch (InvalidArgumentException $error) { + return [Issue::warning($map->mapId, 'Its place type and icons were not checked: the place types cannot be read: ' . $error->getMessage())]; + } + $issues = []; + if ($type !== null && ! $types->hasType($type)) { + $issues[] = Issue::error($map->mapId, sprintf('Its place type %s is not a place type.', $type), + 'Choose one on the Place Type row, or make it on the Place Types page.', 'map.place-type'); + } + + return [...$issues, ...self::findIconIssues($map, $mapsById, $types)]; + } + + /** + * The Engine's findings for the map's entrances, worded for an author. + * + * @param array $mapsById + * @return list + */ + private static function findIconIssues(ProjectMap $map, array $mapsById, PlaceTypeCatalog $types): array + { + try { + // Only a map whose tileset gives place icons shows them; the rest are not read further. + if (($map->loadTileset()?->placeIcons ?? []) === [] || ($graphics = $map->loadGraphics()) === null) { + return []; + } + $events = MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), $map->mapId)->events; + $found = MapPlaceIconRule::inspect($map->mapId, $events, $map->getLayerSet(), $graphics, $types, + self::readPlaceTypes($mapsById)); + } catch (Throwable) { + return []; // The map's own reading checks say why it cannot be read. + } + + return array_map(static fn(MapValidationIssue $issue): Issue => new Issue(Severity::WARNING, + $issue->marker === null ? $map->mapId : sprintf('%s event %s', $map->mapId, $issue->marker), + $issue->message, $issue->hint, $issue->rule, $map->mapId, $issue->cells), $found); + } + + /** + * Every readable map's place type by map id; a map whose type cannot be read is left out, as its own check + * reports it. + * + * @param array $mapsById + * @return array + */ + private static function readPlaceTypes(array $mapsById): array + { + $types = []; + foreach ($mapsById as $id => $map) { + try { + $types[$id] = PlaceTypeCatalog::readMapPlaceType($map->getEditableData()); + } catch (InvalidArgumentException) { + continue; + } + } + + return $types; + } +} diff --git a/src/Validation/ProgressionValidator.php b/src/Validation/ProgressionValidator.php new file mode 100644 index 00000000..8ed5dec0 --- /dev/null +++ b/src/Validation/ProgressionValidator.php @@ -0,0 +1,36 @@ +getRecordDatabase('system')?->getRecordByIndex(0)?->toArray(); + if (! is_array($system) || ! array_key_exists('progression', $system)) { + return []; + } + try { + ProgressionSettings::fromArray($system['progression']); + } catch (InvalidArgumentException $invalid) { + return [Issue::error(self::WHERE, 'The game cannot read its progression rules: ' . $invalid->getMessage(), + 'Choose Level Up HP/MP in Database > System (full, increase or none), and remove any other progression key.')]; + } + + return []; + } +} diff --git a/src/Validation/ProjectValidator.php b/src/Validation/ProjectValidator.php index 12d2d337..62f4e395 100644 --- a/src/Validation/ProjectValidator.php +++ b/src/Validation/ProjectValidator.php @@ -2,6 +2,13 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Database\TroopEventPageContract; +use Ichiloto\Engine\Entities\Enemies\EnemyHabitat; +use Ichiloto\Engine\Battle\Presentation\BattleFooting; +use Ichiloto\Editor\ProjectConfig; +use Ichiloto\Engine\Messaging\Dialogue\ConditionalDialogue; +use Ichiloto\Engine\Quests\QuestWaymark; use Ichiloto\Editor\Database\BattleEntryActorResolver; use Ichiloto\Editor\Database\BattleEntryRuleContract; use Ichiloto\Editor\Database\InventoryCatalog; @@ -12,19 +19,41 @@ use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\ReferenceCatalog; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; +use Ichiloto\Engine\Core\WorldConditionExclusion; +use Ichiloto\Editor\Events\CommandMapContext; +use Ichiloto\Editor\Events\ProjectScriptCommands; +use Ichiloto\Editor\Field\MapAreaAuthoring; +use Ichiloto\Editor\Field\MapAreaRefusal; use Ichiloto\Editor\Field\MapEncounters; use Ichiloto\Editor\Field\ProjectNpc; use Ichiloto\Editor\ActorStatPreview; +use Ichiloto\Editor\Database\ProjectCatalogs; use Ichiloto\Editor\EquipmentOptimizationPolicy; use Ichiloto\Editor\PermanentGrowthCatalog; +use Ichiloto\Editor\ProjectActor; use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectQuest; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Battle\Presentation\BattlePresentationCatalog; +use Ichiloto\Engine\Field\EncounterArenaCoverage; +use Ichiloto\Engine\Field\EncounterEntry; +use Ichiloto\Engine\Field\EncounterTable; +use Ichiloto\Engine\Rendering\Tilesets\Tileset; use Ichiloto\Engine\Core\WorldConditionType; +use Ichiloto\Engine\Cutscenes\Cinematics\CinematicScriptValidator; +use Ichiloto\Engine\Field\SkitSpeaker; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandField; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandFieldKind; +use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry; use Ichiloto\Engine\Events\Interpreter\EventInterpreter; use Ichiloto\Engine\Events\Interpreter\MovementRouteRunner; +use Ichiloto\Engine\Events\MapEventDefinition; +use Ichiloto\Engine\Events\Triggers\EventCueKind; +use Ichiloto\Engine\Events\Triggers\ScriptEventTrigger; use Ichiloto\Engine\IO\Console\TerminalText; +use Ichiloto\Engine\Progress\Knowledge\KnowledgeRecordType; use InvalidArgumentException; use Throwable; use Symfony\Component\Console\Formatter\OutputFormatterStyle; @@ -70,9 +99,40 @@ class ProjectValidator protected array $legacyCommonEventIds = []; /** - * @var string|null The project root, for the catalogue a command names. + * @var ProjectWorkspace|null The project being validated, for the catalogues a command names. */ - protected ?string $projectRootForKnowledge = null; + protected ?ProjectWorkspace $validatedWorkspace = null; + + + /** + * The arena keys the project's battle presentation declares, read once per + * validation; null when it declares no battle presentation, or one that + * cannot be read ({@see $arenaCatalogProblem}). + * + * @var list|null + */ + protected ?array $arenaKeys = null; + + /** Why the battle presentation cannot be read, or null. */ + protected ?string $arenaCatalogProblem = null; + + /** + * Each arena's footing and the arena a battle naming none is fought on, + * read with the arena keys. + * + * @var array + */ + protected array $arenaFootings = []; + protected ?string $defaultArena = null; + /** The battle presentation itself, read with the arena keys, for the terrain variants it draws. */ + protected ?BattlePresentationCatalog $arenaCatalog = null; + + /** + * Each troop's members with where they live, read once per validation. + * + * @var array> + */ + protected array $troopHabitats = []; protected const string TRANSFER_TRIGGER = 'TransferPlayerTrigger'; protected const string SCRIPT_TRIGGER = 'ScriptEventTrigger'; @@ -85,7 +145,9 @@ class ProjectValidator */ public function validate(ProjectWorkspace $workspace): array { - $this->projectRootForKnowledge = $workspace->projectRoot; + $this->validatedWorkspace = $workspace; + $this->readArenaKeys($workspace); + $this->troopHabitats = $this->readTroopHabitats($workspace); $this->knowledgeCatalogData = null; $this->commonEventScripts = $this->eventScriptsById($workspace); $this->cinematicCommonEventIds = []; @@ -95,36 +157,49 @@ public function validate(ProjectWorkspace $workspace): array $this->inventoryCatalog = InventoryCatalog::fromWorkspace($workspace); } catch (Throwable $throwable) { return [Issue::error( - 'assets/Data/items.php', + 'assets/Data/Items, Weapons and Armors', sprintf('The inventory could not be read: %s', $throwable->getMessage()), 'Until this is fixed the project has no items, weapons or armors at all.' )]; } - $unreadableInventory = $this->describeUnreadableInventory($workspace); - - if ($unreadableInventory !== null) { - // Every reference to an item would now fail for the same reason, and - // a page of "does not exist" would bury it. One accurate error beats - // the cascade it causes. - return [$unreadableInventory]; - } - $issues = [ + ...$this->checkScriptCommandDeclarations($workspace), ...$this->checkMaps($workspace), ...$this->checkQuests($workspace), ...$this->checkTroops($workspace), ...$this->checkBattleEntryRules($workspace), + ...$this->checkSkillCatalog($workspace), + ...$this->checkItemCatalog($workspace), ...$this->checkSummons($workspace), ...$this->checkCutscenes($workspace), ...$this->checkReferences($workspace), + ...new AnimationReferenceValidator()->validate($workspace), + ...new CounterAttackValidator()->validate($workspace), + ...new TroopFormationValidator()->validate($workspace), + ...new BattleTestValidator()->validate($workspace), + ...new ProgressionValidator()->validate($workspace), + ...new DashValidator()->validate($workspace), + ...new MasteryValidator()->validate($workspace), + ...new IncomeValidator()->validate($workspace), + ...new WordFormValidator()->validate($workspace), ...$this->checkDefinitionIdentities($workspace), ...$this->checkActorDefinitions($workspace), + ...new ActorReferenceValidator()->validate($workspace), + ...new DialoguePresentationValidator()->validate($workspace), + ...new SkitBackdropValidator()->validate($workspace), + ...new RecordReferenceValidator()->validate($workspace), ...$this->checkKnowledgeCatalog($workspace), ...$this->checkSpecialProperties($workspace), + ...$this->checkElements($workspace), + ...$this->checkTilesets($workspace), ...$this->checkPermanentGrowth($workspace), ...$this->checkOptimizationPolicy($workspace), ...new SaveCompatibilityValidator()->validate($workspace), + ...new ScreenTransitionValidator()->validate($workspace), + ...new ReachabilityValidator()->validate($workspace), + ...new StoryGuidanceValidator()->validate($workspace), + ...new EffectValidator()->validate($workspace), ]; usort( @@ -135,51 +210,6 @@ public function validate(ProjectWorkspace $workspace): array return $issues; } - /** - * Returns the one error to report when a project has an inventory file - * the runtime cannot read, or null when it can. - * - * The engine's own constructors enforce a definition's bounds -- a sell - * rate outside 0 through 10000, a modifier outside -100 through 100, an - * affinity that is not a factor -- by refusing to build it, which takes - * the whole file with it. The catalogue is then empty, and every - * reference to an item in the project fails for a reason that has - * nothing to do with that reference. - * - * @param ProjectWorkspace $workspace The project. - * @return Issue|null The error, or null. - */ - protected function describeUnreadableInventory(ProjectWorkspace $workspace): ?Issue - { - $path = rtrim($workspace->projectRoot, '/') . '/assets/Data/items.php'; - - // Whether the file loaded at all, which a catalogue whose every - // definition is contested still did. - if (! is_file($path) || $this->inventoryCatalog->claimants() !== []) { - return null; - } - - try { - $payload = ProjectDirectoryContext::run($workspace->projectRoot, static fn(): mixed => require $path); - } catch (Throwable $throwable) { - return Issue::error( - 'assets/Data/items.php', - sprintf('The inventory could not be read: %s', $throwable->getMessage()), - 'Until this is fixed the project has no items, weapons or armors at all.' - ); - } - - if (is_array($payload) && $payload !== []) { - return Issue::error( - 'assets/Data/items.php', - 'The inventory holds entries the project could not read as definitions.', - 'Until this is fixed the project has no items, weapons or armors at all.' - ); - } - - return null; - } - /** * Checks that every inventory definition can be resolved to exactly one * thing, and that its policy numbers are ones the runtime accepts. @@ -260,11 +290,15 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array $seen = []; $statKeys = ActorStatPreview::statKeys(); + // What an actor's Attack command may use, by the rule its picker offers. + $attackSkills = new ReferenceCatalog($workspace)->valuesFor('attack_skills'); + foreach ($workspace->actorDatabase->getActors() as $actor) { $where = sprintf('actor %s', $actor->getName()); $id = $actor->getDefinitionId(); - if (isset($seen[$id])) { + $identityKey = strtolower($id); + if ($id !== '' && isset($seen[$identityKey])) { $issues[] = Issue::error( $where, sprintf('Two actors resolve to the identity "%s".', $id), @@ -272,13 +306,28 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array ); } - $seen[$id] = true; + if ($id !== '') { $seen[$identityKey] = true; } + + if (! $actor->hasDefinitionId()) { + $issues[] = Issue::error($where, 'Actor has no explicit stable id.', + 'For an absent id, use Freeze current name as ID in the actor editor or the CLI validation migration before renaming. Malformed explicit ids require manual correction; no file is repaired automatically.'); + } + + if ($actor->getAttackStyle() !== '' && ProjectActor::findAttackStyleType($actor->getAttackStyle()) === null) { + $issues[] = Issue::error( + $where, + sprintf('Its attack style "%s" is not a weapon type the Engine knows.', $actor->getAttackStyle()), + 'Choose one on the Attack Style row, or remove it to fight unarmed.' + ); + } - // An actor without its own id is resolved by display name, which the - // runtime supports deliberately. That is a convention worth adopting - // rather than a defect, so the Inspector says so on the row where it - // can be acted on; a validator reporting it on every legacy actor - // would be noise in front of the findings that are defects. + if ($actor->getAttackSkill() !== '' && ! in_array($actor->getAttackSkill(), $attackSkills, true)) { + $issues[] = Issue::error( + $where, + sprintf('Its attack skill "%s" is not a basic skill usable in battle.', $actor->getAttackSkill()), + 'Choose one on the Attack Skill row, or clear it for the built-in attack.' + ); + } $variants = $actor->getNaturalVariants(); $default = $actor->getDefaultNaturalVariantId(); @@ -329,6 +378,62 @@ protected function checkActorDefinitions(ProjectWorkspace $workspace): array * @param ProjectWorkspace $workspace The project. * @return Issue[] The issues found. */ + /** + * Checks every tileset as the Engine reads it, unsaved edits included: + * one the Engine refuses leaves every map that names it without its + * pieces and graphics, used by a map yet or not. Terrain kinds still + * awaiting their art are listed. + * + * @param ProjectWorkspace $workspace The project. + * @return Issue[] The issues found. + */ + protected function checkTilesets(ProjectWorkspace $workspace): array + { + $issues = []; + + foreach ($workspace->getRecordDatabase('tilesets')?->getRecords() ?? [] as $record) { + try { + $tileset = Tileset::fromArray($record->recordId, (array) $record->toArray()); + // A kind whose art is still to be drawn paints its glyph alone, by an authored state, until its autotile is bound. + $awaiting = array_filter($tileset->kinds, static fn(\Ichiloto\Engine\Rendering\Tilesets\TerrainKind $kind): bool => $kind->awaitingArt); + if ($awaiting !== []) { + $issues[] = Issue::warning( + sprintf('assets/%s/%s.php', Tileset::DIRECTORY, $record->recordId), + sprintf('%d terrain %s awaiting art: %s. Maps show %s glyph graphically.', count($awaiting), count($awaiting) === 1 ? 'kind is' : 'kinds are', + implode(', ', array_map(static fn($kind): string => $kind->name, $awaiting)), count($awaiting) === 1 ? 'its' : 'their'), + 'Bind each kind\'s autotile in Database > Tilesets once its art is drawn.', + ); + } + } catch (InvalidArgumentException $refused) { + $issues[] = Issue::error( + sprintf('assets/%s/%s.php', Tileset::DIRECTORY, $record->recordId), + $refused->getMessage(), + 'Correct it in Database > Tilesets.', + ); + } + } + + return $issues; + } + + /** + * Checks the project's elements as the game reads them at start: a name + * twice, or an empty one, stops the game from starting at all. + * + * @param ProjectWorkspace $workspace The project. + * @return Issue[] The issues found. + */ + protected function checkElements(ProjectWorkspace $workspace): array + { + try { + $workspace->getElementIdentities(); + } catch (InvalidArgumentException $refused) { + return [Issue::error('assets/Data/system.php elements', $refused->getMessage(), 'Give each element one distinct name in Types.')]; + } + + return []; + } + /** * Checks the special properties a project's equipment declares. * @@ -405,18 +510,19 @@ protected function checkSpecialProperties(ProjectWorkspace $workspace): array protected function checkPermanentGrowth(ProjectWorkspace $workspace): array { $where = PermanentGrowthCatalog::RELATIVE_PATH; - $path = rtrim($workspace->projectRoot, '/') . '/' . $where; - - if (! is_file($path)) { - return []; - } try { - $payload = (static fn(): mixed => require $path)(); + $file = $workspace->catalogs->readData(PermanentGrowthCatalog::ASSET_PATH); } catch (Throwable $throwable) { return [Issue::error($where, 'The definitions could not be loaded: ' . $throwable->getMessage())]; } + if (! $file['exists']) { + return []; + } + + $payload = $file['payload']; + if (! is_array($payload)) { return [Issue::error($where, 'The file must return a list of definitions.')]; } @@ -485,18 +591,19 @@ protected function checkPermanentGrowth(ProjectWorkspace $workspace): array protected function checkOptimizationPolicy(ProjectWorkspace $workspace): array { $where = EquipmentOptimizationPolicy::RELATIVE_PATH; - $path = rtrim($workspace->projectRoot, '/') . '/' . $where; - - if (! is_file($path)) { - return []; - } try { - $policy = (static fn(): mixed => require $path)(); + $file = $workspace->catalogs->readData(EquipmentOptimizationPolicy::ASSET_PATH); } catch (Throwable $throwable) { return [Issue::error($where, 'The policy could not be loaded: ' . $throwable->getMessage())]; } + if (! $file['exists']) { + return []; + } + + $policy = $file['payload']; + if (! is_array($policy)) { return [Issue::error($where, 'The file must return the policy as an array.')]; } @@ -513,12 +620,12 @@ protected function checkOptimizationPolicy(ProjectWorkspace $workspace): array } } - if (EquipmentOptimizationPolicy::isAvailable() && EquipmentOptimizationPolicy::enginePolicyFor($workspace->projectRoot) === null) { + if (EquipmentOptimizationPolicy::isAvailable() && EquipmentOptimizationPolicy::enginePolicyFor($workspace) === null) { // The engine refuses a weight key it does not have and a weight that // is not an integer, by throwing rather than by ignoring them. $issues[] = Issue::error( $where, - $this->describePolicyRefusal($workspace->projectRoot), + $this->describePolicyRefusal($workspace), 'Until this is fixed the game falls back to the legacy equal-weight policy.' ); } @@ -640,12 +747,12 @@ protected function checkOptimizationExclusions( * Returns why the engine refused to build the declared policy, in its own * words. * - * @param string $projectRoot The project root. + * @param ProjectWorkspace $workspace The project. * @return string The message. */ - protected function describePolicyRefusal(string $projectRoot): string + protected function describePolicyRefusal(ProjectWorkspace $workspace): string { - $declared = EquipmentOptimizationPolicy::declaredIn($projectRoot) ?? []; + $declared = EquipmentOptimizationPolicy::declaredIn($workspace) ?? []; try { new \Ichiloto\Engine\Entities\EquipmentOptimization\DeclaredEquipmentOptimizationPolicy( @@ -708,18 +815,18 @@ protected function describeOutcomeName(string $name, array $elements): ?string protected function checkKnowledgeCatalog(ProjectWorkspace $workspace): array { - $path = rtrim($workspace->projectRoot, '/') . '/assets/Data/knowledge.php'; - - if (! is_file($path)) { - return []; - } - try { - $catalog = (static fn(): mixed => require $path)(); + $file = $workspace->catalogs->readData(ProjectCatalogs::KNOWLEDGE_FILE); } catch (Throwable $throwable) { return [Issue::error('assets/Data/knowledge.php', 'The catalogue could not be loaded: ' . $throwable->getMessage())]; } + if (! $file['exists']) { + return []; + } + + $catalog = $file['payload']; + if (! is_array($catalog)) { return [Issue::error('assets/Data/knowledge.php', 'The catalogue must return an array.')]; } @@ -739,7 +846,15 @@ protected function checkKnowledgeCatalog(ProjectWorkspace $workspace): array } } - $recordTypes = array_map(strval(...), (array) ($catalog['recordTypes'] ?? [])); + $summons = $workspace->cutscenes?->ids(CutsceneType::SUMMON) ?? []; + $recordTypes = []; + foreach ((array) ($catalog['recordTypes'] ?? []) as $entry) { + try { + $recordTypes[] = KnowledgeRecordType::fromEntry($entry)->id; + } catch (InvalidArgumentException) { + // checkKnowledgeIdentities() reports an entry the runtime cannot read. + } + } $subjectIds = []; foreach ((array) ($catalog['subjects'] ?? []) as $index => $subject) { @@ -770,7 +885,14 @@ protected function checkKnowledgeCatalog(ProjectWorkspace $workspace): array } if (trim(strval($subject['quickCard'] ?? '')) === '') { - $issues[] = Issue::error($where, sprintf('%s has no quick card.', $label), 'A record shows its quick card first.'); + $issues[] = Issue::warning($where, sprintf('%s has no quick card yet.', $label), + 'Its record lists and counts without one; write it when its wording is ready.'); + } + + $summon = trim(strval($subject['summon'] ?? '')); + if ($summon !== '' && ! in_array($summon, $summons, true)) { + $issues[] = Issue::warning($where, sprintf('%s is a record of the summon "%s", which the project does not have.', $label, $summon), + 'Its record shows no Summon Info until it names a summon the project has.'); } } @@ -986,19 +1108,11 @@ protected function knowledgeCatalogData(): array return $this->knowledgeCatalogData; } - $path = rtrim($this->projectRootForKnowledge ?? '', '/') . '/assets/Data/knowledge.php'; - - if ($this->projectRootForKnowledge === null || ! is_file($path)) { - return $this->knowledgeCatalogData = []; - } - try { - $catalog = (static fn(): mixed => require $path)(); + return $this->knowledgeCatalogData = $this->validatedWorkspace?->catalogs->readKnowledgeData() ?? []; } catch (Throwable) { return $this->knowledgeCatalogData = []; } - - return $this->knowledgeCatalogData = is_array($catalog) ? $catalog : []; } /** @@ -1069,8 +1183,8 @@ protected function checkKnowledgeIdentities(array $catalog, string $where): arra $issues = []; $recordTypes = []; - foreach ((array) ($catalog['recordTypes'] ?? []) as $index => $type) { - $type = is_scalar($type) ? trim(strval($type)) : ''; + foreach ((array) ($catalog['recordTypes'] ?? []) as $index => $entry) { + $type = is_scalar($entry) ? trim(strval($entry)) : (is_array($entry) && is_scalar($entry['id'] ?? null) ? trim(strval($entry['id'])) : ''); if ($type === '') { $issues[] = Issue::error($where, sprintf('Record type %s is empty.', strval($index))); @@ -1078,6 +1192,11 @@ protected function checkKnowledgeIdentities(array $catalog, string $where): arra continue; } + if (is_array($entry) && array_diff(array_keys($entry), ['id', 'label']) !== []) { + $issues[] = Issue::error($where, sprintf('Record type "%s" holds more than its id and label.', $type), + 'The runtime refuses the catalogue until it is only an id, or an id with a label.'); + } + if (isset($recordTypes[$type])) { $issues[] = Issue::warning( $where, @@ -1192,6 +1311,19 @@ protected function checkTroops(ProjectWorkspace $workspace): array } } + foreach ($database->getRecords() as $record) { + $problem = is_array($record->toArray()) ? TroopEventPageContract::findProblem((array) $record->toArray()) : null; + + if ($problem !== null) { + // The Engine refuses the troop when its battle starts, with this message. + $issues[] = Issue::error( + 'troop ' . (trim(strval($record->get('name') ?? '')) ?: '(unnamed)'), + $problem, + 'Fix the event page or member in the troop, as the message says.', + ); + } + } + foreach ($database->getRecords() as $record) { $name = trim(strval($record->get('name') ?? '')) ?: '(unnamed)'; $problem = BattleEntryRuleContract::classificationProblem( @@ -1292,6 +1424,48 @@ protected function checkBattleEntryRules(ProjectWorkspace $workspace): array return $issues; } + /** + * Reports what the Engine's skill catalogue cannot identify: a record the + * runtime cannot read, or a skill whose name another skill already uses. + * + * @return Issue[] + */ + protected function checkSkillCatalog(ProjectWorkspace $workspace): array + { + return array_map( + static fn(string $problem): Issue => Issue::error( + 'assets/Data/Skills', + $problem, + 'Each skill is one record file; correct the file named, and give each skill a name no other skill uses.', + ), + $workspace->loadSkillCatalog()->getProblems(), + ); + } + + /** + * Reports the inventory records the Engine's item catalogue cannot read. + * + * The engine's own constructors enforce a definition's bounds (a sell rate + * outside 0 through 10000, a modifier outside -100 through 100, an + * affinity that is not a factor) by refusing to build it. The game then + * leaves that one record out, so it is reported against its own file in + * the engine's own words; a reference to it is still a reference to the + * definition the author meant, and is not reported again. + * + * @return Issue[] + */ + protected function checkItemCatalog(ProjectWorkspace $workspace): array + { + return array_map( + static fn(string $problem): Issue => Issue::error( + 'assets/Data/' . strstr($problem, ':', true), + ltrim((string) strstr($problem, ':'), ': '), + 'Each item, weapon and armor is one record file; correct the file named in Database > Items, Weapons or Armors.', + ), + $workspace->catalogs->listItemRecordProblems(), + ); + } + /** * Checks authored summon gates, wielder policies, linked actions, and * actor starting assignments without imposing any game-specific names. @@ -1300,8 +1474,7 @@ protected function checkBattleEntryRules(ProjectWorkspace $workspace): array */ protected function checkSummons(ProjectWorkspace $workspace): array { - $root = $workspace->projectRoot . '/assets/Cutscenes/Summons'; - $paths = glob($root . '/*/*.data.php') ?: []; + $paths = $workspace->catalogs->listSummonDataPaths(); if ($paths === []) { return $this->checkActorSummonAssignments($workspace, []); @@ -1312,23 +1485,16 @@ protected function checkSummons(ProjectWorkspace $workspace): array 'quests' => $catalog->valuesFor('quests'), 'inventory' => $catalog->valuesFor('inventory'), ]; - $actorNames = array_map( - static fn(\Ichiloto\Editor\ProjectActor $actor): string => $actor->getName(), - $workspace->actorDatabase->getActors(), - ); - $skillNames = array_map( - static fn(\Ichiloto\Editor\ProjectSkill $skill): string => $skill->getName(), - $workspace->skillDatabase->getSkills(), - ); + $skillNames = $workspace->getSkillNames(); + $definitions = []; $issues = []; foreach ($paths as $path) { - $file = ProjectDirectoryContext::run( + $data = ($workspace->readCurrentData($path) ?? ['payload' => ProjectDirectoryContext::run( $workspace->projectRoot, static fn(): PhpDataFile => PhpDataFile::load($path), - ); - $data = $file->payload; + )->payload])['payload']; $fallbackId = basename(dirname($path)); if (! is_array($data)) { @@ -1357,14 +1523,14 @@ protected function checkSummons(ProjectWorkspace $workspace): array $issues[] = Issue::error( $where, sprintf('It links to action "%s", which does not exist.', $linkedAction), - 'Choose an action from assets/Data/skills.php.', + 'Choose one of the skills under assets/Data/Skills.', ); } $issues = [ ...$issues, ...$this->checkSummonAvailability($data, $where, $known), - ...$this->checkSummonWielders($data, $where, $actorNames), + ...$this->checkSummonWielders($data, $where), ]; } @@ -1432,8 +1598,13 @@ protected function checkSummonAvailability(array $data, string $where, array $kn return [...$issues, ...$this->checkConditions($conditions, $where, $known)]; } - /** @return Issue[] */ - protected function checkSummonWielders(array $data, string $where, array $actorNames): array + /** + * The wielder policy's shape. Which actors it names is checked with every + * other actor reference, by stable id, by ActorReferenceValidator. + * + * @return Issue[] + */ + protected function checkSummonWielders(array $data, string $where): array { if (! array_key_exists('wielders', $data)) { return []; @@ -1464,18 +1635,6 @@ protected function checkSummonWielders(array $data, string $where, array $actorN if (! is_array($characters) || $characters === []) { $issues[] = Issue::error($where, 'Its character eligibility list is empty or malformed.', 'Name at least one project actor.'); - } else { - foreach ($characters as $character) { - $name = is_string($character) ? trim($character) : ''; - - if ($name === '' || ! in_array($name, $actorNames, true)) { - $issues[] = Issue::error( - $where, - sprintf('Its eligible character "%s" does not exist.', $name !== '' ? $name : '(malformed)'), - 'Use an exact actor identity from assets/Data/Actors.', - ); - } - } } } @@ -1514,7 +1673,7 @@ protected function checkActorSummonAssignments(ProjectWorkspace $workspace, arra $where = 'actor ' . $actor->getName(); $reported = []; - foreach ($diagnostics->forActor($actor->getName(), $actor->getClassName(), $actor->getSummons()) as $row) { + foreach ($diagnostics->forActor($actor->getRuntimeId(), $actor->getClassName(), $actor->getSummons()) as $row) { foreach ($row['problems'] as $problem) { // One assignment list reports duplicates once, as before. if (str_starts_with($problem['message'], 'Its summon assignments contain duplicate ids') && isset($reported['duplicates'])) { @@ -1555,17 +1714,36 @@ protected function checkMaps(ProjectWorkspace $workspace): array { $issues = []; $troops = $this->labelsOf($workspace, 'troops'); + $mapsById = array_column(array_map(static fn(ProjectMap $map): array => ['id' => $map->mapId, 'map' => $map], $workspace->maps), 'map', 'id'); foreach ($workspace->maps as $map) { + if (($sourceIssue = $map->getGridSourceIssue()) !== null) { + $issues[] = new Issue(Severity::ERROR, + $map->mapId, + "Map source is read-only: {$sourceIssue}", + 'Repair the source by hand, then reopen the project. Other maps remain editable.', + map: $map->mapId, + ); + continue; + } + $issues = [ ...$issues, - ...$this->checkLayers($map), - ...$this->checkEventMarkers($map), - ...$this->checkDoors($map, $workspace->mapIds), - ...$this->checkEncounters($map, $troops), - ...$this->checkNpcs($map, $workspace), - ...$this->checkDuplicateKeys($map), - ...$this->checkMapSource($map), + ...Issue::attributeToMap([ + ...$this->checkLayers($map), + ...MapGraphicsValidator::validate($map), + ...MapGraphicsValidator::validateCoverage($map), + ...PassageAgreementCheck::validate($map), + ...$this->checkRetiredMapData($map), + ...MapCompletenessCheck::validate($map, $this->inventoryCatalog), + ...$this->checkDoors($map, $workspace->mapIds), + ...MapRoleCheck::validate($map, $mapsById), + ...PlaceIconCheck::validate($map, $mapsById), + ...$this->checkEncounters($map, $troops), + ...$this->checkNpcs($map, $workspace), + ...$this->checkDuplicateKeys($map), + ...$this->checkMapSource($map), + ], $map->mapId), ]; } @@ -1580,6 +1758,14 @@ protected function checkMaps(ProjectWorkspace $workspace): array */ protected function checkLayers(ProjectMap $map): array { + if (! $map->isLegacyMap()) { + try { + $map->validateLayerContracts(); + } catch (\Throwable $error) { + return [Issue::error($map->mapId, $error->getMessage(), 'Repair the authored layer or the collision dictionary before saving.')]; + } + return []; + } $tileRows = count($map->tileLines); $eventRows = count($map->eventLines); @@ -1595,46 +1781,25 @@ protected function checkLayers(ProjectMap $map): array } /** - * Checks that placed markers and event definitions agree. + * Checks for map data the Engine no longer reads. * * @param ProjectMap $map The map. * @return Issue[] The issues found. */ - protected function checkEventMarkers(ProjectMap $map): array + protected function checkRetiredMapData(ProjectMap $map): array { - $issues = []; - $defined = array_keys((array) ($map->data['events'] ?? [])); - $placed = $map->getPlacedEventMarkers(); - - foreach (array_diff($placed, $defined) as $marker) { - $issues[] = Issue::error( - $map->mapId, - sprintf('The event layer places marker "%s", which the map does not define.', $marker), - 'The engine throws "Unmapped event markers" when the map loads. Define it, or clear the marker.' - ); - } - - foreach (array_diff($defined, $placed) as $marker) { - $issues[] = Issue::warning( - $map->mapId, - sprintf('Marker "%s" is defined but never placed on the event layer.', $marker), - 'Nothing can trigger it. Place it, or remove the definition.' - ); - } - - foreach ($placed as $marker) { - if (! $map->isEventMarkerSolidRectangle($marker)) { - $issues[] = Issue::error( - $map->mapId, - sprintf('Marker "%s" does not occupy one solid rectangle.', $marker), - 'The engine rejects sparse, cross-shaped, and disconnected event markers when the map loads.' - ); - } + if (! array_key_exists('tiles2d', $map->getEditableData())) { + return []; } - return $issues; + return [Issue::warning( + $map->mapId, + 'Its tiles2d crop table is no longer read.', + 'Glyph-keyed crops are retired; the map shows its terminal glyphs until it has a tileset. Remove the tiles2d key.' + )]; } + /** * Checks that doors lead somewhere. * @@ -1646,7 +1811,7 @@ protected function checkDoors(ProjectMap $map, array $mapIds): array { $issues = []; - foreach ((array) ($map->data['events'] ?? []) as $marker => $event) { + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $event) { if (! is_array($event)) { continue; } @@ -1675,6 +1840,22 @@ protected function checkDoors(ProjectMap $map, array $mapIds): array } } + // Edge triggers lead off the map the same way doors do. + foreach (array_values((array) ($map->getEditableData()['triggers'] ?? [])) as $index => $trigger) { + $destination = is_array($trigger) ? trim(strval($trigger['destinationMap'] ?? '')) : ''; + + if ($destination === '') { + $issues[] = Issue::error($map->mapId, sprintf('Edge trigger %d names no destinationMap.', $index + 1), + 'Add destinationMap, or remove the trigger.'); + } elseif (! in_array($destination, $mapIds, true)) { + $issues[] = Issue::error( + $map->mapId, + sprintf('Edge trigger %d leads to "%s", which is not a map in this project.', $index + 1, $destination), + 'Walking off this edge crashes the game. Check the map id.' + ); + } + } + return $issues; } @@ -1694,7 +1875,7 @@ protected function checkDoors(ProjectMap $map, array $mapIds): array */ protected function checkEncounters(ProjectMap $map, array $troops): array { - $authored = $map->data['encounters'] ?? null; + $authored = $map->getEditableData()['encounters'] ?? null; if ($authored === null) { return []; @@ -1761,63 +1942,356 @@ protected function checkEncounters(ProjectMap $map, array $troops): array } } + $issues = [...$issues, ...$this->checkEncounterAreas($map, $rows), ...$this->checkEncounterTerrains($map, $rows)]; + $issues = [...$issues, ...$this->checkBattleArena($encounters->mapArena(), $map->mapId, 'The encounters')]; + + foreach ($rows as $row) { + $issues = [...$issues, ...$this->checkBattleArena($row['arena'], $map->mapId, sprintf('The encounter with "%s"', $row['name']))]; + $issues = [...$issues, ...$this->checkEncounterHabitats($map, $row['name'], $row['arena'] ?? $encounters->mapArena())]; + } + + $issues = [...$issues, ...$this->checkEncounterArenaCoverage($map, $encounters)]; + return [...$issues, ...$this->checkEncounterRate($map, $encounters), ...$this->checkEncounterTiles($map, $encounters), ...$this->checkEncounterTroopDuplicates($map)]; } /** - * Checks the average number of steps between fights. + * Checks the map's battle arena rules and which arena art covers every + * random battle the map can start. The Engine reads the rules and draws + * the battles ({@see EncounterTable}, {@see EncounterArenaCoverage}); this + * reports what it leaves out and every arena and encounter terrain pair a + * battle is drawn on without a variant for, which falls back to the arena's + * own background. Every encounter cell counts, reached or not. * * @return Issue[] The issues found. */ - protected function checkEncounterRate(ProjectMap $map, MapEncounters $encounters): array + protected function checkEncounterArenaCoverage(ProjectMap $map, MapEncounters $encounters): array { - $rate = $encounters->rawRate(); + $issues = []; - if ($rate === null) { - return [Issue::warning( - $map->mapId, - sprintf('Encounters have no rate, so the engine uses its default of %d steps.', MapEncounters::DEFAULT_RATE), - "Set 'rate' to the average number of steps between fights." - )]; + foreach ($encounters->arenaRules() as $index => $rule) { + $issues = [...$issues, ...$this->checkBattleArena($rule['arena'], $map->mapId, sprintf('Battle arena rule %d', $index + 1))]; } - if (! is_numeric($rate)) { - return [Issue::error( - $map->mapId, - sprintf('The encounter rate is %s, which is not a number of steps.', var_export($rate, true)), - "Set 'rate' to a whole number of steps; the engine reads anything else as its default of " . MapEncounters::DEFAULT_RATE . '.' - )]; + try { + $block = $map->getEditableData()[MapEncounters::KEY] ?? null; + $table = EncounterTable::readBlock(is_array($block) ? $block : null, MapAreaAuthoring::readCatalog($map), $map->resolveEncounterTerrain()); + $collisions = $map->getResolvedCollisionMap(); + } catch (Throwable) { + // A map the Engine cannot read is reported by the checks that read it. + return $issues; } - if (intval($rate) < 1) { - return [Issue::error( - $map->mapId, - sprintf('The encounter rate is %s, which the engine clamps to one step.', var_export($rate, true)), - 'A fight would start on almost every step. Set the average number of steps between fights.' - )]; + foreach ($table->ignored as $ignored) { + if (str_starts_with($ignored, 'Battle arena rule') || str_contains($ignored, MapEncounters::ARENA_RULES_KEY)) { + $issues[] = Issue::error($map->mapId, $ignored, + 'A rule names one of the map\'s areas, an encounter terrain its kinds meet, or both, and the arena it picks.'); + } } - if (floatval($rate) !== floatval(intval($rate))) { - return [Issue::warning( - $map->mapId, - sprintf('The encounter rate is %s, which the engine reads as %d steps.', var_export($rate, true), intval($rate)), - 'Rates are whole numbers of steps; write the one you mean.' - )]; + if ($this->arenaCatalog === null) { + return $issues; } - return []; + $coverage = EncounterArenaCoverage::measure($table, $collisions, $this->arenaCatalog); + foreach ($coverage->findMissingVariants() as $missing) { + $issues[] = Issue::warning($map->mapId, + sprintf('Battles on the arena "%s" start on %s (%s, %d %s), which it has no variant for, so they are drawn on its own background.', + $missing['arena'], $missing['terrain'], implode('; ', $missing['scopes']), $missing['cells'], $missing['cells'] === 1 ? 'cell' : 'cells'), + sprintf('Add a %s variant to "%s" on its Battle Arenas page, naming the arena itself if its picture already shows %s, or give this map an arena rule for it.', + $missing['terrain'], $missing['arena'], $missing['terrain'])); + } + + // A troop drawn on a variant or a rule's arena still needs somewhere there for its members to stand; + // the arena it is named on is checked with its row. + $named = array_column(array_map(fn(array $row): array => [$row['name'], $row['arena'] ?? $encounters->mapArena() ?? $this->defaultArena], + $encounters->rows()), 1, 0); + $habitats = []; + foreach ($coverage->rows as $row) { + foreach ($row['drawn'] === null ? [] : $row['troops'] as $troop) { + if (($named[$troop] ?? null) === $row['drawn']) { + continue; + } + foreach ($this->checkEncounterHabitats($map, $troop, $row['drawn']) as $issue) { + $habitats[$issue->message] = $issue; + } + } + } + + return [...$issues, ...array_values($habitats)]; } /** - * Checks which tiles count towards the next fight. + * Checks that every encounter terrain a troop is limited to is one the + * map's terrain kinds meet, in a scope the engine reads: the engine never + * meets such a troop rather than meeting it on any terrain. * + * @param array}> $rows * @return Issue[] The issues found. */ - protected function checkEncounterTiles(ProjectMap $map, MapEncounters $encounters): array + protected function checkEncounterTerrains(ProjectMap $map, array $rows): array { - $tiles = $map->data['encounters']['tiles'] ?? null; + $scoped = array_filter($rows, static fn(array $row): bool => $row['terrains'] !== []); - if ($tiles === null) { + if ($scoped === []) { + return []; + } + + try { + $known = $map->loadTerrainKinds()?->getEncounterTerrains(); + } catch (InvalidArgumentException) { + // The kinds themselves are reported with the map's completeness. + return []; + } + + $issues = []; + + foreach ($scoped as $row) { + if (EncounterEntry::getFromValue(['weight' => 1, MapEncounters::TERRAINS_KEY => $row['terrains']]) === null) { + $issues[] = Issue::error($map->mapId, + sprintf('The troop "%s" is limited to terrains the engine cannot read (%s), so it is never met.', $row['name'], implode(', ', $row['terrains'])), + 'List each encounter terrain id once.'); + + continue; + } + + foreach (array_diff($row['terrains'], $known ?? []) as $terrain) { + $issues[] = Issue::error($map->mapId, + sprintf('The troop "%s" is limited to the encounter terrain "%s", which no terrain kind of this map meets, so it is never met.', $row['name'], $terrain), + $known === null ? 'Paint the map in terrain kinds first.' : sprintf('This map\'s kinds meet %s.', $known === [] ? 'no encounter terrain' : implode(', ', $known))); + } + } + + return $issues; + } + + /** + * Checks that every area an encounter troop is limited to is one the map + * declares, and that its scope is one the engine reads: the engine never + * meets a troop whose scope it cannot place, rather than meeting it + * map-wide. + * + * @param array}> $rows + * @return Issue[] The issues found. + */ + protected function checkEncounterAreas(ProjectMap $map, array $rows): array + { + $scoped = array_filter($rows, static fn(array $row): bool => $row['areas'] !== []); + + if ($scoped === []) { + return []; + } + + try { + $declared = array_keys(MapAreaAuthoring::readCatalog($map)->areas); + } catch (MapAreaRefusal) { + // The areas themselves are reported where they are declared. + return []; + } + + $issues = []; + + foreach ($scoped as $row) { + if (EncounterEntry::getFromValue(['weight' => 1, MapEncounters::AREAS_KEY => $row['areas']]) === null) { + $issues[] = Issue::error($map->mapId, + sprintf('The troop "%s" is limited to areas the engine cannot read (%s), so it is never met.', $row['name'], implode(', ', $row['areas'])), + 'List each of the map\'s area ids once.'); + + continue; + } + + foreach (array_diff($row['areas'], $declared) as $area) { + $issues[] = Issue::error($map->mapId, + sprintf('The troop "%s" is limited to the area "%s", which this map does not declare, so it is never met.', $row['name'], $area), + $declared === [] ? 'Declare the area on the map first.' : sprintf('This map\'s areas are %s.', implode(', ', $declared))); + } + } + + return $issues; + } + + /** + * Reads the arena keys a battle may name, once per validation. + */ + protected function readArenaKeys(ProjectWorkspace $workspace): void + { + $this->arenaKeys = null; + $this->arenaCatalogProblem = null; + $this->arenaFootings = []; + $this->defaultArena = null; + $this->arenaCatalog = null; + + try { + $catalog = $workspace->catalogs->loadBattlePresentation(); + $this->arenaCatalog = $catalog; + $this->arenaKeys = $catalog === null ? null : array_map(strval(...), array_keys($catalog->getArenaChoices())); + $this->arenaFootings = array_map(static fn($arena): BattleFooting => $arena->footing, $catalog?->arenas ?? []); + $this->defaultArena = $catalog?->defaultArena; + } catch (Throwable $problem) { + $this->arenaCatalogProblem = $problem->getMessage(); + } + } + + /** + * Each troop's members with where they live, from the troops and enemies + * as they are now. An enemy the project does not define, or whose habitat + * cannot be read, is reported where it is authored, so it is left out. + * + * @return array> + */ + protected function readTroopHabitats(ProjectWorkspace $workspace): array + { + $troops = $workspace->getRecordDatabase('troops'); + $enemies = $workspace->getRecordDatabase('enemies'); + + if (! $troops instanceof ProjectRecordDatabase || ! $enemies instanceof ProjectRecordDatabase) { + return []; + } + + $habitats = []; + + foreach ($enemies->getRecords() as $record) { + $habitat = $record->get('habitat'); + $habitats[strval($record->get('name'))] = $habitat === null ? EnemyHabitat::LAND : (is_string($habitat) ? EnemyHabitat::tryFrom($habitat) : null); + } + + $members = []; + + foreach ($troops->getRecords() as $record) { + foreach ($record->getSubList('enemies') as $member) { + $enemy = is_array($member) && is_string($member['enemy'] ?? null) ? $member['enemy'] : null; + + if ($enemy !== null && ($habitats[$enemy] ?? null) !== null) { + $members[strval($record->get('name'))][] = ['enemy' => $enemy, 'habitat' => $habitats[$enemy]]; + } + } + } + + return $members; + } + + /** + * Checks that every member of an encounter's troop has somewhere to stand + * on the arena it is fought on: a water creature needs the arena's water, + * a land beast its land. Graphical presentation only, so a battle still + * starts; the member would stand wherever its side's area allows. + * + * @return Issue[] The issues found. + */ + protected function checkEncounterHabitats(ProjectMap $map, string $troop, mixed $arena): array + { + $arena = is_string($arena) && $arena !== '' ? $arena : $this->defaultArena; + $footing = $arena === null ? null : ($this->arenaFootings[$arena] ?? null); + + if ($footing === null) { + return []; + } + + $issues = []; + + foreach ($this->troopHabitats[$troop] ?? [] as ['enemy' => $enemy, 'habitat' => $habitat]) { + if (! $footing->hasFootingFor($habitat)) { + $issues[$enemy] = Issue::warning($map->mapId, + sprintf('The encounter with "%s" is fought on the arena "%s", which has no %s for %s to stand on.', + $troop, $arena, $habitat->value, $enemy), + sprintf('Mark the arena\'s %s footing on its Battle Arenas page, fight this troop on another arena, or change %s\'s habitat.', + $habitat->value, $enemy)); + } + } + + return array_values($issues); + } + + /** + * Checks an arena a battle names: graphical presentation only, so it is + * checked against what the battle presentation declares, and a battle + * that names none takes the presentation's default. + * + * @return Issue[] The issues found. + */ + protected function checkBattleArena(mixed $arena, string $where, string $subject): array + { + if ($arena === null) { + return []; + } + + if (! is_string($arena) || trim($arena) === '') { + return [Issue::error($where, sprintf('%s names a battleArena that is not an arena key.', $subject), + 'Name one of the battle presentation\'s arenas, or remove it to use the default arena.')]; + } + + if ($this->arenaCatalogProblem !== null) { + return [Issue::error($where, sprintf('%s names the arena "%s", but the battle presentation cannot be read: %s', $subject, $arena, $this->arenaCatalogProblem), + sprintf('Correct assets/%s.', BattlePresentationCatalog::FILE))]; + } + + if ($this->arenaKeys === null) { + return [Issue::error($where, sprintf('%s names the arena "%s", but the project declares no battle presentation to draw it in.', $subject, $arena), + sprintf('Remove the battleArena, or declare arenas in assets/%s.', BattlePresentationCatalog::FILE))]; + } + + if (! in_array($arena, $this->arenaKeys, true)) { + return [Issue::error($where, sprintf('%s names the arena "%s", which the battle presentation does not declare.', $subject, $arena), + 'Choose one of its arenas, or remove the battleArena to use the default arena.')]; + } + + return []; + } + + /** + * Checks the average number of steps between fights. + * + * @return Issue[] The issues found. + */ + protected function checkEncounterRate(ProjectMap $map, MapEncounters $encounters): array + { + $rate = $encounters->rawRate(); + + if ($rate === null) { + return [Issue::warning( + $map->mapId, + sprintf('Encounters have no rate, so the engine uses its default of %d steps.', MapEncounters::DEFAULT_RATE), + "Set 'rate' to the average number of steps between fights." + )]; + } + + if (! is_numeric($rate)) { + return [Issue::error( + $map->mapId, + sprintf('The encounter rate is %s, which is not a number of steps.', var_export($rate, true)), + "Set 'rate' to a whole number of steps; the engine reads anything else as its default of " . MapEncounters::DEFAULT_RATE . '.' + )]; + } + + if (intval($rate) < 1) { + return [Issue::error( + $map->mapId, + sprintf('The encounter rate is %s, which the engine clamps to one step.', var_export($rate, true)), + 'A fight would start on almost every step. Set the average number of steps between fights.' + )]; + } + + if (floatval($rate) !== floatval(intval($rate))) { + return [Issue::warning( + $map->mapId, + sprintf('The encounter rate is %s, which the engine reads as %d steps.', var_export($rate, true), intval($rate)), + 'Rates are whole numbers of steps; write the one you mean.' + )]; + } + + return []; + } + + /** + * Checks which tiles count towards the next fight. + * + * @return Issue[] The issues found. + */ + protected function checkEncounterTiles(ProjectMap $map, MapEncounters $encounters): array + { + $tiles = $map->getEditableData()['encounters']['tiles'] ?? null; + + if ($tiles === null) { return []; } @@ -1861,8 +2335,10 @@ protected function checkEncounterTroopDuplicates(ProjectMap $map): array * `NpcManager::configure()` skips an entry silently when it is not an * array, has no name, or has no coordinates; ignores a wander area or * directional map that is not an array; treats any movement but `wander` - * as fixed; clamps wander dimensions to 1; and throws on a duplicate id. - * `Player::interact()` talks to the NPC on the faced tile, and + * as fixed; reads `directionFix` as set only when it is exactly true; + * clamps wander dimensions to 1; and throws on a duplicate id. + * `Player::interact()` turns the NPC on the faced tile toward the player + * unless its direction is fixed, then talks to it, and * `MapManager::canMoveTo()` refuses a tile an NPC stands on. Every check * here follows from one of those, with an error where the authored * content cannot happen and a warning where it can but probably not as @@ -1874,14 +2350,14 @@ protected function checkEncounterTroopDuplicates(ProjectMap $map): array */ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): array { - if (! array_key_exists('npcs', $map->data) || $map->data['npcs'] === null) { + if (! array_key_exists('npcs', $map->getEditableData()) || $map->getEditableData()['npcs'] === null) { return []; } - if (! is_array($map->data['npcs'])) { + if (! is_array($map->getEditableData()['npcs'])) { return [Issue::error( $map->mapId, - sprintf('The npcs block is %s, not a list of NPC entries.', get_debug_type($map->data['npcs'])), + sprintf('The npcs block is %s, not a list of NPC entries.', get_debug_type($map->getEditableData()['npcs'])), 'The game reads npcs as a list; every NPC on this map is dropped until it is one.' )]; } @@ -1891,7 +2367,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra $height = $map->getHeight(); $anchors = []; - foreach ($map->data['npcs'] as $index => $entry) { + foreach ($map->getEditableData()['npcs'] as $index => $entry) { $ordinal = is_int($index) ? $index + 1 : strval($index); if (! is_array($entry)) { @@ -1906,6 +2382,16 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra $name = is_scalar($entry['name'] ?? null) ? trim(strval($entry['name'])) : ''; $where = sprintf('%s NPC %s', $map->mapId, $name !== '' ? $name : sprintf('entry %s', $ordinal)); + if (array_key_exists('sprites2d', $entry)) { + try { + if (! is_array($entry['sprites2d'])) { throw new \RuntimeException('sprites2d must be a character sheet definition array.'); } + \Ichiloto\Editor\Field\NpcCharacterSheet::validate($entry['sprites2d'], $workspace->projectRoot . '/assets'); + } catch (\Throwable $error) { + $issues[] = Issue::warning($where, 'Invalid sprites2d: ' . $error->getMessage(), + 'The game keeps the terminal appearance and interaction. Name an RPG Maker character sheet (sheet, optional index and layer), or remove the optional sprites2d.'); + } + } + if ($name === '') { $issues[] = Issue::error( $where, @@ -1941,7 +2427,7 @@ protected function checkNpcs(ProjectMap $map, ProjectWorkspace $workspace): arra * @param string $where Where it lives. * @param int $width The map width. * @param int $height The map height. - * @param array $anchors Anchors seen so far, appended to. + * @param array}> $anchors Anchors seen so far, appended to. * @param string $name The NPC's name. * @param int|string $index The entry index. * @return Issue[] The issues found. @@ -1986,6 +2472,7 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in 'where' => $where, 'wanders' => strval($entry['movement'] ?? 'fixed') === 'wander', 'width' => ProjectNpc::glyphWidth($sprite), + 'conditions' => is_array($entry['conditions'] ?? null) ? $entry['conditions'] : [], ]; if ($x < 0 || $y < 0 || $x >= $width || $y >= $height) { @@ -2000,7 +2487,8 @@ protected function checkNpcPlacement(array $entry, string $where, int $width, in } /** - * Checks the base sprite: present as text, visible, and inside the map. + * Checks the base sprite: text and inside the map, with explicit empty + * text reserved for an interaction whose appearance belongs to the map. * * @param array $entry The NPC entry. * @param string $where Where it lives. @@ -2021,6 +2509,10 @@ protected function checkNpcSprite(array $entry, string $where, int $width): arra )]; } + if ($entry['sprite'] === '') { + return []; + } + $sprite = strval($entry['sprite']); if (trim(ProjectNpc::strippedGlyph($sprite)) === '') { @@ -2045,8 +2537,9 @@ protected function checkNpcSprite(array $entry, string $where, int $width): arra } /** - * Checks the movement mode and the wander area against how the game - * wanders: only into tiles inside the area, never off the map. + * Checks the movement mode, the direction fix and the wander area against + * how the game moves: wandering only into tiles inside the area, never off + * the map, and turning to the player unless the heading is fixed. * * @param array $entry The NPC entry. * @param string $where Where it lives. @@ -2076,6 +2569,23 @@ protected function checkNpcMovement(array $entry, string $where, int $width, int $wanders = $movement === 'wander'; + if (array_key_exists('directionFix', $entry) && ! is_bool($entry['directionFix'])) { + $issues[] = Issue::error( + $where, + sprintf('Its directionFix is %s, not true or false.', get_debug_type($entry['directionFix'])), + 'The game keeps its heading only for exactly true, so it turns to face the player. Write true or false.' + ); + } + + if (array_key_exists('facing', $entry) + && (! is_string($entry['facing']) || ! in_array(strtolower(trim($entry['facing'])), ProjectNpc::DIRECTIONS, true))) { + $issues[] = Issue::warning( + $where, + sprintf('Its facing %s is not north, south, east or west.', is_string($entry['facing']) ? "\"{$entry['facing']}\"" : get_debug_type($entry['facing'])), + 'The game stands it facing south instead. Choose one of the four headings.' + ); + } + if (! array_key_exists('wanderArea', $entry)) { return $issues; } @@ -2263,14 +2773,9 @@ protected function checkNpcInteractionShapes(array $entry, string $where): array ); } - $isVariantList = false; - - foreach ($dialogue as $page) { - if (is_array($page) && (isset($page['lines']) || isset($page['script']) || isset($page['conditions']))) { - $isVariantList = true; - break; - } - } + // The Engine's own test, so the editor never disagrees with the game about which it is reading. + $isVariantList = ConditionalDialogue::isVariantList($dialogue); + $isPage = static fn(array $page): bool => array_intersect(array_keys($page), ConditionalDialogue::VARIANT_FIELDS) !== []; foreach ($dialogue as $position => $page) { if (! is_array($page)) { @@ -2280,7 +2785,7 @@ protected function checkNpcInteractionShapes(array $entry, string $where): array $ordinal = is_int($position) ? $position + 1 : strval($position); if ($isVariantList) { - if (! isset($page['lines']) && ! isset($page['script']) && ! isset($page['conditions'])) { + if (! $isPage($page)) { $issues[] = Issue::error( $where, sprintf('Dialogue entry %s is a plain page inside a list of variants.', $ordinal), @@ -2331,6 +2836,29 @@ protected function checkNpcInteractionShapes(array $entry, string $where): array return [...$issues, ...$this->checkStateWrites(is_array($entry['sets'] ?? null) ? $entry['sets'] : [], $where)]; } + /** + * Returns each event marker's tiles on a map, keyed "x,y". + * + * @param ProjectMap $map The map. + * @return array> Tiles keyed by marker. + */ + protected function eventAreaCells(ProjectMap $map): array + { + $cells = []; + + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + $marker = $map->getEventSymbol($x, $y); + + if (trim($marker) !== '') { + $cells[$marker]["{$x},{$y}"] = true; + } + } + } + + return $cells; + } + /** * Reports fields the runtime does not read. They are preserved by the * editor, so this is a warning, not a loss. @@ -2361,25 +2889,43 @@ protected function checkNpcUnknownFields(array $entry, string $where): array * Checks where NPCs stand against each other, against event tiles and * spawn tiles. * - * The game refuses to move onto a tile an NPC stands on, so a fixed NPC - * on an event tile makes that event unreachable; a wanderer only blocks - * it while there. Two NPCs on one tile: `npcAt` finds the first, so the - * second can never be spoken to while both stand still. + * The game refuses to move onto a tile an NPC stands on. An NPC on one + * tile of an event's area is fine while the area has another tile to step + * onto (a shopkeeper behind the counter of a shop area, a guest inside a + * celebration area). Only when fixed NPCs cover every tile of the area can + * the event never fire; a wanderer only blocks it while there. Two NPCs on + * one tile: `npcAt` finds the first, so the second can never be spoken to + * while both stand still, unless their visibility conditions exclude each + * other, as phases of one presence do. * * @param ProjectMap $map The map. * @param ProjectWorkspace $workspace The project. - * @param array $anchors The NPC anchors. + * @param array}> $anchors The NPC anchors. * @return Issue[] The issues found. */ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspace, array $anchors): array { $issues = []; - $definitions = (array) ($map->data['events'] ?? []); + $definitions = (array) ($map->getEditableData()['events'] ?? []); $spawns = $this->spawnTilesOn($map, $workspace); + $occupiedBy = ['fixed' => [], 'any' => []]; + + foreach ($anchors as $anchor) { + $cell = $anchor['x'] . ',' . $anchor['y']; + $occupiedBy['any'][$cell] = true; + + if (! $anchor['wanders']) { + $occupiedBy['fixed'][$cell] = true; + } + } + + $areaCells = $this->eventAreaCells($map); foreach ($anchors as $index => $anchor) { foreach ($anchors as $otherIndex => $other) { - if ($otherIndex <= $index) { + // Phases whose visibility conditions exclude each other are never + // on the field together, so they may share or overlap a tile. + if ($otherIndex <= $index || WorldConditionExclusion::proveExclusive($anchor['conditions'], $other['conditions'])) { continue; } @@ -2411,19 +2957,21 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa $marker = $map->getEventSymbol($anchor['x'], $anchor['y']); - if (trim($marker) !== '' && isset($definitions[$marker])) { + $isAreaCovered = static fn(array $occupied): bool => array_diff_key($areaCells[$marker] ?? [], $occupied) === []; + + if (trim($marker) !== '' && isset($definitions[$marker]) && $isAreaCovered($occupiedBy['any'])) { $class = is_array($definitions[$marker]) ? strval($definitions[$marker]['class'] ?? '') : ''; $classLabel = $class !== '' ? sprintf(' (%s)', basename(str_replace('\\', '/', $class))) : ''; - $issues[] = $anchor['wanders'] + $issues[] = ! $isAreaCovered($occupiedBy['fixed']) ? Issue::warning( $anchor['where'], - sprintf('It starts on event marker %s%s at (%d, %d).', $marker, $classLabel, $anchor['x'], $anchor['y']), - 'The player cannot step onto that tile while the NPC stands there, so the event cannot fire until it wanders off.' + sprintf('It starts on event marker %s%s at (%d, %d), and NPCs cover every tile of that event.', $marker, $classLabel, $anchor['x'], $anchor['y']), + 'The player cannot step onto the event while they stand there, so it cannot fire until a wanderer moves off.' ) : Issue::error( $anchor['where'], - sprintf('It stands on event marker %s%s at (%d, %d).', $marker, $classLabel, $anchor['x'], $anchor['y']), - 'The player can never step onto that tile, so the event never fires. Move the NPC or the marker.' + sprintf('It stands on event marker %s%s at (%d, %d), and NPCs cover every tile of that event.', $marker, $classLabel, $anchor['x'], $anchor['y']), + 'The player can never step onto the event, so it never fires. Move the NPC, or give the event a free tile.' ); } @@ -2456,7 +3004,7 @@ protected function checkNpcCollisions(ProjectMap $map, ProjectWorkspace $workspa protected function spawnTilesOn(ProjectMap $map, ProjectWorkspace $workspace): array { $spawns = []; - $positions = $workspace->systemDatabase->getField('startingPositions'); + $positions = $workspace->getSystemField('startingPositions'); $start = is_array($positions) ? ($positions['player'] ?? null) : null; if ( @@ -2474,7 +3022,7 @@ protected function spawnTilesOn(ProjectMap $map, ProjectWorkspace $workspace): a } foreach ($workspace->maps as $source) { - foreach ((array) ($source->data['events'] ?? []) as $marker => $definition) { + foreach ((array) ($source->getEditableData()['events'] ?? []) as $marker => $definition) { if (! is_array($definition)) { continue; } @@ -2515,7 +3063,7 @@ protected function checkNpcIdentities(ProjectMap $map): array $seen = []; $issues = []; - foreach ((array) ($map->data['npcs'] ?? []) as $index => $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $index => $npc) { if (! is_array($npc)) { continue; } @@ -2633,7 +3181,7 @@ protected function checkMapSource(ProjectMap $map): array $names = []; - foreach ((array) ($map->data['npcs'] ?? []) as $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { if (! is_array($npc) || trim(strval($npc['id'] ?? '')) !== '') { continue; } @@ -2688,10 +3236,22 @@ protected function duplicateKeysAt(ProjectMap $map, array $path): array $keyOfArray = []; $pendingKey = null; $occurrences = []; + // Whether each open bracket or parenthesis opened an array: `[...]`, or + // `array(...)` as var_export writes it, beside the parentheses of calls. + $opened = []; + $previous = null; foreach ($tokens as $index => $token) { + if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + $after = $previous; + $previous = $token; + if (! is_array($token)) { - if ($token === '[') { + $opens = $token === '[' || ($token === '(' && is_array($after) && $after[0] === T_ARRAY); + if ($opens) { + $opened[] = true; $depth++; // The key this array was written against, so a nested block knows // its own name. @@ -2701,10 +3261,18 @@ protected function duplicateKeysAt(ProjectMap $map, array $path): array continue; } - if ($token === ']') { - unset($keyOfArray[$depth]); - $depth = max(0, $depth - 1); - $pendingKey = null; + if ($token === '(') { + $opened[] = false; + + continue; + } + + if ($token === ']' || $token === ')') { + if (array_pop($opened) === true) { + unset($keyOfArray[$depth]); + $depth = max(0, $depth - 1); + $pendingKey = null; + } continue; } @@ -2770,7 +3338,7 @@ protected function isFollowedByArrow(array $tokens, int $index): bool */ protected function checkQuests(ProjectWorkspace $workspace): array { - $quests = $workspace->questDatabase->getQuests(); + $quests = $workspace->getQuests(); if ($quests === []) { return []; @@ -2794,8 +3362,9 @@ protected function checkQuests(ProjectWorkspace $workspace): array $issues = [ ...$issues, - ...$this->checkObjectives($quest, $where, $workspace->mapIds, $items, $foes, $known), + ...$this->checkObjectives($quest, $where, $workspace->mapIds, $items, $foes, $known, $workspace), ...$this->checkConditions($quest->getPrerequisites(), $where, $known, true), + ...$this->checkWaymarks($quest->getStartWaymarks(), $where . ' start', $workspace->mapIds, $known, $workspace), ]; } @@ -2820,6 +3389,7 @@ protected function checkObjectives( array $items, array $foes, array $known, + ?ProjectWorkspace $workspace = null, ): array { $objectives = $quest->getObjectives(); @@ -2867,6 +3437,8 @@ protected function checkObjectives( ]; } + $issues = [...$issues, ...$this->checkWaymarks($objective['waymarks'] ?? [], $where, $mapIds, $known, $workspace)]; + if ($target === '') { $issues[] = Issue::error($where, sprintf('A "%s" objective names no target.', $type), 'It can never advance.'); continue; @@ -2891,6 +3463,55 @@ protected function checkObjectives( return $issues; } + /** + * Checks a list of quest waymarks (an objective's, or a quest's start + * waymarks): the Engine's own shape rules, a map the project defines, a + * subject that map still has, and their conditions. + * + * @param mixed $waymarks The authored list. + * @param string[] $mapIds Every map in the project. + * @param array $known Known project references. + * @return Issue[] The issues found. + */ + protected function checkWaymarks(mixed $waymarks, string $where, array $mapIds, array $known, ?ProjectWorkspace $workspace): array + { + if (! is_array($waymarks) || ! array_is_list($waymarks)) { + return [Issue::error($where, 'The waymarks are malformed.', 'Use a list of waymarks, each naming a map.')]; + } + + $issues = []; + $categories = ['event' => 'event_markers', 'npc' => 'map_npcs', 'object' => 'map_world_objects', 'landmark' => 'map_landmarks']; + + foreach ($waymarks as $index => $entry) { + $at = sprintf('%s waymark %d', $where, $index + 1); + + try { + $waymark = QuestWaymark::fromArray($entry); + } catch (InvalidArgumentException $exception) { + $issues[] = Issue::error($at, 'The waymark cannot be read: ' . $exception->getMessage(), 'The quest is skipped until it is fixed.'); + continue; + } + + if (! in_array($waymark->map, $mapIds, true)) { + $issues[] = Issue::error($at, sprintf('The waymark names the map "%s", which the project does not define.', $waymark->map), + 'Choose one of the project\'s maps.'); + continue; + } + + $map = $workspace === null ? null : (new ReferenceCatalog($workspace))->findMap($waymark->map); + + if ($waymark->subject !== null && $map !== null + && ! in_array($waymark->subjectId, (new ReferenceCatalog($workspace, $map))->valuesFor($categories[$waymark->subject->value]), true)) { + $issues[] = Issue::error($at, sprintf('The waymark points at %s "%s", which %s does not have.', + $waymark->subject->value, $waymark->subjectId, $waymark->map), 'It would never be shown; choose what is on that map now.'); + } + + $issues = [...$issues, ...$this->checkConditions($waymark->conditions, $at, $known)]; + } + + return $issues; + } + /** * Returns the names a database category defines. * @@ -2943,16 +3564,41 @@ protected function checkReferences(ProjectWorkspace $workspace): array 'sfx' => $catalog->valuesFor('sfx'), 'common_events' => $catalog->valuesFor('common_events'), 'cinematics' => $catalog->valuesFor('cinematics'), + 'animations' => $catalog->valuesFor('animations'), + 'effects' => $catalog->valuesFor('effects'), + 'actors' => $catalog->valuesFor('actors'), + 'income_schedules' => $catalog->valuesFor('income_schedules'), ]; return [ + ...$this->checkConfigReferences($workspace, $known), ...$this->checkSkitReferences($workspace, $known), ...$this->checkMapReferences($workspace, $known), ...$this->checkScriptReferences($workspace, $known), + ...$this->checkTroopEventReferences($workspace, $known), ...$this->checkAchievementReferences($workspace, $known), ]; } + /** + * Checks the config.php settings that name another resource, where this + * validation knows the resources of that kind. + * + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkConfigReferences(ProjectWorkspace $workspace, array $known): array + { + $issues = []; + foreach (ProjectConfig::ENGINE_REFERENCES as $path => $category) { + $value = $workspace->config?->getRecord($path)->get('value'); + if (is_string($value) && isset($known[$category])) { + $issues = [...$issues, ...$this->checkReference($value, $category, 'setting ' . $path, 'config.php: ' . $path, $known)]; + } + } + return $issues; + } + /** * Checks manually authored achievement condition records. * @@ -3008,6 +3654,12 @@ protected function checkSkitReferences(ProjectWorkspace $workspace, array $known } $issues = []; + try { + $actors = $workspace->actorDatabase->createActorStore(); + } catch (InvalidArgumentException|\RuntimeException $exception) { + $actors = null; + $issues[] = Issue::error('skit actors', $exception->getMessage(), 'Repair the actor registry before validating skit actor references.'); + } foreach ($database->getRecords() as $record) { $skit = (array) $record->toArray(); @@ -3023,6 +3675,26 @@ protected function checkSkitReferences(ProjectWorkspace $workspace, array $known } $issues = [...$issues, ...$this->checkConditions((array) ($skit['conditions'] ?? []), $where, $known)]; + foreach ($skit['beats'] ?? [] as $index => $beat) { + if (! is_array($beat)) { + $issues[] = Issue::error($where, 'Each skit beat must be a data record.'); + continue; + } + if ($actors === null) { continue; } + $speaker = SkitSpeaker::getFromBeat($beat, $actors); + $beatLocation = sprintf('%s beat %d', $where, $index + 1); + foreach ($speaker->notices as $notice) { + $issues[] = Issue::warning($beatLocation, $notice, 'Choose the Actor picker to persist the stable actor id.'); + } + foreach ($speaker->errors as $error) { + $hint = array_key_exists('actor', $beat) && array_key_exists('speaker', $beat) + ? 'Choose an Actor, or enter a non-actor speaker, not both.' + : (array_key_exists('actor', $beat) + ? 'Choose an existing actor from the Actor picker; the reference must use its stable id, not its display name.' + : 'Enter plain display text for a non-actor speaker.'); + $issues[] = Issue::error($beatLocation, $error, $hint); + } + } } return $issues; @@ -3035,7 +3707,34 @@ protected function checkSkitReferences(ProjectWorkspace $workspace, array $known * @param array $known What the project defines. * @return Issue[] The issues found. */ - protected function checkScriptReferences(ProjectWorkspace $workspace, array $known): array + /** + * Checks what each troop event page's commands name (items, sounds, + * Common Events, switches) as a script's commands are checked. + * + * @param array $known What the project defines. + * @return Issue[] + */ + protected function checkTroopEventReferences(ProjectWorkspace $workspace, array $known): array + { + $database = $workspace->getRecordDatabase('troops'); + $issues = []; + + foreach ($database instanceof ProjectRecordDatabase ? $database->getRecords() : [] as $record) { + $name = trim(strval($record->get('name') ?? '')) ?: '(unnamed)'; + $pages = $record->get('events'); + + foreach (is_array($pages) ? $pages : [] as $page => $entry) { + if (is_array($entry) && is_array($entry['commands'] ?? null)) { + $issues = [...$issues, ...$this->checkCommands($entry['commands'], + sprintf('troop %s event page %d', $name, intval($page) + 1), $known)]; + } + } + } + + return $issues; + } + + protected function checkScriptReferences(ProjectWorkspace $workspace, array $known): array { $database = $workspace->getRecordDatabase('common_events'); @@ -3096,6 +3795,22 @@ protected function checkCommands( array $npcIdsByMap = [], array $commonEventStack = [], ): array + { + $context = ['map' => ScriptMapState::startOn($mapId, $npcIds), 'npcIdsByMap' => $npcIdsByMap]; + + return $this->walkEventCommands($commands, $where, $known, $context, $commonEventStack); + } + + /** + * Nested sequences and Common Events share the caller's current map. + * Independent entry points receive a fresh context through checkCommands(). + * @param array $commands + * @param array $known + * @param array{map: ScriptMapState, npcIdsByMap: array} $context + * @param string[] $commonEventStack + * @return Issue[] + */ + protected function walkEventCommands(array $commands, string $where, array $known, array &$context, array $commonEventStack = []): array { $issues = []; @@ -3106,17 +3821,19 @@ protected function checkCommands( $type = strval($command['type'] ?? ''); - if (! in_array($type, EventInterpreter::COMMAND_TYPES, true)) { + $registered = ScriptCommandRegistry::getCatalog()->findDefinition($type); + + if ($registered !== null) { + $issues = [...$issues, ...$this->checkRegisteredCommand($registered, $command, $where, $known)]; + } elseif (! in_array($type, EventInterpreter::COMMAND_TYPES, true)) { $issues[] = Issue::error( $where, sprintf('It uses the unknown event command type "%s".', $type !== '' ? $type : '(empty)'), - 'Choose a command supported by the runtime EventInterpreter.' + 'Choose a command built into the runtime, or one the Engine or the project registers.' ); } - if ($type === 'knowledge') { - $issues = [...$issues, ...$this->checkKnowledgeCommand($command, $where)]; - } + $issues = [...$issues, ...$this->checkSharedCommandSemantics($command, $where, $context['map']->npcIds, $context['map']->description)]; $named = match ($type) { 'give_item' => ['inventory', 'item', 'item'], @@ -3136,11 +3853,15 @@ protected function checkCommands( ]; } - if ($type === 'move_route') { - $issues = [ - ...$issues, - ...$this->checkMovementRoute($command, $where, $npcIds, $mapId), - ]; + if ($type === 'field_animation') { + // An event script runs the same command a cinematic does, and the + // runtime holds it to the same contract. + try { + CinematicScriptValidator::validateFieldAnimation($command, $where, 'command'); + $issues = [...$issues, ...$this->checkFieldAnimationReference($command, $where, $known)]; + } catch (InvalidArgumentException $exception) { + $issues[] = Issue::error($where, $exception->getMessage(), 'The runtime stops the script at this command.'); + } } if ($type === 'common_event') { @@ -3164,13 +3885,11 @@ protected function checkCommands( if (in_array($eventId, $commonEventStack, true)) { $issues[] = $this->getCommonEventCycleIssue($where, [...$commonEventStack, $eventId]); } else { - $issues = [...$issues, ...$this->checkCommands( + $issues = [...$issues, ...$this->walkEventCommands( $this->commonEventScripts[$eventId], sprintf('%s common event %s', $where, $eventId), $known, - $npcIds, - $mapId, - $npcIdsByMap, + $context, [...$commonEventStack, $eventId], )]; } @@ -3178,83 +3897,59 @@ protected function checkCommands( } } - if ($type === 'start_battle') { - $issues = [ - ...$issues, - ...$this->checkBattleContinuation($command, $where), - ]; - } - $issues = [...$issues, ...$this->checkConditions((array) ($command['conditions'] ?? []), $where, $known)]; if ($type === 'sequence') { - $issues = [...$issues, ...$this->checkCommands( + $issues = [...$issues, ...$this->walkEventCommands( (array) ($command['commands'] ?? []), $where, $known, - $npcIds, - $mapId, - $npcIdsByMap, + $context, $commonEventStack, )]; } if ($type === 'parallel') { - foreach ((array) ($command['lanes'] ?? []) as $lane) { - if (! is_array($lane)) { - continue; - } - - $laneCommands = array_is_list($lane) ? $lane : (array) ($lane['commands'] ?? []); - $issues = [...$issues, ...$this->checkCommands( - $laneCommands, - $where, - $known, - $npcIds, - $mapId, - $npcIdsByMap, - $commonEventStack, - )]; - } - } + [$laneIssues, $context['map']] = $this->walkParallelLanes( + $command, + $context['map'], + $context['npcIdsByMap'], + $commonEventStack, + function (array $laneCommands, ScriptMapState $laneStart) use (&$context, $where, $known, $commonEventStack): array { + $context['map'] = $laneStart; + $laneIssues = $this->walkEventCommands($laneCommands, $where, $known, $context, $commonEventStack); - // A choice hides its commands one level down, per option. - foreach ((array) ($command['options'] ?? []) as $option) { - if (is_array($option)) { - $issues = [...$issues, ...$this->checkCommands( - (array) ($option['then'] ?? []), - $where, - $known, - $npcIds, - $mapId, - $npcIdsByMap, - $commonEventStack, - )]; - } + return [$laneIssues, $context['map']]; + }, + ); + $issues = [...$issues, ...$laneIssues]; } - foreach (['then', 'else', 'cancel'] as $arm) { - $issues = [...$issues, ...$this->checkCommands( - (array) ($command[$arm] ?? []), + // Alternative arms start on the same map, not at the end of a sibling arm. + $branchContexts = []; + foreach (CommandMapContext::getArms($command) as $arm) { + $branchContext = $context; + $issues = [...$issues, ...$this->walkEventCommands( + $arm['commands'], $where, $known, - $npcIds, - $mapId, - $npcIdsByMap, + $branchContext, $commonEventStack, )]; + $branchContexts[] = $branchContext; } - if ($type === 'transfer') { - $destination = trim(strval($command['map'] ?? '')); - - if ($destination !== '' && array_key_exists($destination, $npcIdsByMap)) { - $mapId = $destination; - $npcIds = $npcIdsByMap[$destination]; - } else { - $mapId = $destination !== '' ? $destination : $mapId; - $npcIds = null; + if ($branchContexts !== []) { + // An omitted alternative can leave the current map unchanged. + if (CommandMapContext::canSkipArms($command)) { + $branchContexts[] = $context; } + // A command after a branch can address only NPCs present on every possible map. + $context['map'] = ScriptMapState::mergeStates(array_column($branchContexts, 'map')); + } + + if ($type === 'transfer') { + $context['map'] = $context['map']->transferTo(trim(strval($command['map'] ?? '')), $context['npcIdsByMap']); } } @@ -3262,13 +3957,77 @@ protected function checkCommands( } /** - * Checks one deterministic, awaited movement route. + * Walks a parallel command's lanes as the Engine runs them: together, on + * the one current map, which a transfer in any lane changes for every lane. + * Each lane starts on the incoming map or any map a sibling lane transfers + * to, and its own transfers can be overtaken by a sibling's. The block ends + * where its transferring lanes can finish, or where it started when no + * lane transfers. + * + * @param array $command The parallel command. + * @param array $npcIdsByMap Each known map's NPCs. + * @param string[] $commonEventStack Reusable event scripts being followed. + * @param callable(array, ScriptMapState): array{Issue[], ScriptMapState} $walkLane Walks one lane from its start state, returning its issues and end state. + * @return array{Issue[], ScriptMapState} The lanes' issues and the state after the block. + */ + protected function walkParallelLanes( + array $command, + ScriptMapState $start, + array $npcIdsByMap, + array $commonEventStack, + callable $walkLane, + ): array + { + $lanes = CommandMapContext::getLanes($command); + $findCommonEvent = fn(string $id): ?array => $this->commonEventScripts[$id] ?? null; + $destinations = array_map( + static fn(array $lane): array => CommandMapContext::listTransferDestinations($lane['commands'], $findCommonEvent, array_values($commonEventStack)), + $lanes, + ); + $issues = []; + $ends = []; + + foreach ($lanes as $index => $lane) { + $siblings = array_values(array_unique(array_merge([], ...array_values(array_diff_key($destinations, [$index => true]))))); + $laneStart = ScriptMapState::mergeStates([ + $start, + ...array_map(static fn(string $map): ScriptMapState => $start->arriveOn($map, $npcIdsByMap), $siblings), + ])->setConcurrentMaps([...$start->concurrentMaps, ...$siblings]); + [$laneIssues, $end] = $walkLane($lane['commands'], $laneStart); + $issues = [...$issues, ...$laneIssues]; + + if ($destinations[$index] !== []) { + $ends[] = $end; + } + } + + return [$issues, $ends !== [] ? ScriptMapState::mergeStates($ends)->setConcurrentMaps($start->concurrentMaps) : $start]; + } + + /** + * Shared project/runtime checks used by both event ownership paths. + * @param array $command + * @param string[]|null $npcIds + * @return Issue[] + */ + protected function checkSharedCommandSemantics(array $command, string $where, ?array $npcIds, ?string $mapId, bool $cinematic = false): array + { + return match ($command['type'] ?? '') { + 'knowledge' => $this->checkKnowledgeCommand($command, $where), + 'start_battle' => $this->checkBattleContinuation($command, $where), + 'move_route' => $this->checkMovementRoute($command, $where, $npcIds, $mapId, $cinematic), + default => [], + }; + } + + /** + * Checks one deterministic, awaited movement route in its owning lifecycle. * * @param array $command The route command. * @param string[]|null $npcIds Current-map NPC ids, or null without map context. * @return Issue[] The issues found. */ - protected function checkMovementRoute(array $command, string $where, ?array $npcIds, ?string $mapId): array + protected function checkMovementRoute(array $command, string $where, ?array $npcIds, ?string $mapId, bool $cinematic = false): array { $issues = []; @@ -3284,15 +4043,17 @@ protected function checkMovementRoute(array $command, string $where, ?array $npc $subject = strtolower(trim(strval($command['subject'] ?? 'player'))); - if (! in_array($subject, ['player', 'npc'], true)) { + $subjects = $cinematic ? ['player', 'npc', 'staged_actor'] : ['player', 'npc']; + + if (! in_array($subject, $subjects, true)) { $issues[] = Issue::error( $where, sprintf('A movement route uses unsupported subject "%s".', $subject !== '' ? $subject : '(empty)'), - 'Choose player or npc.' + 'Choose ' . implode(', ', $subjects) . '.' ); } - if (array_key_exists('remember', $command) || array_key_exists('retrace', $command)) { + if (! $cinematic && (array_key_exists('remember', $command) || array_key_exists('retrace', $command))) { $issues[] = Issue::error( $where, 'Recorded movement routes require an active cinematic session.', @@ -3304,7 +4065,7 @@ protected function checkMovementRoute(array $command, string $where, ?array $npc if ($subject === 'npc' && $npcId === '') { $issues[] = Issue::error($where, 'An NPC movement route has no npcId.', 'Choose a stable current-map NPC id.'); - } elseif ($subject === 'npc' && $npcIds !== null && ! in_array($npcId, $npcIds, true)) { + } elseif (! $cinematic && $subject === 'npc' && $npcIds !== null && ! in_array($npcId, $npcIds, true)) { $issues[] = Issue::error( $where, sprintf('The route targets NPC id "%s", which is not on map "%s".', $npcId, $mapId ?? '(unknown)'), @@ -3312,6 +4073,18 @@ protected function checkMovementRoute(array $command, string $where, ?array $npc ); } + foreach ((array) ($command['waypoints'] ?? []) as $waypoint) { + $at = is_array($waypoint) && is_array($waypoint['at'] ?? null) ? $waypoint['at'] : null; + $atNpc = $at !== null && ($at['kind'] ?? null) === 'npc' ? trim(strval($at['id'] ?? '')) : ''; + if (! $cinematic && $atNpc !== '' && $npcIds !== null && ! in_array($atNpc, $npcIds, true)) { + $issues[] = Issue::error( + $where, + sprintf('A route waypoint is relative to NPC id "%s", which is not on map "%s".', $atNpc, $mapId ?? '(unknown)'), + 'Choose an NPC on the map the route runs on.' + ); + } + } + $wait = $command['wait'] ?? true; if (! is_bool($wait) || ! $wait) { @@ -3416,6 +4189,10 @@ protected function checkBattleContinuation(array $command, string $where): array $issues[] = Issue::error($where, 'A start_battle command names no troop.', 'Choose a configured troop.'); } + if (array_key_exists(MapEncounters::ARENA_KEY, $command)) { + $issues = [...$issues, ...$this->checkBattleArena($command[MapEncounters::ARENA_KEY] ?? '', $where, 'A start_battle command')]; + } + if ( array_key_exists('resultVariable', $command) && (! is_string($command['resultVariable']) || trim($command['resultVariable']) === '') @@ -3437,6 +4214,18 @@ protected function checkBattleContinuation(array $command, string $where): array ); } + if ( + array_key_exists('reservePolicy', $command) + && ! in_array($command['reservePolicy'], ['none', 'replace_after_wipeout'], true) + ) { + $reservePolicy = $command['reservePolicy']; + $issues[] = Issue::error( + $where, + sprintf('A start_battle command uses invalid reservePolicy "%s".', is_scalar($reservePolicy) ? strval($reservePolicy) : get_debug_type($reservePolicy)), + 'Choose none or replace_after_wipeout, or remove it: by default a wiped-out frontline loses the battle.' + ); + } + if (array_key_exists('escapePolicy', $command)) { $escapePolicy = $command['escapePolicy']; @@ -3466,109 +4255,111 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) $eventScripts = $this->commonEventScripts; foreach ($workspace->maps as $map) { + $issues = [...$issues, ...Issue::attributeToMap($this->checkMapContentReferences($map, $known, $npcIdsByMap, $eventScripts), $map->mapId)]; + } + + return $issues; + } + + /** + * Checks what one map's music, events and NPCs name. + * + * @param array $known The known reference values. + * @param array $npcIdsByMap Stable NPC ids by map id. + * @param array> $eventScripts Script payloads by id. + * @return Issue[] The issues found. + */ + protected function checkMapContentReferences(ProjectMap $map, array $known, array $npcIdsByMap, array $eventScripts): array + { + $issues = [ + ...$this->checkReference(strval($map->getEditableData()['bgm'] ?? ''), 'bgm', 'track', $map->mapId, $known), + ...$this->checkBgmVariants($map, $known), + ]; + + foreach ((array) ($map->getEditableData()['events'] ?? []) as $marker => $definition) { + if (! is_array($definition)) { + continue; + } + + $where = sprintf('%s event %s', $map->mapId, strval($marker)); + $data = (array) ($definition['data'] ?? []); + $class = strval($definition['class'] ?? ''); + $currentNpcIds = $npcIdsByMap[$map->mapId] ?? []; + $issues = [ ...$issues, - ...$this->checkReference(strval($map->data['bgm'] ?? ''), 'bgm', 'track', $map->mapId, $known), - ...$this->checkBgmVariants($map, $known), + ...$this->checkEventCue($definition['cue'] ?? null, $where, $known), + ...$this->checkConditions((array) ($definition['conditions'] ?? []), $where, $known), + ...$this->checkDialogueVariants((array) ($data['dialogue'] ?? []), $where, $known), + ...$this->checkCommands( + (array) ($data['script'] ?? []), + $where, + $known, + $currentNpcIds, + $map->mapId, + $npcIdsByMap, + ), + ...$this->checkReference(strval($data['bgm'] ?? ''), 'bgm', 'track', $where, $known), + ...$this->checkReference(strval($data['sfx'] ?? ''), 'sfx', 'sound', $where, $known), ]; - foreach ((array) ($map->data['events'] ?? []) as $marker => $definition) { - if (! is_array($definition)) { - continue; - } - - $where = sprintf('%s event %s', $map->mapId, strval($marker)); - $data = (array) ($definition['data'] ?? []); - $class = strval($definition['class'] ?? ''); - $currentNpcIds = $npcIdsByMap[$map->mapId] ?? []; + if (str_contains($class, 'CinematicEventTrigger')) { + $issues = [...$issues, ...$this->checkCinematicTrigger($definition, $where, $known)]; + } + if (str_contains($class, self::SCRIPT_TRIGGER)) { $issues = [ ...$issues, - ...$this->checkEventCue($definition['cue'] ?? null, $where, $known), - ...$this->checkConditions((array) ($definition['conditions'] ?? []), $where, $known), - ...$this->checkDialogueVariants((array) ($data['dialogue'] ?? []), $where, $known), - ...$this->checkCommands( - (array) ($data['script'] ?? []), + ...$this->checkScriptTrigger( + $definition, $where, $known, + $eventScripts, $currentNpcIds, $map->mapId, $npcIdsByMap, ), - ...$this->checkReference(strval($data['bgm'] ?? ''), 'bgm', 'track', $where, $known), - ...$this->checkReference(strval($data['sfx'] ?? ''), 'sfx', 'sound', $where, $known), ]; + } - if (str_contains($class, 'CinematicEventTrigger')) { - $issues = [...$issues, ...$this->checkCinematicTrigger($definition, $where, $known)]; - } - - if (str_contains($class, self::SCRIPT_TRIGGER)) { + foreach ((array) ($data['items'] ?? []) as $stock) { + if (is_array($stock)) { $issues = [ ...$issues, - ...$this->checkScriptTrigger( - $definition, - $where, - $known, - $eventScripts, - $currentNpcIds, - $map->mapId, - $npcIdsByMap, - ), + ...$this->checkReference(strval($stock['item'] ?? ''), 'inventory', 'item', $where, $known), ]; } - - foreach ((array) ($data['items'] ?? []) as $stock) { - if (is_array($stock)) { - $issues = [ - ...$issues, - ...$this->checkReference(strval($stock['item'] ?? ''), 'inventory', 'item', $where, $known), - ]; - } - } - - $issues = [...$issues, ...$this->checkChestLoot($data, $where, $known)]; } + } - foreach ((array) ($map->data['npcs'] ?? []) as $npc) { - if (! is_array($npc)) { - continue; - } - - $where = sprintf('%s NPC %s', $map->mapId, strval($npc['name'] ?? '(unnamed)')); - $issues = [ - ...$issues, - ...$this->checkConditions((array) ($npc['conditions'] ?? []), $where, $known), - ...$this->checkDialogueVariants((array) ($npc['dialogue'] ?? []), $where, $known), - ...$this->checkCommands( - (array) ($npc['script'] ?? []), - $where, - $known, - $npcIdsByMap[$map->mapId] ?? [], - $map->mapId, - $npcIdsByMap, - ), - ...$this->checkStateWrites((array) ($npc['sets'] ?? []), $where), - ]; + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { + if (! is_array($npc)) { + continue; } + + $where = sprintf('%s NPC %s', $map->mapId, strval($npc['name'] ?? '(unnamed)')); + $issues = [ + ...$issues, + ...$this->checkConditions((array) ($npc['conditions'] ?? []), $where, $known), + // An NPC's cue is the same contract as an event's, checked the same way. + ...$this->checkEventCue($npc['cue'] ?? null, $where, $known), + ...$this->checkDialogueVariants((array) ($npc['dialogue'] ?? []), $where, $known), + ...$this->checkNpcPageReferences((array) ($npc['dialogue'] ?? []), $where, $known), + ...$this->checkCommands( + (array) ($npc['script'] ?? []), + $where, + $known, + $npcIdsByMap[$map->mapId] ?? [], + $map->mapId, + $npcIdsByMap, + ), + ...$this->checkStateWrites((array) ($npc['sets'] ?? []), $where), + ]; } return $issues; } - /** - * Checks what a chest gives out. - * - * A chest names its loot by kind: an inventory definition, a skill, or an - * amount of gold or experience. An inventory reference that resolves to - * nothing opens a chest that gives nothing, which looks exactly like a - * chest an author meant to leave empty. - * - * @param array $data The trigger's data. - * @param string $where Where it lives. - * @param array $known What the project defines. - * @return Issue[] The issues found. - */ /** * Checks a map's conditional music the way the engine reads it: an * ordered list whose first matching variant selects the track. @@ -3583,11 +4374,11 @@ protected function checkMapReferences(ProjectWorkspace $workspace, array $known) */ protected function checkBgmVariants(ProjectMap $map, array $known): array { - if (! array_key_exists('bgmVariants', $map->data)) { + if (! array_key_exists('bgmVariants', $map->getEditableData())) { return []; } - $variants = $map->data['bgmVariants']; + $variants = $map->getEditableData()['bgmVariants']; $where = $map->mapId; if (! is_array($variants) || ! array_is_list($variants)) { @@ -3687,72 +4478,6 @@ protected function checkBgmVariants(ProjectMap $map, array $known): array return $issues; } - protected function checkChestLoot(array $data, string $where, array $known): array - { - if (! array_key_exists('lootType', $data) && ! array_key_exists('loot', $data)) { - return []; - } - - // A chest may declare its kind as the runtime enum or as its value. - $rawType = $data['lootType'] ?? ''; - $lootType = strtolower(trim($rawType instanceof \BackedEnum ? strval($rawType->value) : (is_scalar($rawType) ? strval($rawType) : ''))); - $loot = $data['loot'] ?? null; - $issues = []; - $kinds = ['item', 'gold', 'experience', 'skill', 'spell', 'weapon', 'armor', 'accessory']; - - if ($lootType !== '' && ! in_array($lootType, $kinds, true)) { - $issues[] = Issue::error( - $where, - sprintf('Its loot type "%s" is not one the game knows.', $lootType), - 'Choose item, gold, experience, skill, spell, weapon, armor, or accessory.' - ); - - return $issues; - } - - if ($loot === null || (is_string($loot) && trim($loot) === '')) { - // An unconfigured chest is a chest an author has not finished, not a - // broken reference. - return $issues; - } - - if (in_array($lootType, ['gold', 'experience'], true)) { - if (! is_numeric($loot)) { - $issues[] = Issue::error( - $where, - sprintf('It gives %s "%s", which is not an amount.', $lootType, strval(is_scalar($loot) ? $loot : get_debug_type($loot))), - 'Write a whole number.' - ); - } - - return $issues; - } - - if ($loot instanceof \BackedEnum) { - $loot = $loot->value; - } - - if (! is_scalar($loot)) { - $issues[] = Issue::error( - $where, - sprintf('Its loot is %s, not a reference.', get_debug_type($loot)), - 'Choose the loot from the picker.' - ); - - return $issues; - } - - // Only inventory loot is resolved here. A chest may also give a skill - // or a spell, and the same check would apply, but skill loot is not this - // boundary's subject and enabling it now would report existing project - // content that this work is not permitted to change. The gap is recorded - // for whoever owns that content rather than left to be rediscovered. - return [...$issues, ...match ($lootType) { - 'skill', 'spell' => [], - // No kind declared: the runtime reads the loot as an item. - default => $this->checkReference(strval($loot), 'inventory', 'item', $where, $known), - }]; - } /** * Checks ScriptEventTrigger's runtime-recognized fields and script picker. @@ -3774,13 +4499,13 @@ protected function checkScriptTrigger( array $npcIdsByMap, ): array { $issues = []; - $allowedRootFields = ['class', 'data', 'conditions', 'sets', 'whenBlocked', 'cue']; - - foreach (array_diff(array_keys($definition), $allowedRootFields) as $field) { + // The Engine owns both vocabularies: the envelope every map event shares + // (whose combinations MapEventResolver checks) and this trigger's data. + foreach (array_diff(array_keys($definition), MapEventDefinition::ROOT_FIELDS) as $field) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported root field "%s".', $field), - 'Use class, data, conditions, sets, whenBlocked, or cue.' + sprintf('Use %s.', implode(', ', MapEventDefinition::ROOT_FIELDS)) ); } @@ -3790,13 +4515,11 @@ protected function checkScriptTrigger( return [...$issues, Issue::error($where, 'ScriptEventTrigger data is malformed.', 'Store its settings in a data array.')]; } - $allowedDataFields = ['scriptId', 'script', 'mode', 'reusable']; - - foreach (array_diff(array_keys($data), $allowedDataFields) as $field) { + foreach (array_diff(array_keys($data), ScriptEventTrigger::DATA_FIELDS) as $field) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported data field "%s".', $field), - 'Use scriptId, script, mode, or reusable.' + sprintf('Use %s.', implode(', ', ScriptEventTrigger::DATA_FIELDS)) ); } @@ -3834,13 +4557,13 @@ protected function checkScriptTrigger( } } - $mode = strval($data['mode'] ?? 'action'); + $mode = strval($data['mode'] ?? ScriptEventTrigger::MODE_ACTION); - if (! in_array($mode, ['action', 'auto'], true)) { + if (! in_array($mode, ScriptEventTrigger::MODES, true)) { $issues[] = Issue::error( $where, sprintf('ScriptEventTrigger uses unsupported mode "%s".', $mode), - 'Choose action or auto.' + sprintf('Choose %s.', implode(' or ', ScriptEventTrigger::MODES)) ); } @@ -3862,6 +4585,8 @@ protected function checkScriptTrigger( * * @return Issue[] The issues found. */ + + protected function checkEventCue(mixed $cue, string $where, array $known): array { if ($cue === null) { @@ -3873,8 +4598,14 @@ protected function checkEventCue(mixed $cue, string $where, array $known): array } $issues = []; - foreach (array_diff(array_keys($cue), ['symbol', 'color', 'conditions']) as $field) { - $issues[] = Issue::error($where, sprintf('Its event cue uses unsupported field "%s".', $field), 'Use symbol, color, or conditions.'); + foreach (array_diff(array_keys($cue), ['symbol', 'color', 'conditions', 'kind']) as $field) { + $issues[] = Issue::error($where, sprintf('Its event cue uses unsupported field "%s".', $field), 'Use symbol, color, conditions, or kind.'); + } + + // Kind is a contract even for a cue whose blank symbol disables display. + if (array_key_exists('kind', $cue) + && (! is_string($cue['kind']) || EventCueKind::tryFrom($cue['kind']) === null)) { + $issues[] = Issue::error($where, 'Its authored event cue kind must be story or route.', 'Choose story or route, or omit kind for an unclassified cue.'); } $issues = [ @@ -3923,7 +4654,7 @@ protected function npcIdsByMap(ProjectWorkspace $workspace): array foreach ($workspace->maps as $map) { $idsByMap[$map->mapId] = []; - foreach ((array) ($map->data['npcs'] ?? []) as $npc) { + foreach ((array) ($map->getEditableData()['npcs'] ?? []) as $npc) { if (! is_array($npc)) { continue; } @@ -4000,6 +4731,46 @@ protected function checkStateWrites(array $writes, string $where): array * @param array $known What the project defines. * @return Issue[] The issues found. */ + /** + * Checks what an NPC's pages name beyond their lines: the event script or + * Cinematic a page runs, and its cue. Their shape is the Engine's + * `npc.page` rule, reported with the map's completeness. + * + * @param array $dialogue The NPC's dialogue as authored. + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkNpcPageReferences(array $dialogue, string $where, array $known): array + { + if (! ConditionalDialogue::isVariantList(array_values(array_filter($dialogue, is_array(...))))) { + return []; + } + + $issues = []; + + foreach ($dialogue as $index => $page) { + if (! is_array($page)) { + continue; + } + + $here = sprintf('%s page %d', $where, intval($index) + 1); + + if (is_string($page['scriptId'] ?? null) && trim($page['scriptId']) !== '') { + $issues = [...$issues, ...$this->checkReference(trim($page['scriptId']), 'common_events', 'event script', $here, $known)]; + } + + if (is_string($page['cinematicId'] ?? null) && trim($page['cinematicId']) !== '') { + $issues = [...$issues, ...$this->checkReference(trim($page['cinematicId']), 'cinematics', 'Cinematic', $here, $known)]; + } + + if (array_key_exists('cue', $page)) { + $issues = [...$issues, ...$this->checkEventCue($page['cue'], $here, $known)]; + } + } + + return $issues; + } + protected function checkDialogueVariants(array $dialogue, string $where, array $known): array { $issues = []; @@ -4116,6 +4887,112 @@ protected function checkConditions( * @param array $known What the project defines. * @return Issue[] The issue, if there is one. */ + /** + * Checks what a `field_animation` command plays: a legacy animation by + * name, or an effect timeline that exists and plays once on the field. + * + * @param array $command The command. + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkFieldAnimationReference(array $command, string $where, array $known): array + { + if (! array_key_exists('effect', $command)) { + return $this->checkReference(strval($command['animation'] ?? ''), 'animations', 'animation', $where, $known); + } + + $effect = is_string($command['effect']) ? $command['effect'] : ''; + $issues = $this->checkReference($effect, 'effects', 'effect', $where, $known); + + if ($issues !== [] || $effect === '') { + return $issues; + } + + return $this->validatedWorkspace === null ? [] : EffectValidator::checkOneShotFieldEffect($this->validatedWorkspace->catalogs, $effect, $where); + } + + /** + * Reports a project's command declarations the game would refuse to start + * with. + * + * @param ProjectWorkspace $workspace The project. + * @return Issue[] The issues found. + */ + protected function checkScriptCommandDeclarations(ProjectWorkspace $workspace): array + { + $problem = $workspace->scriptCommands?->declarationProblem; + + return $problem === null ? [] : [Issue::error( + 'assets/' . ScriptCommandRegistry::PROJECT_FILE, + sprintf('The project\'s script commands could not be read: %s', $problem), + 'The game will not start until it is fixed, and scripts using these commands are reported as unknown.' + )]; + } + + /** + * Checks a registered command against its declaration: everything the + * runtime would refuse it for, and every resource it names. + * + * @param array $command The authored command. + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkRegisteredCommand(ScriptCommandDefinition $definition, array $command, string $where, array $known): array + { + $issues = array_map( + static fn(string $problem): Issue => Issue::error( + $where, + sprintf('Its %s command cannot run: %s', $definition->type, $problem), + 'The runtime stops the script at this command.' + ), + $definition->findProblems($command), + ); + + return [...$issues, ...$this->checkRegisteredReferences($definition, $command, $where, $known)]; + } + + /** + * Checks every resource a registered command names, in its list entries + * too. + * + * @param array $command The authored command. + * @param array $known What the project defines. + * @return Issue[] The issues found. + */ + protected function checkRegisteredReferences(ScriptCommandDefinition $definition, array $command, string $where, array $known): array + { + $check = function (ScriptCommandField $field, array $data) use (&$check, $where, $known): array { + $value = ScriptCommandField::readPath($data, $field->key); + + $category = $field->reference === null ? null : ProjectScriptCommands::getReferenceCategory($field->reference); + + if ($field->kind === ScriptCommandFieldKind::REFERENCE && $category !== null && is_string($value)) { + return $this->checkReference($value, $category, strtolower($field->label), $where, $known); + } + + if ($field->kind !== ScriptCommandFieldKind::LIST || ! is_array($value)) { + return []; + } + + $issues = []; + + foreach (array_filter($value, is_array(...)) as $entry) { + foreach ($field->fields as $entryField) { + $issues = [...$issues, ...$check($entryField, $entry)]; + } + } + + return $issues; + }; + $issues = []; + + foreach ($definition->fields as $field) { + $issues = [...$issues, ...$check($field, $command)]; + } + + return $issues; + } + protected function checkReference(string $value, string $category, string $noun, string $where, array $known): array { $value = trim($value); diff --git a/src/Validation/ReachabilityValidator.php b/src/Validation/ReachabilityValidator.php new file mode 100644 index 00000000..a91bad90 --- /dev/null +++ b/src/Validation/ReachabilityValidator.php @@ -0,0 +1,111 @@ +analyzeReachability(); + $maps = array_column(array_map(static fn(ProjectMap $map): array => ['id' => $map->mapId, 'map' => $map], $workspace->maps), 'map', 'id'); + $untiled = []; + foreach ($project->reports as $mapId => $report) { + $map = $maps[(string) $mapId] ?? null; + $untiled[] = $map === null ? null : self::checkUntiledFloor($map, $report); + } + + return array_values(array_filter([...array_map( + static fn(ReachabilityProblem $problem): ?Issue => self::toIssue($problem), + $project->getAllProblems(), + ), ...$untiled])); + } + + /** How many reachable cells a warning names before summing up the rest. */ + private const int CELLS_NAMED = 3; + + /** + * Reports cells the player can reach that no tile covers in a map with graphics: the field draws nothing + * there, so the player walks on the dark background, as on a map whose floor was never painted. A map without + * a kind, or whose graphics cannot be read, is left to the checks that own it. + */ + private static function checkUntiledFloor(ProjectMap $map, MapReachabilityReport $report): ?Issue + { + $mapId = $map->mapId; + try { + $layers = $map->getLayerSet(); + $graphics = MapGraphicsValidator::readGraphics($map); + $usable = $graphics?->tileset->getUsableSheets($map->getAssetRoot())['sheets'] ?? []; + } catch (Throwable) { + return null; + } + if ($graphics === null || $usable === []) { + return null; + } + $cells = []; + $base = $layers->layers[0]; + for ($y = 0; $y < $base->height; $y++) { + for ($x = 0, $width = $base->getRowLength($y); $x < $width; $x++) { + if (! $report->isReachable($x, $y)) { + continue; + } + foreach ($graphics->layers as $layer) { + $tile = $layer->getTileAt($x, $y) ?? TileId::EMPTY; + if ($tile !== TileId::EMPTY && isset($usable[TileId::getSheet($tile)?->value ?? ''])) { + continue 2; + } + } + $cells[] = "({$x}, {$y})"; + } + } + if ($cells === []) { + return null; + } + $named = array_slice($cells, 0, self::CELLS_NAMED); + $rest = count($cells) - count($named); + + // The player walks on the dark background there: a defect they see, so an error. + return new Issue(Severity::ERROR, $mapId, sprintf('%d %s the player can reach %s no tile, so the field shows the background there: %s%s.', + count($cells), count($cells) === 1 ? 'cell' : 'cells', count($cells) === 1 ? 'has' : 'have', + implode(', ', $named), $rest > 0 ? " and {$rest} more" : ''), + 'Paint floor under them on a tile layer, or close them off if the player should not walk there.', map: $mapId); + } + + private static function toIssue(ReachabilityProblem $problem): ?Issue + { + [$severity, $hint] = match ($problem->kind) { + ReachabilityProblemKind::UNREADABLE_MAP, ReachabilityProblemKind::UNKNOWN_DESTINATION => [null, ''], + ReachabilityProblemKind::UNREACHABLE_EVENT => [Severity::ERROR, 'Open a way to it, or move the event where the player can stand.'], + ReachabilityProblemKind::BLOCKED_ENTRANCE => [Severity::ERROR, 'Point the arrival at open floor the player can stand on.'], + ReachabilityProblemKind::UNREACHABLE_NPC => [Severity::WARNING, 'Leave a free cell beside it, or move what stands in the way.'], + ReachabilityProblemKind::NO_ENTRANCE => [Severity::WARNING, 'Fine for a map kept for content still to come; connect it when it is ready.'], + ReachabilityProblemKind::DETOURED_APPROACH => [Severity::WARNING, + 'Turn the desk and its speaker toward the way the player comes in, move the event to that side, or let it answer from there.'], + }; + + return $severity === null ? null + : new Issue($severity, $problem->mapId, $problem->message, $hint, map: $problem->mapId === '' ? null : $problem->mapId); + } +} diff --git a/src/Validation/RecordReferenceValidator.php b/src/Validation/RecordReferenceValidator.php new file mode 100644 index 00000000..dfdd5ff8 --- /dev/null +++ b/src/Validation/RecordReferenceValidator.php @@ -0,0 +1,164 @@ + 'class', + 'common_events' => 'common event', + 'effects' => 'effect', + 'enemies' => 'enemy', + 'inventory' => 'item', + 'knowledge_subjects' => 'knowledge subject', + 'maps' => 'map', + 'quests' => 'quest', + 'skills' => 'skill', + 'troops' => 'troop', + ]; + + /** + * Categories, or lists of them, whose references a check with more + * context owns (null: the whole category), so nothing is reported twice. + */ + private const array OWNED_ELSEWHERE = [ + // ProjectValidator::checkSkitReferences: the map and every beat. + 'skits' => null, + // ProjectValidator::checkScriptReferences, with the map and cast each script runs with. + 'common_events' => null, + // ProjectValidator::checkQuests: what an objective names depends on its type, and start waymarks name map subjects. + 'quests' => ['objectives', 'startWaymarks'], + ]; + + /** @var array> What each kind holds, read once per validation. */ + private array $known = []; + + /** Resolves an inventory reference as the Engine's item store does: by id or by name. */ + private ?InventoryCatalog $inventory = null; + + /** @return Issue[] */ + public function validate(ProjectWorkspace $workspace): array + { + $catalog = new ReferenceCatalog($workspace); + $this->inventory = InventoryCatalog::fromWorkspace($workspace); + $this->known = []; + foreach (array_keys(self::KINDS) as $kind) { + $this->known[$kind] = array_map('strval', array_values($catalog->valuesFor($kind))); + } + + $issues = []; + foreach ($workspace->recordDatabases as $key => $database) { + if (array_key_exists($key, self::OWNED_ELSEWHERE) && self::OWNED_ELSEWHERE[$key] === null) { + continue; + } + foreach ($database->getRecords() as $record) { + $issues = [...$issues, ...$this->checkRecord($database, $record)]; + } + } + + return $issues; + } + + /** @return Issue[] */ + private function checkRecord(ProjectRecordDatabase $database, ProjectRecord $record): array + { + $schema = $database->schema; + $label = trim(strval($record->get($schema->labelKey) ?? '')) ?: '(unnamed)'; + $where = sprintf('%s %s', $schema->entryNoun, $label); + $payload = $record->toArray(); + $issues = []; + + foreach ($schema->fieldsFor($payload) as $field) { + $issue = $this->checkValue($field, $record->get($field->key), $where, false); + if ($issue !== null) { + $issues[] = $issue; + } + } + + foreach ($schema->getInlineSubLists() as $list) { + if (in_array($list->key, self::OWNED_ELSEWHERE[$schema->key] ?? [], true)) { + continue; + } + foreach ($record->getEntries($list) as $position => $entry) { + $entryWhere = sprintf('%s, %s %d', $where, $list->singular, $position + 1); + foreach ($list->fieldsFor($entry) as $field) { + $issue = $this->checkValue($field, self::readPath($entry, $field->key), $entryWhere, true); + if ($issue !== null) { + $issues[] = $issue; + } + } + } + } + + return $issues; + } + + /** + * The problem with one value that names a definition, or null when it + * names one that exists, or may name none. A list entry exists to name + * something, so in one an empty value is a problem even where the field + * is optional elsewhere. + */ + private function checkValue(RecordField $field, mixed $value, string $where, bool $inEntry): ?Issue + { + $kind = $field->reference; + if ($kind === null || ! array_key_exists($kind, self::KINDS) || ($value !== null && ! is_scalar($value))) { + return null; + } + + $noun = self::KINDS[$kind]; + $text = trim(strval($value ?? '')); + + if ($text === '') { + return $inEntry && ! $field->allowsNone + ? Issue::error($where, sprintf('Its %s names no %s.', strtolower($field->label), $noun), sprintf('Choose a %s, or remove the entry.', $noun)) + : null; + } + + $exists = $kind === 'inventory' && $this->inventory !== null + ? $this->inventory->has($text) + : in_array($text, $this->known[$kind], true); + + return $exists + ? null + : Issue::error($where, sprintf('Its %s names the %s "%s", which the project does not define.', strtolower($field->label), $noun, $text), + sprintf('Choose one of the project\'s %ss.', $noun)); + } + + /** @param array $entry */ + private static function readPath(array $entry, string $key): mixed + { + $current = $entry; + foreach (explode('.', $key) as $segment) { + if (! is_array($current) || ! array_key_exists($segment, $current)) { + return null; + } + $current = $current[$segment]; + } + + return $current; + } +} diff --git a/src/Validation/SaveCompatibilityValidator.php b/src/Validation/SaveCompatibilityValidator.php index 43e1d7eb..ac971a99 100644 --- a/src/Validation/SaveCompatibilityValidator.php +++ b/src/Validation/SaveCompatibilityValidator.php @@ -4,24 +4,30 @@ namespace Ichiloto\Editor\Validation; +use Ichiloto\Engine\Field\NpcPages; use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Cutscenes\Summons\SummonCutsceneDefinition; +use Ichiloto\Engine\Entities\Skills\SkillCatalog; +use Ichiloto\Engine\Exceptions\InvalidSaveCompatibilityManifestException; use Ichiloto\Engine\IO\SaveCompatibility\ContentReferenceCategory; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; +use Ichiloto\Engine\IO\SaveCompatibility\MapRetirement; +use Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest; use Throwable; /** Validates the project-owned save compatibility manifest. */ final class SaveCompatibilityValidator { private const string WHERE = 'assets/Data/save-compatibility.php'; + private const string DECLARED_EDITS_HINT = 'Use a class for a project migration, or declared position edits: mapShifts (map, axis x or y, the 0-based line inserted at, a count of at least 1) for inserted rows and columns, mapRemovals (map, axis, the 0-based first removed line at, a count of at least 1, the cell to that saves on removed lines land on) for removed rows and columns, relocations (map, cells, to) for cells authored content now occupies, mapRetirements (map, into, to, and optional landings of x, y, width, height and to) for a retired map whose saves move onto another map.'; /** * @var InventoryCatalog|null The project's inventory identity, read once. */ private ?InventoryCatalog $inventoryCatalog = null; + private ?SkillCatalog $skillCatalog = null; /** @return Issue[] */ public function validate(ProjectWorkspace $workspace): array @@ -78,7 +84,8 @@ public function validate(ProjectWorkspace $workspace): array $manifest['tombstones'] ?? null, $aliasResult['sources'] )]; - $issues = [...$issues, ...$this->checkMigrations($manifest['migrations'] ?? null, $version)]; + $issues = [...$issues, ...$this->checkMigrations($workspace, $manifest['migrations'] ?? null, $version, + is_array($manifest['aliases']['maps'] ?? null) ? $manifest['aliases']['maps'] : [])]; return $issues; } @@ -171,7 +178,7 @@ private function checkAliases(ProjectWorkspace $workspace, mixed $rawAliases): a } if (! isset($graph[$current])) { - $knownTargets = $this->knownTargets($workspace, $category); + $knownTargets = $this->listKnownTargets($workspace, $category); if ($knownTargets !== null && ! in_array($current, $knownTargets, true)) { $issues[] = Issue::error($where, sprintf( @@ -237,8 +244,11 @@ private function checkTombstones(mixed $rawTombstones, array $aliasSources): arr return $issues; } - /** @return Issue[] */ - private function checkMigrations(mixed $rawMigrations, mixed $version): array + /** + * @param array $mapAliases The manifest's map aliases, as written. + * @return Issue[] + */ + private function checkMigrations(ProjectWorkspace $workspace, mixed $rawMigrations, mixed $version, array $mapAliases): array { if (! is_array($rawMigrations)) { return [Issue::error(self::WHERE, 'migrations must be an ordered list.')]; @@ -246,6 +256,7 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array $issues = []; $steps = []; + $retirements = []; $previousFrom = -1; foreach ($rawMigrations as $index => $entry) { @@ -272,7 +283,19 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array $issues[] = Issue::error($where, sprintf('Migration step %d to %d is in an impossible order.', $from, $to)); } - if ($class === '') { + try { + $edits = SaveCompatibilityManifest::readDeclaredPositionEdits($entry, 'This migration'); + } catch (InvalidSaveCompatibilityManifestException $exception) { + $issues[] = Issue::error($where, $exception->getMessage(), self::DECLARED_EDITS_HINT); + $edits = []; + } + foreach ($edits ?? [] as $edit) { + if ($edit instanceof MapRetirement) { + $retirements[] = ['where' => $where, 'retirement' => $edit]; + } + } + + if ($edits === null && $class === '') { $issues[] = Issue::error($where, 'Migration class must be a non-empty class name.'); } @@ -280,6 +303,8 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array $previousFrom = $from; } + $issues = [...$issues, ...$this->checkRetirementDestinations($workspace, $retirements, $mapAliases)]; + if (is_int($version) && $version >= 0) { for ($from = 0; $from < $version; $from++) { if (! isset($steps[$from])) { @@ -304,15 +329,67 @@ private function checkMigrations(mixed $rawMigrations, mixed $version): array return $issues; } - /** @return string[]|null Null means this category has no closed catalog. */ - private function knownTargets(ProjectWorkspace $workspace, ContentReferenceCategory $category): ?array + /** + * Reports a retirement whose saves end on no map of the project: a map it + * lands saves on (`into`, or a successor a landing names), followed + * through later retirements and map aliases as a save landing there + * moves, is not a current map. + * + * @param list $retirements In manifest order. + * @param array $mapAliases + * @return Issue[] + */ + private function checkRetirementDestinations(ProjectWorkspace $workspace, array $retirements, array $mapAliases): array + { + $aliases = []; + foreach ($mapAliases as $alias) { + if (is_array($alias) && is_string($alias['from'] ?? null) && is_string($alias['to'] ?? null)) { + $aliases[trim($alias['from'])] = trim($alias['to']); + } + } + $resolve = static function (string $mapId) use ($aliases): string { + for ($seen = []; isset($aliases[$mapId]) && ! isset($seen[$mapId]); $mapId = $aliases[$mapId]) { + $seen[$mapId] = true; + } + + return $mapId; + }; + $issues = []; + foreach ($retirements as $index => ['where' => $where, 'retirement' => $retirement]) { + // Every map a save may end on: each map the retirement lands on, and, where a later retirement moves + // saves off one, every map that one lands on in turn. + $ends = array_fill_keys(array_map($resolve, array_column($retirement->getLandingPlaces(), 'map')), true); + foreach (array_slice($retirements, $index + 1) as ['retirement' => $later]) { + if (isset($ends[$resolve($later->map)])) { + unset($ends[$resolve($later->map)]); + $ends += array_fill_keys(array_map($resolve, array_column($later->getLandingPlaces(), 'map')), true); + } + } + foreach (array_keys($ends) as $current) { + if (! in_array($current, $workspace->mapIds, true)) { + $issues[] = Issue::error($where, sprintf('Saves on %s move into %s, which is not a map in the project.', $retirement->map, $current), + 'Retire the map into one that exists, or alias the destination to the map that took its place.'); + } + } + } + + return $issues; + } + + /** + * Lists the current identities an alias of a category may end at, as the + * workspace holds them, unsaved edits included. + * + * @return string[]|null Null means this category has no closed catalog. + */ + public function listKnownTargets(ProjectWorkspace $workspace, ContentReferenceCategory $category): ?array { return match ($category) { ContentReferenceCategory::MAP => $workspace->mapIds, ContentReferenceCategory::ONE_SHOT_EVENT => $this->oneShotEventIds($workspace), ContentReferenceCategory::QUEST => array_map( static fn(object $quest): string => $quest->getId(), - $workspace->questDatabase->getQuests() + $workspace->getQuests() ), // The durable identity a save reconstructs an actor by, which // is the definition id where one is declared and the display @@ -326,10 +403,11 @@ private function knownTargets(ProjectWorkspace $workspace, ContentReferenceCateg // label, so the catalogue of ids is what it has to be in. ContentReferenceCategory::ITEM => $this->inventoryCatalog($workspace)->idsIn('items'), ContentReferenceCategory::EQUIPMENT => $this->inventoryCatalog($workspace)->idsIn('weapons', 'armors'), - ContentReferenceCategory::ABILITY, ContentReferenceCategory::SPELL => array_map( - static fn(object $skill): string => $skill->getName(), - $workspace->skillDatabase->getSkills() - ), + // A save keeps abilities and spells in separate books, and the + // runtime restores each book from its own kind of skill, + // wherever the catalogue authors it. + ContentReferenceCategory::ABILITY => array_keys($this->skillCatalog($workspace)->getAbilities()), + ContentReferenceCategory::SPELL => array_keys($this->skillCatalog($workspace)->getSpells()), ContentReferenceCategory::STATE => $this->recordIdentities($workspace, 'states'), ContentReferenceCategory::ENEMY => $this->recordLabels($workspace, ['enemies']), ContentReferenceCategory::ACHIEVEMENT => $this->phpListIdentities( @@ -337,9 +415,26 @@ private function knownTargets(ProjectWorkspace $workspace, ContentReferenceCateg ), ContentReferenceCategory::SUMMON => $this->summonIds($workspace), ContentReferenceCategory::STORY_EVENT => null, + // A retired subject folds into one the knowledge catalogue still declares. + ContentReferenceCategory::KNOWLEDGE_SUBJECT => $this->knowledgeSubjectIds($workspace), }; } + /** + * The knowledge subjects the project declares; null when the catalogue + * cannot be read, which its own validation reports. + * + * @return string[]|null + */ + private function knowledgeSubjectIds(ProjectWorkspace $workspace): ?array + { + try { + return array_map(static fn(object $subject): string => $subject->id, $workspace->catalogs->loadKnowledgeCatalog()->subjects()); + } catch (Throwable) { + return null; + } + } + /** * Returns the project's inventory catalogue, read once per validation. * @@ -351,6 +446,17 @@ private function inventoryCatalog(ProjectWorkspace $workspace): InventoryCatalog return $this->inventoryCatalog ??= InventoryCatalog::fromWorkspace($workspace); } + /** + * Returns the project's skill catalogue, read once per validation. + * + * @param ProjectWorkspace $workspace The project. + * @return SkillCatalog The catalogue. + */ + private function skillCatalog(ProjectWorkspace $workspace): SkillCatalog + { + return $this->skillCatalog ??= $workspace->loadSkillCatalog(); + } + /** @param string[] $categories @return string[] */ private function recordLabels(ProjectWorkspace $workspace, array $categories): array { @@ -392,11 +498,25 @@ private function oneShotEventIds(ProjectWorkspace $workspace): array continue; } - foreach (array_keys(is_array($map->data['events'] ?? null) ? $map->data['events'] : []) as $marker) { + foreach (array_keys(is_array($map->getEditableData()['events'] ?? null) ? $map->getEditableData()['events'] : []) as $marker) { if (is_string($marker)) { $ids[] = $map->mapId . ':' . $marker; } } + + // An NPC page with an id is a saved identity beside the map's events, as each event marker is. + foreach (is_array($map->getEditableData()['npcs'] ?? null) ? $map->getEditableData()['npcs'] : [] as $entry) { + try { + $pages = is_array($entry) ? NpcPages::fromEntry($entry) : null; + } catch (\InvalidArgumentException) { + continue; // npc.page reports it. + } + foreach ($pages?->pages ?? [] as $page) { + if ($page->id !== null && $pages->npcId !== null) { + $ids[] = $map->mapId . ':' . NpcPages::getCompletionMarker($pages->npcId, $page->id); + } + } + } } return $ids; @@ -426,27 +546,18 @@ private function phpListIdentities(string $path): array return $ids; } - /** @return string[] */ + /** + * The summons the game defines, as the Engine's summon library loads them: + * one definition per directory, each a single record. + * + * @return string[] + */ private function summonIds(ProjectWorkspace $workspace): array { - $root = $workspace->projectRoot . '/assets/Cutscenes/Summons'; - - if (! is_dir($root)) { - return []; - } - - $ids = []; - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root)); - - foreach ($iterator as $file) { - if (! $file->isFile() || ! str_ends_with($file->getFilename(), '.data.php')) { - continue; - } - - $ids = [...$ids, ...$this->phpListIdentities($file->getPathname())]; - } - - return array_values(array_unique($ids)); + return array_values(array_unique(array_map( + static fn(SummonCutsceneDefinition $definition): string => $definition->id, + $workspace->catalogs->loadSummons(), + ))); } private function isOneShotEventIdentity(string $identity): bool diff --git a/src/Validation/ScreenTransitionValidator.php b/src/Validation/ScreenTransitionValidator.php new file mode 100644 index 00000000..0842c9a9 --- /dev/null +++ b/src/Validation/ScreenTransitionValidator.php @@ -0,0 +1,53 @@ +projectRoot . DIRECTORY_SEPARATOR . 'assets'; + + try { + $catalog = ScreenTransitionCatalog::load($assetRoot); + } catch (Throwable $failure) { + return [Issue::error( + self::WHERE, + sprintf('The transitions could not be read: %s', $failure->getMessage()), + 'Return a ScreenTransitionCatalog whose keys match their treatment ids and whose battle names one of them. ' . self::FALLBACK, + )]; + } + + if ($catalog === null) { + return []; + } + + try { + $catalog->validateAssets($assetRoot); + } catch (Throwable $failure) { + return [Issue::error( + self::WHERE, + $failure->getMessage(), + 'Point each image at a PNG inside assets/ that the renderers can load. ' . self::FALLBACK, + )]; + } + + return []; + } +} diff --git a/src/Validation/ScriptMapState.php b/src/Validation/ScriptMapState.php new file mode 100644 index 00000000..a018931b --- /dev/null +++ b/src/Validation/ScriptMapState.php @@ -0,0 +1,104 @@ + $this->mapIds === null ? null : implode(' or ', $this->mapIds); + } + + /** + * @param list|null $mapIds The maps the script can be on, or null when unknown. + * @param list|null $npcIds The NPCs on every one of those maps, or null when unknown. + * @param list $concurrentMaps Maps a concurrent parallel lane can transfer to; an empty id names no map. + */ + public function __construct( + public readonly ?array $mapIds, + public readonly ?array $npcIds, + public readonly array $concurrentMaps = [], + ) + { + } + + /** + * The state of a script that starts on one map. + * + * @param string[]|null $npcIds The map's NPCs, or null when unknown. + */ + public static function startOn(?string $mapId, ?array $npcIds): self + { + return new self($mapId !== null ? [$mapId] : null, $npcIds !== null ? array_values($npcIds) : null); + } + + /** + * Joins states a script can be in at one point: any of their maps, and + * only the NPCs present on all of them. + * + * @param non-empty-list $states + */ + public static function mergeStates(array $states): self + { + $mapLists = array_map(static fn(self $state): ?array => $state->mapIds, $states); + $npcLists = array_map(static fn(self $state): ?array => $state->npcIds, $states); + + return new self( + in_array(null, $mapLists, true) ? null : array_values(array_unique(array_merge(...$mapLists))), + in_array(null, $npcLists, true) ? null : array_values(array_intersect(...$npcLists)), + array_values(array_unique(array_merge(...array_map(static fn(self $state): array => $state->concurrentMaps, $states)))), + ); + } + + /** + * The state after a transfer: on its destination, or on any map a + * concurrent lane can transfer to afterwards. + * + * @param array $npcIdsByMap Each known map's NPCs. + */ + public function transferTo(string $destination, array $npcIdsByMap): self + { + return self::mergeStates(array_map( + fn(string $map): self => $this->arriveOn($map, $npcIdsByMap), + [$destination, ...$this->concurrentMaps], + )); + } + + /** + * The state on arriving at one map. An empty map id names no map: the + * current maps stay, with unknown NPCs. + * + * @param array $npcIdsByMap Each known map's NPCs. + */ + public function arriveOn(string $map, array $npcIdsByMap): self + { + return new self( + $map !== '' ? [$map] : $this->mapIds, + $map !== '' && array_key_exists($map, $npcIdsByMap) ? array_values($npcIdsByMap[$map]) : null, + $this->concurrentMaps, + ); + } + + /** + * The same state with other maps a concurrent lane can transfer to. + * + * @param list $maps + */ + public function setConcurrentMaps(array $maps): self + { + return new self($this->mapIds, $this->npcIds, array_values(array_unique($maps))); + } +} diff --git a/src/Validation/SkitBackdropValidator.php b/src/Validation/SkitBackdropValidator.php new file mode 100644 index 00000000..8c7ae288 --- /dev/null +++ b/src/Validation/SkitBackdropValidator.php @@ -0,0 +1,123 @@ +getAssetRoot(); + try { + $regions = $references->getRegionCatalog(); + } catch (Throwable $error) { + return [Issue::error(self::REGIONS, 'The game refuses the regions: ' . $error->getMessage(), + 'Fix them on the Regions page; until then no region gives its maps a skit backdrop.')]; + } + + $issues = []; + $usedRegions = []; + $mapBindings = []; + foreach ($workspace->maps as $map) { + $binding = SkitBackdrop::readMapBinding($map->getEditableData()); + $mapBindings[$map->mapId] = $binding; + if (RegionCatalog::isRegionName($binding['region'] ?? null)) { $usedRegions[$binding['region']] = true; } + if (($binding[SkitBackdrop::MAP_KEY] ?? null) === null) { continue; } + $problem = self::describeProblem($binding[SkitBackdrop::MAP_KEY], $root, 'The map\'s backdrop'); + if ($problem !== null) { + $issues = [...$issues, ...Issue::attributeToMap([Issue::warning(sprintf('%s: %s', $map->mapId, SkitBackdrop::MAP_KEY), + 'Skits here show no backdrop: ' . $problem, 'Choose a project image on the map\'s Skit Backdrop row.')], $map->mapId)]; + } + } + foreach ($regions->regions as $name => $record) { + $name = (string) $name; + if (! isset($usedRegions[$name])) { + $issues[] = Issue::warning(sprintf('%s: %s', self::REGIONS, $name), sprintf('No map is in region %s.', $name), + 'Choose the region a map names on its Region row, or remove the record on the Regions page.'); + } + $asset = $record[SkitBackdrop::MAP_KEY] ?? null; + $problem = $asset === null ? null : self::describeProblem($asset, $root, sprintf('Region %s\'s backdrop', $name)); + if ($problem !== null) { + $issues[] = Issue::warning(sprintf('%s: %s', self::REGIONS, $name), $problem, + 'Choose a project image for its Skit Backdrop on the Regions page.'); + } + } + + return [...$issues, ...$this->checkSkits($workspace, $mapBindings, $regions, $root)]; + } + + /** + * Every skit backdrop that cannot be drawn, and every map a skit can play on where it would show none. + * + * @param array> $mapBindings Each map's part in the choice, by map id. + * @return Issue[] + */ + private function checkSkits(ProjectWorkspace $workspace, array $mapBindings, RegionCatalog $regions, string $root): array + { + $issues = []; + foreach ($workspace->getRecordDatabase('skits')?->getRecords() ?? [] as $record) { + $skit = (array) $record->toArray(); + $where = sprintf('skit %s', strval($skit['id'] ?? $record->recordId)); + if (($skit[SkitBackdrop::SKIT_KEY] ?? null) !== null) { + $problem = self::describeProblem($skit[SkitBackdrop::SKIT_KEY], $root, 'The skit\'s backdrop'); + if ($problem !== null) { + $issues[] = Issue::warning($where, $problem, 'Choose a project image for its Backdrop, or none to show the place\'s.'); + } + continue; + } + $gate = trim(strval($skit['where'] ?? '')); + $maps = $gate === '' ? $mapBindings : array_intersect_key($mapBindings, [$gate => true]); + $bare = array_keys(array_filter($maps, static fn(array $binding): bool + => ($binding[SkitBackdrop::MAP_KEY] ?? null) === null && SkitBackdrop::resolve($skit, $binding, $regions)->asset === null)); + if ($bare === []) { continue; } + sort($bare); + $listed = implode(', ', array_slice($bare, 0, self::LISTED_MAPS)) + . (count($bare) > self::LISTED_MAPS ? sprintf(' and %d more', count($bare) - self::LISTED_MAPS) : ''); + $issue = Issue::warning($where, $gate === '' + ? sprintf('It can play on any map, and %d of them show it no backdrop: %s.', count($bare), $listed) + : sprintf('It plays on map %s, which shows it no backdrop.', $gate), + 'Choose a Skit Backdrop on the map or its region (Regions page), or a Backdrop on the skit when its content needs one setting.'); + $issues = [...$issues, ...($gate === '' ? [$issue] : Issue::attributeToMap([$issue], $gate))]; + } + + return $issues; + } + + /** Why a named backdrop cannot be drawn, or null when it can. */ + private static function describeProblem(mixed $asset, string $root, string $owner): ?string + { + try { + $asset = SkitBackdrop::validateAsset($asset, $owner); + } catch (InvalidArgumentException $error) { + return $error->getMessage(); + } + try { + PngAssetPreflight::inspect($root, $asset); + } catch (Throwable $error) { + return sprintf('%s %s cannot be drawn: %s', $owner, $asset, $error->getMessage()); + } + + return null; + } +} diff --git a/src/Validation/StoryGuidanceValidator.php b/src/Validation/StoryGuidanceValidator.php new file mode 100644 index 00000000..2300f3b9 --- /dev/null +++ b/src/Validation/StoryGuidanceValidator.php @@ -0,0 +1,91 @@ +getQuests() as $quest) { + try { + $quests[] = Quest::fromArray($quest->toArray()); + } catch (Throwable) { + // A malformed quest is reported by the quest checks; it guides nothing here. + } + } + $troops = $workspace->getRecordDatabase('troops'); + $reader = StoryContentReader::fromAssetRoot($workspace->projectRoot . '/assets', $workspace->readCurrentData(...), + $troops instanceof ProjectRecordDatabase + ? array_map(static fn($record): array => (array) $record->toArray(), $troops->getRecords()) : null); + $maps = []; + foreach ($workspace->maps as $map) { + $maps[$map->mapId] = $map->getEditableData(); + } + $index = StoryIndex::read($reader, $maps); + + return [ + ...array_map(fn(StoryGate $gate): Issue => $this->describeGap($gate, $index), StoryGuidance::findUnguidedGates($quests, $index)), + ...array_map(fn(Quest $quest): Issue => $this->describeStartGap($quest, $index), StoryGuidance::findUnguidedStarts($quests, $index)), + ]; + } + + private function describeStartGap(Quest $quest, StoryIndex $index): Issue + { + $starters = $index->findStarters($quest->id); + $message = $starters === [] + ? sprintf('Nothing in the project starts the quest "%s": no event, NPC, event script, cinematic or battle, ' + . 'so the wayfinder cannot lead to it.', $quest->name) + : sprintf('The quest "%s" starts at %s, but no objective of a main quest active before it, and no start ' + . 'waymark, leads there, so the wayfinder shows nothing until it starts.', $quest->name, + implode(', ', array_map(static fn(StorySource $source): string => $source->describe(), $starters))); + + return new Issue(Severity::WARNING, sprintf('quest %s', $quest->id), $message, self::START_HINT, map: $starters[0]->map ?? null); + } + + private function describeGap(StoryGate $gate, StoryIndex $index): Issue + { + $what = $gate->quest !== null ? sprintf('The quest "%s"', $gate->quest->name) : sprintf('The way through event %s', $gate->marker); + $message = $index->findSetters($gate->flag) === [] + ? sprintf('%s needs "%s", but nothing in the project sets it: no event, NPC, event script, cinematic or battle, ' + . 'so the wayfinder cannot lead to it.', $what, $gate->flag) + : sprintf('%s needs "%s", but no objective or start waymark of a main quest leads the player to it, so the wayfinder shows nothing until it is set.', + $what, $gate->flag); + + return new Issue(Severity::WARNING, $gate->quest !== null ? sprintf('quest %s', $gate->quest->id) + : sprintf('%s event %s', $gate->map, $gate->marker), $message, self::HINT, map: $gate->map); + } +} diff --git a/src/Validation/TroopFormationValidator.php b/src/Validation/TroopFormationValidator.php new file mode 100644 index 00000000..769d946e --- /dev/null +++ b/src/Validation/TroopFormationValidator.php @@ -0,0 +1,151 @@ +validateTerminal($workspace), ...$this->validateGraphical($workspace)]; + } + + /** + * Warns of a member whose terminal position leaves out its target badge, + * its focus marker, its step towards the party or the field's bottom. + * + * @return Issue[] + */ + private function validateTerminal(ProjectWorkspace $workspace): array + { + $troops = $workspace->getRecordDatabase('troops')?->getRecords() ?? []; + if ($troops === []) { + return []; + } + $sprites = new EnemySprites($workspace->projectRoot)->readByEnemy($workspace->getRecordDatabase('enemies')); + $field = new TerminalBattleEffects(); + $issues = []; + foreach ($troops as $record) { + $where = sprintf('assets/Data/troops.php: %s', strval($record->get('name'))); + foreach ($record->getSubList('enemies') as $index => $entry) { + $enemy = strval($entry['enemy'] ?? ''); + $position = is_array($entry['position'] ?? null) ? array_values($entry['position']) : []; + // An unreadable sprite is reported where enemies are checked. + if (($sprites[$enemy] ?? []) === [] || !is_int($position[0] ?? null) || !is_int($position[1] ?? null)) { + continue; + } + foreach ($field->getTroopPlaceProblems(new Vector2($position[0], $position[1]), $sprites[$enemy]) as $problem) { + $issues[] = Issue::warning($where, sprintf('%s (member %d) on the terminal field %s.', $enemy, $index + 1, $problem), + 'Move it on the troop\'s terminal field.'); + } + } + } + + return $issues; + } + + /** @return Issue[] */ + private function validateGraphical(ProjectWorkspace $workspace): array + { + $assets = $workspace->projectRoot . DIRECTORY_SEPARATOR . 'assets'; + $layoutFile = 'assets/' . BattlePresentationCatalog::FILE; + try { + $catalog = $workspace->catalogs->loadBattlePresentation(); + } catch (Throwable $problem) { + return [Issue::error($layoutFile, sprintf('The battle presentation cannot be read, so no formation can be checked: %s', $problem->getMessage()), + sprintf('Correct %s.', $layoutFile))]; + } + if ($catalog?->ui === null) { + return []; + } + + $names = []; + foreach ($workspace->actorDatabase->getActors() as $actor) { + $names[$actor->getDefinitionId()] = $actor->getName(); + } + $starting = $workspace->getSystemField('startingParty'); + $starting = array_values(array_filter(is_array($starting) ? $starting : [], is_string(...))); + // A theme formation holds so many; without one the battle arranges the whole party. + $party = $catalog->ui->partySlots === [] ? $starting : array_slice($starting, 0, count($catalog->ui->partySlots)); + + try { + $alone = BattleFormationLayout::compose($catalog, null, [], $party, $assets)->getClearanceDiagnostics($assets); + } catch (InvalidArgumentException|RuntimeException $problem) { + return [Issue::error($layoutFile, sprintf('The starting party cannot be stood in its battle slots: %s', $problem->getMessage()), + 'Correct the party slots in the battle layout, or the actors\' battle art.')]; + } + $issues = []; + foreach ($alone['party'] as $position => $diagnostics) { + foreach ($diagnostics as $diagnostic) { + $issues[] = Issue::warning(sprintf('%s: party slot %d', $layoutFile, $position + 1), + sprintf('%s: %s', $names[$party[$position]] ?? $party[$position], $diagnostic), + 'Move the party slot in the battle layout, or adjust the actor\'s battle art size or ground point.'); + } + } + + foreach ($workspace->getRecordDatabase('troops')?->getRecords() ?? [] as $record) { + $troop = strval($record->get('name')); + $where = sprintf('assets/Data/troops.php: %s', $troop); + // A member without a placement is arranged by the battle beside the others. + $placed = []; + $readable = true; + foreach ($record->getSubList('enemies') as $index => $entry) { + $slot = null; + if (is_array($entry['graphicalPlacement'] ?? null)) { + try { + $slot = BattleStageSlot::fromArray($entry['graphicalPlacement']); + } catch (InvalidArgumentException $problem) { + // The battle refuses the troop's graphics while any placement is unreadable. + $issues[] = Issue::error($where, sprintf('%s (member %d): %s', strval($entry['enemy'] ?? ''), $index + 1, $problem->getMessage()), + 'Align the troop, arrange it automatically, or drag the member on the troop\'s formation canvas.'); + $readable = false; + } + } + $placed[] = ['enemyId' => strval($entry['enemy'] ?? ''), 'slot' => $slot]; + } + if ($placed === [] || !$readable) { + continue; + } + try { + $clearance = BattleFormationLayout::compose($catalog, null, $placed, $party, $assets)->getClearanceDiagnostics($assets); + } catch (InvalidArgumentException|RuntimeException $problem) { + $issues[] = Issue::error($where, sprintf('Its formation cannot be composed: %s', $problem->getMessage()), + 'Move its members on the troop\'s formation canvas until each stands on the battle canvas.'); + continue; + } + foreach ($clearance['enemies'] as $position => $diagnostics) { + foreach ($diagnostics as $diagnostic) { + $issues[] = Issue::warning($where, + sprintf('%s (member %d): %s', $placed[$position]['enemyId'], $position + 1, $diagnostic), + 'Move it on the troop\'s formation canvas, or adjust its battle art size or ground point.'); + } + } + } + + return $issues; + } +} diff --git a/src/Validation/WordFormValidator.php b/src/Validation/WordFormValidator.php new file mode 100644 index 00000000..5f78117b --- /dev/null +++ b/src/Validation/WordFormValidator.php @@ -0,0 +1,52 @@ + Word Forms; the game will not start until it is fixed.'; + + /** @return Issue[] */ + public function validate(ProjectWorkspace $workspace): array + { + $database = $workspace->getRecordDatabase(WordFormFields::CATEGORY); + if ($database === null || (! is_file($database->backingFilePath()) && ! $database->isDirty())) { + return []; + } + + $issues = []; + $words = []; + foreach ($database->getRecords() as $record) { + $entry = (array) $record->toArray(); + $word = $entry['word'] ?? null; + unset($entry['word']); + try { + new WordForms(is_string($word) ? [$word => $entry] : [$entry]); + } catch (InvalidArgumentException $invalid) { + $issues[] = Issue::error(self::WHERE, 'The game cannot read a word form: ' . $invalid->getMessage(), self::HINT); + continue; + } + if (isset($words[$word])) { + $issues[] = Issue::error(self::WHERE, sprintf('The word "%s" is listed twice.', $word), + 'The game keeps one pair of forms for each word, so list each word once.'); + } + $words[$word] = true; + } + + return $issues; + } +} diff --git a/tests/Fixtures/SourceWriterEnums.php b/tests/Fixtures/SourceWriterEnums.php new file mode 100644 index 00000000..d1228f51 --- /dev/null +++ b/tests/Fixtures/SourceWriterEnums.php @@ -0,0 +1,27 @@ + Character::class, 'data' => [ + 'id' => 'Kaelion', 'name' => 'Kaelion', 'description' => 'A brave and steadfast swordsman.', 'level' => 1, diff --git a/tests/Fixtures/sample-project/assets/Data/Entities/player.php b/tests/Fixtures/sample-project/assets/Data/Entities/player.php new file mode 100644 index 00000000..05304259 --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Entities/player.php @@ -0,0 +1,11 @@ + [ + 'north' => ['^'], + 'east' => ['>'], + 'south' => ['v'], + 'west' => ['<'], + ], +]; diff --git a/tests/Fixtures/sample-project/assets/Data/Items/0001-s-potion.php b/tests/Fixtures/sample-project/assets/Data/Items/0001-s-potion.php new file mode 100644 index 00000000..6eca15ea --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Items/0001-s-potion.php @@ -0,0 +1,15 @@ + InventoryItem::class, + 'data' => [ + 'kind' => 'item', + 'id' => 'legacy.s-potion', + 'name' => 'S-Potion', + 'description' => 'A potion that restores 50 HP.', + 'icon' => '🧪', + 'price' => 50, + ], +]; diff --git a/tests/Fixtures/sample-project/assets/Data/Items/0002-antidote.php b/tests/Fixtures/sample-project/assets/Data/Items/0002-antidote.php new file mode 100644 index 00000000..20d52f05 --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Items/0002-antidote.php @@ -0,0 +1,15 @@ + InventoryItem::class, + 'data' => [ + 'kind' => 'item', + 'id' => 'legacy.antidote', + 'name' => 'Antidote', + 'description' => 'Cures Poison.', + 'icon' => '🧪', + 'price' => 80, + ], +]; diff --git a/tests/Fixtures/sample-project/assets/Data/Weapons/0001-wooden-sword.php b/tests/Fixtures/sample-project/assets/Data/Weapons/0001-wooden-sword.php new file mode 100644 index 00000000..6b067d08 --- /dev/null +++ b/tests/Fixtures/sample-project/assets/Data/Weapons/0001-wooden-sword.php @@ -0,0 +1,19 @@ + InventoryItem::class, + 'data' => [ + 'kind' => 'weapon', + 'id' => 'legacy.wooden-sword', + 'name' => 'Wooden Sword', + 'description' => 'A wooden sword.', + 'icon' => '🗡️', + 'price' => 100, + 'equipmentType' => 'Sword', + 'parameterChanges' => [ + 'attack' => 1, + ], + ], +]; diff --git a/tests/Fixtures/sample-project/assets/Data/animations.php b/tests/Fixtures/sample-project/assets/Data/animations.php index 61c3d38a..3d35c90a 100644 --- a/tests/Fixtures/sample-project/assets/Data/animations.php +++ b/tests/Fixtures/sample-project/assets/Data/animations.php @@ -6,6 +6,8 @@ 'name' => 'Slash', 'position' => 'center', 'maxFrames' => 2, + // Every attack the fixture reaches: enemies, the unarmed, and its sword. + 'roles' => ['attack', 'attack-unarmed', 'attack-sword'], 'frames' => [ [ 'index' => 1, diff --git a/tests/Fixtures/sample-project/assets/Data/items.php b/tests/Fixtures/sample-project/assets/Data/items.php index 076a4787..954efd00 100644 --- a/tests/Fixtures/sample-project/assets/Data/items.php +++ b/tests/Fixtures/sample-project/assets/Data/items.php @@ -1,12 +1,7 @@ getValue($editor); } +/** + * Makes a temporary project's System category one the editor cannot write, + * as a real project's can be: its file returns a list, not the one map of + * settings the editor rewrites. Returns the category's key. + */ +function makeUnwritableCategory(string $root): string +{ + file_put_contents($root . '/assets/Data/system.php', " $skill) { + $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($skill->name)) ?? '', '-'); + $data = var_export(\Ichiloto\Engine\Entities\Skills\SkillRecord::writeSkill($skill), true); + file_put_contents( + sprintf('%s/%04d-%s.php', $directory, $index + 1, $slug), + " \\Ichiloto\\Engine\\Entities\\Skills\\Skill::class, 'data' => {$data}];\n", + ); + } +} + +/** + * Authors a project's inventory as record files, items under Items/, + * weapons under Weapons/, armors and accessories under Armors/, each folder + * numbered in the given order, with the items.php barrel that loads them, + * replacing any inventory already there: the fixture form of a project's + * items, weapons and armors. + * + * @param string $root The project root. + * @return list Each definition's record file, relative to the project root, in the given order. + */ +function writeItemRecords(string $root, \Ichiloto\Engine\Entities\Inventory\InventoryItem ...$items): array +{ + $data = $root . '/assets/Data'; + + foreach (\Ichiloto\Engine\Entities\Inventory\ItemCatalog::DIRECTORIES as $folder) { + is_dir("$data/$folder") || mkdir("$data/$folder", 0777, true); + array_map(unlink(...), glob("$data/$folder/*.php") ?: []); + } + + $numbers = []; + $files = []; + + foreach ($items as $item) { + $folder = match (true) { + $item instanceof \Ichiloto\Engine\Entities\Inventory\Items\Item => 'Items', + $item instanceof \Ichiloto\Engine\Entities\Inventory\Weapons\Weapon => 'Weapons', + default => 'Armors', + }; + $numbers[$folder] = ($numbers[$folder] ?? 0) + 1; + $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($item->name)) ?? '', '-'); + $file = sprintf('assets/Data/%s/%04d-%s.php', $folder, $numbers[$folder], $slug); + $record = var_export(\Ichiloto\Engine\Entities\Inventory\ItemRecord::writeItem($item), true); + file_put_contents("$root/$file", " \\Ichiloto\\Engine\\Entities\\Inventory\\InventoryItem::class, 'data' => {$record}];\n"); + $files[] = $file; + } + + file_put_contents("$data/items.php", " + */ +function itemsFromSource(string $source): array +{ + $path = (string) tempnam(sys_get_temp_dir(), 'ichiloto-items-'); + + try { + file_put_contents($path, $source); + + return array_values((static fn(): mixed => require $path)()); + } finally { + unlink($path); + } +} + +/** + * The synthetic sample project's inventory, read once: what map save + * warnings check a chest's reward against. + */ +/** + * The messages of the pre-save checks of a map, whatever their severity. + * + * @param array $mapsById + * @return list + */ +function mapValidatorMessages(Ichiloto\Editor\ProjectMap $map, array $mapsById): array +{ + return array_map(static fn(Ichiloto\Editor\Validation\Issue $issue): string => $issue->message, + Ichiloto\Editor\Validation\MapValidator::validate($map, $mapsById, mapValidatorInventory())); +} + +function mapValidatorInventory(): \Ichiloto\Editor\Database\InventoryCatalog +{ + static $catalog = null; + + return $catalog ??= \Ichiloto\Editor\Database\InventoryCatalog::fromWorkspace( + \Ichiloto\Editor\ProjectWorkspace::fromProject(makeTemporaryProject('ichiloto-map-validator-')), + ); +} diff --git a/tests/Support/AuthoringCanvasFixtures.php b/tests/Support/AuthoringCanvasFixtures.php new file mode 100644 index 00000000..854a0906 --- /dev/null +++ b/tests/Support/AuthoringCanvasFixtures.php @@ -0,0 +1,46 @@ + 2, 'type' => 'ready', 'capabilities' => [...json_decode(previewWindowReady(), true)['capabilities'], + RendererSessionConfig::TEXT_LAYOUT, RendererSessionConfig::CANVAS_LABELS, RendererSessionConfig::WORLD_CANVAS, + RendererSessionConfig::SPRITE_PIVOT, RendererSessionConfig::TILE_BATCHES]], JSON_THROW_ON_ERROR); +} + +/** @return list> */ +function findCanvasMessages(array $answer, string $type): array +{ + return array_values(array_filter($answer['messages'], static fn(array $message): bool => $message['type'] === $type)); +} + +/** The window's acknowledgement of every frame an exchange sent it. */ +function acknowledgeCanvasFrames(array $answer): array +{ + return array_map(static fn(array $frame): string => json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $frame['payload']['generation'], 'frame' => $frame['payload']['frame'], 'presented' => true], JSON_THROW_ON_ERROR), + findCanvasMessages($answer, 'frame')); +} + +/** + * A map's whole picture, as a newly opened editing canvas first draws it: the exchange's answer, with every + * operation of the frames it sent. + * + * @param array $variants + * @return array + */ +function drawAuthoringCanvas(EditorSession $session, string $mapId, array $variants = [], float $seconds = 0): array +{ + $session->closeAuthoringCanvas($mapId); + $opened = $session->exchangeAuthoringCanvas($mapId, [], $variants, $seconds); + $drawn = $session->exchangeAuthoringCanvas($mapId, [canvasWindowReady()], $variants, $seconds, $opened['sessionId']); + $session->closeAuthoringCanvas($mapId); + + return [...$drawn, 'operations' => array_merge(...array_map(static fn(array $frame): array => $frame['payload']['operations'], + findCanvasMessages($drawn, 'frame')))]; +} diff --git a/tests/Support/BattleFormationFixtures.php b/tests/Support/BattleFormationFixtures.php new file mode 100644 index 00000000..a9f2467d --- /dev/null +++ b/tests/Support/BattleFormationFixtures.php @@ -0,0 +1,158 @@ + new BattleArenaDefinition($name, + new CanvasImage('arena', 'Graphics/Battlebacks/' . $file, new CanvasRectangle(0, 0, 1350, 720))); +return new BattlePresentationCatalog( + arenas: ['arena.road' => $arena('Road', 'Road.png'), 'arena.cave' => $arena('Cave', 'Cave.png')], + actors: [], + enemies: [], + ui: new BattleCanvasLayout(1350, 720, partySlots: [new BattleStageSlot(1050, 180, 180, 260), new BattleStageSlot(1180, 40, 180, 260)]), + defaultArena: 'arena.cave', +); +PHP); + file_put_contents($root . '/assets/Data/troops.php', <<<'PHP' + 'Pair', + 'enemies' => [ + // The leader, placed for the graphical battle. + ['enemy' => 'Regular Bat', 'position' => [15, 7], 'graphicalPlacement' => ['x' => 260, 'depth' => 300, 'width' => 275, 'height' => 190]], + ['enemy' => 'Regular Bat', 'position' => [15, 20]], + ], + ], +]; +PHP); + file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); + writeTilesetTestPng($root . '/assets/Graphics/Battlebacks/Cave.png', 8, 8); + + return $root; +} + +/** The troop formation project with one enemy record and two terminal sprites. */ +function enemyPreviewProject(): string +{ + $root = troopFormationProject(); + @mkdir($root . '/assets/Graphics/Enemies', 0o777, true); + file_put_contents($root . '/assets/Graphics/Enemies/wisp.txt', "~*~\n"); + file_put_contents($root . '/assets/secret.txt', "not a sprite\n"); + writeRegularBatEnemy($root); + + return $root; +} + +/** The troop formation project with a second enemy, a wisp, beside its bat. */ +function troopPageProject(): string +{ + $root = enemyPreviewProject(); + file_put_contents($root . '/assets/Data/Enemies/marsh-wisp.php', " Enemy::class, 'data' => " + . var_export(['name' => 'Marsh Wisp', 'level' => 1, 'imagePath' => 'wisp', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ + 'maxHp' => 5, 'maxMp' => 0, 'attack' => 1, 'defence' => 1, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 1, 'grace' => 1, 'evasion' => 1, + ]], true) . "];\n"); + + return $root; +} + +/** The Regular Bat the troop formation project's troop fights, as its one enemy record, with its terminal sprite. */ +function writeRegularBatEnemy(string $root): void +{ + foreach (['/assets/Data/Enemies', '/assets/Graphics/Enemies'] as $folder) { + if (! is_dir($root . $folder)) { + mkdir($root . $folder, 0o777, true); + } + } + file_put_contents($root . '/assets/Graphics/Enemies/bat.txt', "/\\o/\\\n ' '\n"); + file_put_contents($root . '/assets/Data/Enemies/regular-bat.php', " Enemy::class, 'data' => " + . var_export(['name' => 'Regular Bat', 'level' => 2, 'imagePath' => 'bat', 'rewards' => ['experience' => 1, 'gold' => 1], 'stats' => [ + 'maxHp' => 10, 'maxMp' => 0, 'attack' => 3, 'defence' => 2, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 2, 'grace' => 1, 'evasion' => 1, + ]], true) . "];\n"); +} + +/** The enemy preview project with battle art for its bat on disk. */ +function battlerArtProject(): string +{ + $root = enemyPreviewProject(); + writeTilesetTestPng($root . '/assets/Graphics/Enemies/Bat.png', 40, 80); + writeTilesetTestPng($root . '/assets/Graphics/Enemies/BatAttack.png', 160, 80); + + return $root; +} + +/** A battle art record's row, by its field. */ +function readBattlerRow(EditorSession $session, int $index, string $field): array +{ + return array_find($session->readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException("No row {$field}."); +} + +function setBattlerRow(EditorSession $session, int $index, string $field, string $value): void +{ + $session->applyDatabaseRecord('battler_enemies', $index, readBattlerRow($session, $index, $field)['key'], $value); +} + +/** The troop formation project with a Kaelion the Engine can build: full stats, as a battle needs. */ +function battleTestProject(): string +{ + $root = troopFormationProject(); + file_put_contents($root . '/assets/Data/Actors/Kaelion.php', " 'Ichiloto\\Engine\\Entities\\Character', 'data' => [ + 'id' => 'Kaelion', 'name' => 'Kaelion', 'level' => 1, 'currentExp' => 0, 'stats' => [ + 'currentHp' => 40, 'currentMp' => 10, 'currentAp' => 3, 'totalHp' => 100, 'totalMp' => 20, 'totalAp' => 3, + 'attack' => 8, 'defence' => 7, 'magicAttack' => 6, 'magicDefence' => 5, 'speed' => 4, 'grace' => 3, 'evasion' => 2, + ], + ]], true) . ";\n"); + + return $root; +} + +/** The battle test project holding the lantern summon, with sheet art that rests on its second cell. */ +function summonBattlePreviewProject(array $wielders = []): string +{ + $root = battleTestProject(); + $summon = $root . '/assets/Cutscenes/Summons/lantern-wisp'; + mkdir($summon, 0o777, true); + $data = lanternSummonData(); + if ($wielders !== []) { + $data = str_replace(" 'linkedActionId' => 'Fireball',", " 'linkedActionId' => 'Fireball',\n 'wielders' => " . var_export($wielders, true) . ',', $data); + } + file_put_contents($summon . '/lantern-wisp.data.php', $data); + file_put_contents($summon . '/lantern-wisp.timeline.php', str_replace([" 'lengthFrames' => 24,", " ['type' => 'text', 'id' => 'name',"], [ + " 'lengthFrames' => 24,\n 'restFrame' => 12,", + " ['type' => 'image', 'id' => 'wisp-art', 'presentation' => 'graphical', 'asset' => 'Graphics/Summons/Wisp.png',\n" + . " 'sheet' => ['columns' => 2, 'rows' => 1], 'anchor' => 'target', 'attachment' => 'ground', 'pivot' => ['x' => 0.5, 'y' => 1],\n" + . " 'keyframes' => [['frame' => 0, 'duration' => 12, 'sourceFrame' => 0], ['frame' => 12, 'duration' => 12, 'sourceFrame' => 1]]],\n" + . " ['type' => 'text', 'id' => 'name',"], lanternSummonTimeline())); + mkdir($root . '/assets/Graphics/Summons', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); + writeFireballSkill($root); + // The battle reads enemies the way the game loads them, one file each through the project's barrel. + writeRegularBatEnemy($root); + // One member placed; the battle arranges the other beside it. + file_put_contents($root . '/assets/Data/troops.php', str_replace("['enemy' => 'Regular Bat', 'position' => [15, 20]]", + "['enemy' => 'Regular Bat', 'position' => [15, 20], 'graphicalPlacement' => ['x' => 420, 'depth' => 220, 'width' => 275, 'height' => 190]]", + (string) file_get_contents($root . '/assets/Data/troops.php'))); + file_put_contents($root . '/assets/Data/enemies.php', " CollisionType::NONE, '#' => CollisionType::SOLID, '⊓' => CollisionType::SOLID];\n"); + writeBuildingMap($root, 'town', 8, 6, 'Town'); + writeBuildingMap($root, 'inside', 4, 3, 'Inside'); + $sheets = $root . '/assets/Graphics/Tilesets'; + mkdir($sheets, 0o777, true); + file_put_contents($sheets . '/Town_B.png', PngPixels::createBlank(32, 32)->encode()); + $c = PngPixels::createBlank(32, 32); + $c->setPixel(0, 0, [200, 10, 10, 255]); + file_put_contents($sheets . '/Town_C.png', $c->encode()); + mkdir($root . '/assets/Data/Tilesets', 0o777, true); + file_put_contents($root . '/assets/Data/Tilesets/town.php', " 'Town', 'sheets' => ['B' => 'Graphics/Tilesets/Town_B.png', 'C' => 'Graphics/Tilesets/Town_C.png']], true) . ";\n"); + + return $root; +} + +/** A map of open ground with an empty buildings layer, its own collision and the town tileset. */ +function writeBuildingMap(string $root, string $id, int $width, int $height, string $name): void +{ + $directory = $root . '/assets/Maps/' . $id; + mkdir($directory . '/layers', 0o777, true); + $rows = static fn(string $cell): string => implode("\n", array_fill(0, $height, str_repeat($cell, $width))); + file_put_contents($directory . '/layers/01.terrain.map.php', MapGridSource::buildSource($rows('.'), 'MAP')); + file_put_contents($directory . '/layers/02.buildings.map.php', MapGridSource::buildSource($rows(' '), 'MAP')); + file_put_contents($directory . "/{$id}.event.php", MapGridSource::buildSource($rows(' '), 'EVENT')); + file_put_contents($directory . "/{$id}.data.php", ' $name, 'region' => '', 'events' => [], + 'tileset' => 'town', 'occupancy' => array_fill(0, $height, array_fill(0, $width, CollisionType::NONE))], true) . ';'); +} + +/** + * The building's art as it arrives from an artist, outside the project: a + * small image of 3 x 2 cells, each a colour of its own. + */ +function writeIncomingBuildingArt(string $directory): string +{ + if (! is_dir($directory)) { + mkdir($directory, 0o777, true); + } + $art = PngPixels::createBlank(6, 4); + foreach ([[0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1]] as $index => [$column, $row]) { + for ($y = 0; $y < 2; $y++) { + for ($x = 0; $x < 2; $x++) { + $art->setPixel($column * 2 + $x, $row * 2 + $y, [40 * $index, 100, 180 - $index * 20, 255 - $index]); + } + } + } + $path = $directory . '/Hut.png'; + file_put_contents($path, $art->encode()); + + return $path; +} + +/** The session host as the GUI editor speaks to it, over its own requests. */ +function createBuildingRpc(string $root): Closure +{ + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + + return $request; +} + +/** A request's answer, failing the test with the refusal when there is one. */ +function expectAnswer(array $reply): array +{ + expect($reply)->toHaveKey('result', message: json_encode($reply['error'] ?? $reply)); + + return $reply['result']; +} + +/** A refused request's message. */ +function expectRefusal(array $reply): string +{ + expect($reply)->toHaveKey('error'); + + return (string) $reply['error']['message']; +} diff --git a/tests/Support/CutsceneFixtures.php b/tests/Support/CutsceneFixtures.php index 906e16d2..22504b56 100644 --- a/tests/Support/CutsceneFixtures.php +++ b/tests/Support/CutsceneFixtures.php @@ -2,8 +2,11 @@ declare(strict_types=1); +use Ichiloto\Editor\Cutscenes\CutsceneHydration; use Ichiloto\Editor\Cutscenes\CutsceneLibrary; use Ichiloto\Editor\Editor; +use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Rendering\Transport\RendererSessionConfig; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\UI\CutscenesScreen; @@ -121,6 +124,44 @@ function lanternSummonTimeline(): string PHP_SOURCE; } +/** A summon project whose lantern wisp has a terminal and a graphical sequence, its art a synthetic sheet. */ +function stagedSummonProject(): string +{ + $root = cutsceneProject(); + file_put_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php', <<<'PHP_SOURCE' + 1, + 'presentations' => [ + 'terminal' => [ + 'fps' => 12, + 'lengthFrames' => 24, + 'tracks' => [ + ['type' => 'glyph', 'id' => 'wisp', 'keyframes' => [['frame' => 0, 'duration' => 24, 'content' => '*', 'position' => ['x' => 10, 'y' => 5]]]], + ], + 'cues' => [['id' => 'flare', 'frame' => 12, 'type' => 'applyEffect']], + ], + 'graphical' => [ + 'fps' => 12, + 'lengthFrames' => 24, + 'restFrame' => 12, + 'tracks' => [ + // The wisp itself. + ['type' => 'image', 'id' => 'wisp-art', 'asset' => 'Graphics/Summons/Wisp.png', 'sheet' => ['columns' => 2, 'rows' => 1], + 'keyframes' => [['frame' => 0, 'duration' => 24, 'sourceFrame' => 0]]], + ], + 'cues' => [['id' => 'flare', 'frame' => 12, 'type' => 'applyEffect']], + ], + ], +]; +PHP_SOURCE); + mkdir($root . '/assets/Graphics/Summons', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8); + + return $root; +} + /** * A throwaway project holding the cinematic and the summon. */ @@ -146,34 +187,17 @@ function cutsceneProject(): string */ function writeFireballSkill(string $root): void { - file_put_contents($root . '/assets/Data/skills.php', <<<'PHP_SOURCE' -stats->magicAttack * 3', NULL, 0.2, false), - ], - ), -]; -PHP_SOURCE); + writeSkillRecords($root, new \Ichiloto\Engine\Entities\Skills\MagicSkill( + 'Fireball', + 'A burst of flame.', + 'FIR', + 4, + 0, + new \Ichiloto\Engine\Entities\ItemScope(), + \Ichiloto\Engine\Entities\Enumerations\Occasion::BATTLE_SCREEN, + new \Ichiloto\Engine\Entities\Skills\SkillInvocation('$1 casts Fireball!', 0, 0, 1, 10), + [new \Ichiloto\Engine\Entities\Effects\SkillEffects\HPDamageSkillEffect('$user->stats->magicAttack * 3', null, 0.2, false)], + )); } /** @@ -465,3 +489,109 @@ function require_pair_data(string $literal): array return $value; } + +/** + * An original flat field effect: one glyph track and a sound cue, with a + * comment the editor must keep. + */ +function emberSparkTimeline(): string +{ + return <<<'PHP_SOURCE' + 10, + 'lengthFrames' => 6, + 'tracks' => [[ + 'id' => 'spark', + 'type' => 'glyph', + 'keyframes' => [ + ['frame' => 0, 'duration' => 3, 'content' => '*', 'position' => ['x' => 0, 'y' => 0], 'color' => 'yellow'], + ['frame' => 3, 'duration' => 3, 'content' => '+', 'position' => ['x' => 1, 'y' => 0], 'color' => 'red'], + ], + ]], + 'cues' => [['id' => 'crackle', 'frame' => 0, 'type' => 'playSound', 'payload' => ['sound' => 'crackle']]], +]; +PHP_SOURCE; +} + +/** + * An original battle effect with separate terminal and graphical sequences: + * a glyph stroke in the terminal, a two-frame image stroke on screen. + */ +function duskSlashTimeline(): string +{ + return <<<'PHP_SOURCE' + [ + 'terminal' => [ + 'fps' => 12, + 'lengthFrames' => 4, + 'tracks' => [[ + 'id' => 'stroke', + 'type' => 'glyph', + 'facing' => 'west', + 'keyframes' => [['frame' => 0, 'duration' => 4, 'content' => '/', 'position' => ['x' => 0, 'y' => 0]]], + ]], + ], + 'graphical' => [ + 'fps' => 12, + 'lengthFrames' => 2, + 'restFrame' => 1, + 'tracks' => [[ + 'id' => 'stroke-art', + 'type' => 'image', + 'asset' => 'Graphics/Effects/dusk-slash.png', + 'sheet' => ['columns' => 2, 'rows' => 1], + 'facing' => 'west', + 'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 1, 'sourceFrame' => 1]], + ]], + ], + ], +]; +PHP_SOURCE; +} + +/** A project holding the two original effects under assets/Animations. */ +function effectProject(): string +{ + $root = cutsceneProject(); + foreach (['ember-spark' => emberSparkTimeline(), 'dusk-slash' => duskSlashTimeline()] as $id => $source) { + @mkdir($root . '/assets/Animations/' . $id, 0o777, true); + file_put_contents($root . '/assets/Animations/' . $id . '/' . $id . '.timeline.php', $source); + } + @mkdir($root . '/assets/Graphics/Effects', 0o777, true); + writeTilesetTestPng($root . '/assets/Graphics/Effects/dusk-slash.png', 32, 16); + + return $root; +} + +/** The harbour cinematic of {@see cutsceneProject()}, as the Engine reads it. */ +function harbourDefinition(string $root): \Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition +{ + $folder = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns'; + $data = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.data.php'); + $script = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.script.php'); + + return CutsceneHydration::cinematic($data, $script, $root); +} + +/** The editor window's READY: what its graphical view can draw, as the game's renderer says it. */ +function previewWindowReady(): string +{ + return json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => [ + RendererSessionConfig::SPRITE_SOURCE_RECT, RendererSessionConfig::GRAPHICAL_CANVAS, RendererSessionConfig::CANVAS_OVERLAY, + RendererSessionConfig::CANVAS_CLIP_OPACITY, RendererSessionConfig::FRAME_VIEWPORT, RendererSessionConfig::FIELD_MOTION, + RendererSessionConfig::SPRITE_LIFT, RendererSessionConfig::CANVAS_IMAGE_TONE, RendererSessionConfig::CANVAS_IMAGE_FLIP, + ]]); +} + +/** @param list}> $messages */ +function previewFrameGenerations(array $messages): array +{ + return array_values(array_filter(array_map(static fn(array $message): ?int => $message['type'] === 'frame' + ? ($message['payload']['generation'] ?? null) : null, $messages), static fn(?int $generation): bool => $generation !== null)); +} diff --git a/tests/Support/DialogueFixtures.php b/tests/Support/DialogueFixtures.php new file mode 100644 index 00000000..4f13ec51 --- /dev/null +++ b/tests/Support/DialogueFixtures.php @@ -0,0 +1,51 @@ + 'ichiloto.dialogue/1', + 'actors' => [ + 'Kaelion' => [ + 'portrait' => 'Graphics/Faces/Hero.png', + 'emotions' => [ + 'Angry' => ['portrait' => 'Graphics/Faces/HeroAngry.png', 'bust' => 'Graphics/Faces/HeroAngry.png'], + ], + ], + ], + 'resources' => [ + 'Mira' => [ + 'portrait' => 'Graphics/Faces/Mira.png', // The innkeeper. + 'emotions' => ['Worried' => 'Graphics/Faces/MiraWorried.png'], + ], + ], + 'speakers' => [ + 'Kaelion' => 'Kaelion', + 'Mom' => 'Mira', + ], +]; +PHP; +} + +/** A project holding a dialogue catalogue and its artwork, opened in a session. */ +function createDialogueProject(?string $catalogue = null): array +{ + $root = makeTemporaryProject('editor-dialogue-portraits-'); + $file = $root . '/' . DialogueCatalogFile::RELATIVE_PATH; + mkdir(dirname($file), 0o777, true); + file_put_contents($file, $catalogue ?? syntheticDialogueCatalogue()); + foreach (['Hero', 'HeroAngry', 'HeroSad', 'Mira', 'MiraWorried'] as $face) { + writeTilesetTestPng($root . '/assets/Graphics/Faces/' . $face . '.png', 8, 8); + } + + return [$root, $file, EditorSession::open($root)]; +} diff --git a/tests/Support/EventRecordFixtures.php b/tests/Support/EventRecordFixtures.php new file mode 100644 index 00000000..569c7a2e --- /dev/null +++ b/tests/Support/EventRecordFixtures.php @@ -0,0 +1,81 @@ + 'Test Map', + 'region' => '', + 'description' => 'A tiny fixture map.', + 'triggers' => [], + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger', + 'data' => [ + 'lootType' => 'item', + 'loot' => 'S-Potion', + ], + ], + // The way out. + 'T' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger', + 'data' => [ + 'destinationMap' => 'test-map', + 'spawnPoint' => ['x' => 1, 'y' => 1], + 'spawnSprite' => ['@'], + ], + ], + 'D' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\DialogueEventTrigger', + 'sets' => [ + ['type' => 'event', 'name' => 'met_guide', 'note' => 'kept by the author'], + ], + 'data' => [ + 'dialogue' => [ + ['name' => 'Guide', 'text' => 'First line.'], + ['name' => '', 'text' => 'Second line.'], + ], + ], + ], + 'S' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ScriptEventTrigger', + 'conditions' => [], + 'data' => [ + 'mode' => 'auto', + 'reusable' => true, + 'scriptId' => 'harbour-watch', + 'script' => [ + ['type' => 'text', 'name' => 'Observer', 'text' => 'Stop there.'], + // Timed by the author. + ['type' => 'wait', 'seconds' => abs(1)], + ], + ], + ], + ], +]; +PHP); + + return $root; +} + +/** The test map's data file in an event record project. */ +function eventRecordDataPath(string $root): string +{ + return $root . '/assets/Maps/test-map/test-map.data.php'; +} diff --git a/tests/Support/LayeredMapFixtures.php b/tests/Support/LayeredMapFixtures.php new file mode 100644 index 00000000..296c957a --- /dev/null +++ b/tests/Support/LayeredMapFixtures.php @@ -0,0 +1,51 @@ +....\n...."; + $building = " / \n xx "; + $decoration = "d \n d"; + $events = " E\n "; + if ($ragged) { + $terrain = "....\n.."; + $building = " / \nx "; + $decoration = "d \n d"; + $events = " E\n "; + } + foreach (['01.terrain.map.php' => $terrain, '04.buildings.map.php' => $building, '07.detail.deco.php' => $decoration] as $file => $text) { + $source = MapGridSource::buildSource($text, 'AUTHORED', '// keep ' . $file . "\n"); + file_put_contents($directory . '/layers/' . $file, $source); + } + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($events, 'EVENTS')); + file_put_contents($directory . '/test-map.data.php', <<<'SOURCE' + 'Test Map', 'region' => '', 'events' => [], +]; +SOURCE); + return $root; +} + +function loadLayeredMap(string $root): ProjectMap +{ + return ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map'); +} + +function layeredCanvasEditor(?string $root = null): array +{ + $root ??= layeredMapProject(); + $editor = deletionEditor($root); + setEditorProperty($editor, 'focusedPane', 'canvas'); + return [$editor, getEditorProperty($editor, 'workspace')->getMapByIndex(0), $root]; +} diff --git a/tests/Support/MapFixtures.php b/tests/Support/MapFixtures.php index b15539d6..3b8d9c1d 100644 --- a/tests/Support/MapFixtures.php +++ b/tests/Support/MapFixtures.php @@ -126,3 +126,40 @@ function backdateTriplet(ProjectMap $map): void touch($path, time() - 3600); } } + +/** + * Copies the fixture map into a scratch maps root for save tests. + */ +function scratchMapCopy(): array +{ + $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-editor-test-' . bin2hex(random_bytes(4))); + $mapsRoot = $root . '/assets/Maps'; + $directory = $mapsRoot . '/test-map'; + mkdir($directory, 0777, true); + + foreach (['data', 'map', 'event'] as $part) { + copy( + fixturePath("sample-project/assets/Maps/test-map/test-map.{$part}.php"), + "{$directory}/test-map.{$part}.php", + ); + } + + return [$root, ProjectMap::fromDirectory($mapsRoot, $directory)]; +} + +/** + * Removes a scratch tree created by scratchMapCopy(). + */ +function removeScratchTree(string $root): void +{ + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($root); +} diff --git a/tests/Support/MapGraphicsFixtures.php b/tests/Support/MapGraphicsFixtures.php new file mode 100644 index 00000000..52f22200 --- /dev/null +++ b/tests/Support/MapGraphicsFixtures.php @@ -0,0 +1,86 @@ + pack('N', strlen($data)) . $type . $data + . pack('N', crc32($type . $data)); + file_put_contents($path, "\x89PNG\r\n\x1a\n" + . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', (string) gzcompress(str_repeat("\0" . str_repeat("\x70\x90\xB0\xFF", $width), $height))) + . $chunk('IEND', '')); +} + +/** + * Writes tileset `home` with an A2 sheet (16 x 12 tiles) and a B sheet + * (16 x 16 tiles) at 2 pixels per tile, and any pieces given. + * + * @param array $sheets Asset-relative PNG paths by sheet name. + * @param array> $pieces Pieces by id, as a tileset file lists them. + * @param int|null $missingArt The tileset's missing-art tile, if it names one. + */ +function writeTestTileset(string $root, string $id = 'home', ?array $sheets = null, array $pieces = [], ?int $missingArt = null, + ?array $shadows = null): void +{ + $sheets ??= ['A2' => 'Graphics/Tilesets/Home_A2.png', 'B' => 'Graphics/Tilesets/Home_B.png']; + writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24); + writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_B.png', 32, 32); + if (! is_dir($root . '/assets/Data/Tilesets')) { + mkdir($root . '/assets/Data/Tilesets', 0o777, true); + } + $data = ['name' => ucfirst($id), 'sheets' => $sheets] + ($pieces === [] ? [] : ['pieces' => $pieces]) + + ($missingArt === null ? [] : ['missingArt' => $missingArt]) + ($shadows === null ? [] : ['shadows' => $shadows]); + file_put_contents($root . "/assets/Data/Tilesets/{$id}.php", " [],", "'events' => [], 'tileset' => 'home',", (string) file_get_contents($data))); + writeTileLayer($directory, '01.floor.tiles.php', $ragged ? "2816 2816 2816 2816\n2816 2816" : "2816 2816 2816 2816\n2816 2816 2816 2816", + "// Painted in the GUI editor.\n"); + writeTileLayer($directory, '02.decor.tiles.php', $ragged ? "0 5 0 0\n0 5" : "0 5 0 0\n0 0 0 5"); + + return $root; +} + +/** + * The tile identities of one tile layer, read as the Engine reads them. + * + * @return list> + */ +function readTileRows(string $path): array +{ + return new Ichiloto\Engine\Field\MapTileLayer('layer', 0, $path, MapGridSource::readFile($path))->readTiles(); +} diff --git a/tests/Support/MapLandmarkFixtures.php b/tests/Support/MapLandmarkFixtures.php new file mode 100644 index 00000000..3e8ea6bf --- /dev/null +++ b/tests/Support/MapLandmarkFixtures.php @@ -0,0 +1,77 @@ + [],", << [ + 'T' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 0]]], + 'E' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\DialogueEventTrigger', 'data' => []], + ], + // Map-owned presentation stays authored. + 'worldObjects' => [['id' => 'old-tower', 'anchor' => ['x' => 1, 'y' => 1], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]], + {$landmarks} +PHP, (string) file_get_contents($file))); + + return [$root, $file, EditorSession::open($root)]; +} + +/** The map inspector row whose key has these members, read fresh. */ +function findLandmarkRow(EditorSession $session, array $key, ?string $marker = null, string $mapId = 'test-map'): array +{ + $rows = $session->readInspector($mapId, $marker)['rows']; + return array_find($rows, static fn(array $row): bool => array_intersect_assoc($key, $row['key'] ?? []) === $key && array_diff_key($key, $row['key'] ?? []) === []) + ?? throw new RuntimeException('Missing inspector row ' . json_encode($key)); +} + +function applyLandmarkRow(EditorSession $session, array $key, string $value, ?string $marker = null, string $mapId = 'test-map'): array +{ + $row = findLandmarkRow($session, $key, $marker, $mapId); + return $session->applyInspector($mapId, $session->readMap($mapId)['revision'], $row['key'], $value); +} + +/** The map's declarations as the Editor holds them, unsaved edits included. */ +function readLandmarkEntries(EditorSession $session, string $mapId = 'test-map'): array +{ + return Ichiloto\Editor\Field\MapLandmarks::readMap(getSessionMap($session, $mapId))->entries; +} + +function getSessionMap(EditorSession $session, string $mapId = 'test-map'): Ichiloto\Editor\ProjectMap +{ + return new ReflectionMethod(EditorSession::class, 'requireMap')->invoke($session, $mapId); +} + +/** + * The landmark project with a second, global map "world" whose transfer T leads into test-map and is declared as + * the landmark town-gate, and the session over both. + * + * @return array{string, string, EditorSession} + */ +function createAtlasProject(string $testMapAtlas = ''): array +{ + [$root, $file] = createLandmarkProject($testMapAtlas); + $world = $root . '/assets/Maps/world'; + copyDirectoryRecursively($root . '/assets/Maps/test-map', $world); + rename($world . '/test-map.data.php', $world . '/world.data.php'); + rename($world . '/test-map.event.php', $world . '/world.event.php'); + $source = str_replace(["'name' => 'Test Map'", "// Map-owned presentation stays authored.\n"], + ["'name' => 'World', 'atlas' => ['scope' => 'global'], 'landmarks' => [['id' => 'town-gate', 'kind' => 'entrance', 'anchor' => ['event' => 'T']]]", ''], + (string) file_get_contents($world . '/world.data.php')); + file_put_contents($world . '/world.data.php', $source); + + return [$root, $file, EditorSession::open($root)]; +} diff --git a/tests/Support/MapLineFixtures.php b/tests/Support/MapLineFixtures.php new file mode 100644 index 00000000..46092e61 --- /dev/null +++ b/tests/Support/MapLineFixtures.php @@ -0,0 +1,195 @@ + "######\n#....#\n#.~~.#\n#....#\n######", + 'layers/02.detail.deco.php' => " \n d \n \n d \n ", + 'home.event.php' => " \n W \n \n D S \n ", + ]; + foreach ($grids as $file => $text) { + file_put_contents($home . '/' . $file, MapGridSource::buildSource($text, 'AUTHORED', "// keep {$file}\n")); + } + writeTileLayer($home, '01.floor.tiles.php', implode("\n", array_map( + static fn(int $row): string => implode(' ', array_fill(0, 6, $row === 2 ? '5' : '2816')), range(0, 4))), + "// Painted in the GUI editor.\n"); + file_put_contents($home . '/home.data.php', <<<'PHP' + 'Home', + 'region' => '', + 'tileset' => 'home', + 'station' => ['x' => 3, 'y' => 3], + 'tileLayers' => ['floor' => ['offset' => [0, 3]]], + 'npcs' => [ + ['id' => 'cat', 'name' => 'Cat', 'sprite' => 'c', 'x' => 2, 'y' => 3, + 'movement' => 'wander', 'wanderArea' => ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 3]], + ['id' => 'mother', 'name' => 'Mother', 'sprite' => '@', 'x' => 4, 'y' => 0, 'script' => [ + ['type' => 'move_player', 'x' => 1, 'y' => 4], + ['type' => 'transfer', 'map' => 'town', 'x' => 2, 'y' => 3], + ['type' => 'move_player', 'x' => 3, 'y' => 3], + ]], + ['id' => 'lamp', 'name' => 'Lamp', 'sprite' => 'i', 'x' => 1, 'y' => 2 + 1], + ], + 'events' => [ + 'D' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger', + 'data' => ['spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South'], 'cost' => 0], + ], + 'W' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'home-only'], + ], + 'S' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], + ], + ], +]; +PHP); + + $town = $root . '/assets/Maps/town'; + mkdir($town, 0o777, true); + file_put_contents($town . '/town.map.php', MapGridSource::buildSource("######\n# #\n######", 'ICHILOTO_MAP')); + file_put_contents($town . '/town.event.php', MapGridSource::buildSource(" AB S \n \n ", 'ICHILOTO_EVENT_MAP')); + file_put_contents($town . '/town.data.php', <<<'PHP' + 'Town', + 'region' => '', + 'npcs' => [ + ['id' => 'guard', 'name' => 'Guard', 'sprite' => 'g', 'x' => 1, 'y' => 1, 'script' => [ + ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3], + ['type' => 'move_player', 'x' => 2, 'y' => 4], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 4]]], + ]], + ], + 'events' => [ + 'A' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], + ], + 'B' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 4], 'spawnSprite' => ['North']], + ], + 'S' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'shared'], + ], + ], +]; +PHP); + + file_put_contents($root . '/assets/Events/home-only.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 4], + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 1.0], +]; +PHP); + file_put_contents($root . '/assets/Events/shared.php', <<<'PHP' + 'move_player', 'x' => 3, 'y' => 4], +]; +PHP); + file_put_contents($root . '/assets/Data/system.php', <<<'PHP' + [ + 'player' => ['destinationMap' => 'home', 'spawnPoint' => ['x' => 1, 'y' => 4], 'spawnSprite' => ['South']], + ], +]; +PHP); + + return $root; +} + +/** Plans an insertion on the fixture's home map. */ +function planLineInsertion(string $root, string $axis, int $at, int $count): Ichiloto\Editor\Maps\MapLinePlan +{ + return MapLinePlanner::planInsertion($root, 'home', $axis, $at, $count); +} + +/** @return list Each hand edit as `file where`. */ +function describeMapLineHandEdits(Ichiloto\Editor\Maps\MapLinePlan $plan): array +{ + return array_map(static fn(array $edit): string => $edit['file'] . ' ' . $edit['where'], $plan->handEdits); +} + +/** @return list The messages validation reports for the save manifest. */ +function describeSaveCompatibilityIssues(string $root): array +{ + return array_values(array_map(static fn($issue): string => $issue->message, array_filter(validateProject($root), + static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/save-compatibility.php')))); +} + +/** The editor over the fixture with `home` selected and the cursor on the canvas. */ +function mapLineEditor(string $root): Editor +{ + $editor = deletionEditor($root); + setEditorProperty($editor, 'focusedPane', 'canvas'); + expect(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->mapId)->toBe('home'); + + return $editor; +} + +function openMapLinePaletteItem(Editor $editor, string $label): void +{ + $items = callEditorMethod($editor, 'buildPaletteItems'); + $item = array_values(array_filter($items, static fn($item) => $item->label === $label))[0] ?? null; + expect($item)->not->toBeNull(); + ($item->action)(); +} + +/** + * The map line project with a collision dictionary, so the player's reach on + * `home` is known. `g` paints walkable encounter grass and `P` a save point. + */ +function mapLineRemovalProject(): string +{ + $root = mapLineProject(); + file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::SOLID, '.' => CollisionType::NONE, '~' => CollisionType::SOLID, ' ' => CollisionType::NONE," + . " 'g' => CollisionType::ENCOUNTER, 'P' => CollisionType::SAVE_POINT];\n"); + // The start and the town arrival stand in a gap in the bottom wall, so the floor is reachable from them. + file_put_contents($root . '/assets/Maps/home/layers/01.terrain.map.php', MapGridSource::buildSource( + "######\n#....#\n#.~~.#\n#....#\n#..###", 'AUTHORED', "// keep layers/01.terrain.map.php\n")); + // The game reads a bed only with its question. + $home = $root . '/assets/Maps/home/home.data.php'; + file_put_contents($home, str_replace("'cost' => 0]", "'cost' => 0, 'confirmDialogue' => ['text' => 'Rest?']]", (string) file_get_contents($home))); + + return $root; +} + +/** Plans a removal on the fixture's home map. */ +function planLineRemoval(string $root, string $axis, int $at, int $count, ?array $landing = null): Ichiloto\Editor\Maps\MapLinePlan +{ + return MapLinePlanner::planRemoval($root, 'home', $axis, $at, $count, $landing); +} diff --git a/tests/Support/NpcFixtures.php b/tests/Support/NpcFixtures.php new file mode 100644 index 00000000..80f6ddbe --- /dev/null +++ b/tests/Support/NpcFixtures.php @@ -0,0 +1,70 @@ + $npcs The npcs block. + */ +function npcSessionProject(array $npcs): string +{ + $root = makeTemporaryProject('ichiloto-npc-session-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = $npcs; + file_put_contents($path, "readMap('test-map')['npcs'], 'id'); +} + +/** The row with a field id in an NPC read. */ +function npcSessionRow(array $read, string $fieldId): array +{ + return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $fieldId) + ?? throw new RuntimeException("No NPC row {$fieldId}."); +} + +/** + * Writes reusable event scripts into a project, each as the bare command list + * `assets/Events/.php` returns. + * + * @param array>> $scripts Commands by script id. + */ +function writeNpcEventScripts(string $root, array $scripts): void +{ + foreach ($scripts as $id => $commands) { + file_put_contents($root . '/assets/Events/' . $id . '.php', ", 1: array, 2: array>>}> + * Bob's extra fields, the map's events and the event scripts, per case. + */ +function npcEventScriptChains(): array +{ + $startA = [['type' => 'common_event', 'id' => 'a']]; + $route = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'mara', 'steps' => [['direction' => 'north']]]; + $event = static fn(array $data): array => ['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', 'data' => [...$data, 'mode' => 'action']]]; + $chain = ['a' => [['type' => 'common_event', 'id' => 'b']], 'b' => [$route]]; + + return [ + 'an NPC script through a to b' => [['script' => $startA], [], $chain], + 'an event inline script through a to b' => [[], $event(['script' => $startA]), $chain], + 'an event script id through a to b' => [[], $event(['scriptId' => 'a']), $chain], + 'a cycle between a and b' => [['script' => $startA], [], [...$chain, 'b' => [['type' => 'common_event', 'id' => 'a'], $route]]], + ]; +} diff --git a/tests/Support/PhysicalOccupancyFixtures.php b/tests/Support/PhysicalOccupancyFixtures.php new file mode 100644 index 00000000..663ac11e --- /dev/null +++ b/tests/Support/PhysicalOccupancyFixtures.php @@ -0,0 +1,38 @@ + str_repeat(' ', strlen($row)), explode("\n", $glyphs))); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($events, 'EVENT')); + $rows = array_map(static fn(string $row): array => array_fill(0, strlen($row), CollisionType::NONE), explode("\n", $glyphs)); + file_put_contents($directory . '/test-map.data.php', $source ?? ' 'Physical fixture', 'events' => [], 'occupancy' => $rows], true) . ';'); + + return $root; +} + +function getPhysicalOccupancyMap(EditorSession $session): \Ichiloto\Editor\ProjectMap +{ + return new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0); +} + +function createPhysicalOccupancyRpc(string $root): Closure +{ + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + + return $request; +} diff --git a/tests/Support/TerrainKindFixtures.php b/tests/Support/TerrainKindFixtures.php new file mode 100644 index 00000000..5c62fced --- /dev/null +++ b/tests/Support/TerrainKindFixtures.php @@ -0,0 +1,38 @@ + ['name' => 'Meadow', 'layer' => 'terrain', 'glyph' => '.', 'art' => ['layer' => 'floor', 'tile' => 2816], + 'passage' => 'walk', 'encounters' => 'meadow', 'atlas' => 'ground'], + 'path' => ['name' => 'Path', 'layer' => 'terrain', 'glyph' => '=', 'art' => ['layer' => 'floor', 'tile' => 2864], + 'passage' => 'walk', 'atlas' => 'road'], + 'grove' => ['name' => 'Grove', 'layer' => 'terrain', 'glyph' => '♣', 'ground' => 'meadow', + 'art' => ['layer' => 'canopy', 'tile' => 2912], 'passage' => 'walk', 'encounters' => 'grove', 'atlas' => 'vegetation'], + 'peaks' => ['name' => 'Peaks', 'layer' => 'terrain', 'glyph' => '^', 'ground' => 'meadow', + 'art' => ['awaiting' => true], 'passage' => 'solid', 'atlas' => 'highland'], + ]; + file_put_contents($path, "|null $rows Another 12 x 8 layout: `#` solid, `g` walkable encounter grass, anything else floor. + */ +function createTreeBrushProject(bool $declared = true, ?array $rows = null): string +{ + $root = makeTemporaryProject('editor-tree-brush-'); + $directory = $root . '/assets/Maps/test-map'; + foreach (glob($directory . '/*') ?: [] as $file) { + is_file($file) && unlink($file); + } + mkdir($directory . '/layers', 0o777, true); + $rows ??= ['############', '#..........#', '#.....#....#', '#.....#....#', '#.....#....#', '#.....#....#', '#..........#', '############']; + file_put_contents($directory . '/layers/01.terrain.map.php', MapGridSource::buildSource(implode("\n", $rows), 'MAP')); + $events = array_fill(0, 8, str_repeat(' ', 12)); + $events[2][9] = 'E'; + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(implode("\n", $events), 'EVENTS')); + $occupancy = array_map(static fn(string $row): array => array_map( + static fn(string $glyph): CollisionType => match ($glyph) { + '#' => CollisionType::SOLID, + 'g' => CollisionType::ENCOUNTER, + default => CollisionType::NONE, + }, str_split($row)), $rows); + $data = ['name' => 'Forest', 'region' => '', 'events' => ['E' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'script' => [['type' => 'show_text', 'text' => 'Hello']]]]], + 'npcs' => [['id' => 'ranger', 'name' => 'Ranger', 'sprite' => 'r', 'x' => 2, 'y' => 2]]]; + if ($declared) { + $data['occupancy'] = $occupancy; + } + file_put_contents($directory . '/test-map.data.php', ' [ + 'oak' => ['name' => 'Oak', 'pivot' => ['x' => 0.5, 'y' => 0.9375], + 'sprites2d' => ['asset' => 'Graphics/Objects/Oak.png', 'layer' => 100, 'cells' => ['width' => 3, 'height' => 4]], + 'occupancyStamp' => [['x' => 0, 'y' => 0, 'type' => {$solid}]]], + 'pair' => ['name' => 'Pair', 'pivot' => ['x' => 0.375, 'y' => 0.95], + 'sprites2d' => ['asset' => 'Graphics/Objects/Pair.png', 'layer' => 100, 'cells' => ['width' => 4, 'height' => 5]], + 'occupancyStamp' => [['x' => 0, 'y' => 0, 'type' => {$solid}], ['x' => 2, 'y' => 0, 'type' => {$solid}]]], + 'counter' => ['name' => 'Counter', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Objects/Counter.png']], +]]; +PHP); + + return $root; +} + +/** @return list Every cell of a rectangle. */ +function listRectangleCells(int $x0, int $y0, int $x1, int $y1): array +{ + $cells = []; + for ($y = $y0; $y <= $y1; $y++) { + for ($x = $x0; $x <= $x1; $x++) { + $cells[] = [$x, $y]; + } + } + + return $cells; +} diff --git a/tests/Support/ValidationFixtures.php b/tests/Support/ValidationFixtures.php index 35b14a31..29a22c33 100644 --- a/tests/Support/ValidationFixtures.php +++ b/tests/Support/ValidationFixtures.php @@ -22,6 +22,18 @@ function validateProject(string $root): array return new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)); } +/** + * Rewrites the sample map's legacy `` row in the current style, so the + * Engine can load it: the Engine knows no `blue` style, reads the tag as + * glyphs, and refuses the map's event layer as narrower than its tiles. + * Tests of legacy-tag preservation keep the fixture as it is. + */ +function writeLoadableSampleMap(string $root): void +{ + $path = $root . '/assets/Maps/test-map/test-map.map.php'; + file_put_contents($path, str_replace(['', ''], ['', ''], (string) file_get_contents($path))); +} + /** * Returns the issues whose message contains the given text. * diff --git a/tests/Support/battle-entry-parity-runner.php b/tests/Support/battle-entry-parity-runner.php index ec4ce54d..98d23a17 100644 --- a/tests/Support/battle-entry-parity-runner.php +++ b/tests/Support/battle-entry-parity-runner.php @@ -11,7 +11,7 @@ * source checkout. * * argv: [1] engine source root, [2] rule file path, [3] actors directory - * (empty string for none). Prints one JSON object: {ok, order} on success, + * (empty string for none). Prints one JSON object: {ok, order, diagnostics} on success, * {ok: false, phase, class, error} on refusal. */ @@ -55,6 +55,7 @@ $catalog = Ichiloto\Engine\Battle\Entry\BattleEntryRuleCatalog::fromProject($ruleFile, $store); echo json_encode([ 'ok' => true, + 'diagnostics' => $catalog->getDiagnostics(), 'order' => array_map( static fn(Ichiloto\Engine\Battle\Entry\BattleEntryRule $rule): string => $rule->id, $catalog->rules(), diff --git a/tests/Support/map-bgm-parity-runner.php b/tests/Support/map-bgm-parity-runner.php index 4177f577..432acd12 100644 --- a/tests/Support/map-bgm-parity-runner.php +++ b/tests/Support/map-bgm-parity-runner.php @@ -48,6 +48,16 @@ // a real game. $projectRoot = sys_get_temp_dir() . '/ichiloto-bgm-parity-' . bin2hex(random_bytes(4)); mkdir($projectRoot . '/assets/Data', 0755, true); +// This subprocess has no suite to clean up after it: it removes its project +// itself when it ends, normally or not. +register_shutdown_function(static function () use ($projectRoot): void { + chdir(sys_get_temp_dir()); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($projectRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST) as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($projectRoot); +}); file_put_contents($projectRoot . '/assets/Data/items.php', <<<'PHP' valuesFor('attack_skills'))->toBe(['Attack']); +}); + +it('authors an actor\'s attack skill as a picked reference, the built-in attack when unset', function () { + $root = attackSkillProject(); + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); + callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); + $row = static fn(): array => array_find(callEditorMethod($editor, 'getDatabaseSettingsFields'), + static fn(array $field): bool => ($field['field'] ?? null) === 'attackSkill'); + + expect($row())->toMatchArray(['label' => 'Attack Skill', 'value' => '(Built-in attack)', 'reference' => 'attack_skills', 'allowsNone' => true]) + ->and($row())->not->toHaveKey('control'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $row(), 'Attack'); + $actors = getEditorProperty($editor, 'workspace')->actorDatabase; + $actors->save(); + $path = glob($root . '/assets/Data/Actors/*.php')[0]; + + expect($row()['value'])->toBe('Attack') + ->and((require $path)['data']['attackSkill'])->toBe('Attack'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $row(), ''); + $actors->save(); + + expect((require $path)['data'])->not->toHaveKey('attackSkill'); +}); + +it('reports an attack skill that is not a basic skill usable in battle', function () { + $root = attackSkillProject(); + $workspace = ProjectWorkspace::fromProject($root); + $workspace->actorDatabase->setField(0, 'attackSkill', 'Fire'); + $issues = array_map(static fn(object $issue): string => $issue->message, new \Ichiloto\Editor\Validation\ProjectValidator()->validate($workspace)); + + expect($issues)->toContain('Its attack skill "Fire" is not a basic skill usable in battle.'); +}); diff --git a/tests/Unit/ActorAttackStyleTest.php b/tests/Unit/ActorAttackStyleTest.php new file mode 100644 index 00000000..a6df91ec --- /dev/null +++ b/tests/Unit/ActorAttackStyleTest.php @@ -0,0 +1,96 @@ + 120, 'height' => 40]); + setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('actors')); + + return $editor; +} + +/** @return array */ +function attackStyleRow(Editor $editor): array +{ + return array_find(callEditorMethod($editor, 'getDatabaseSettingsFields'), + static fn(array $field): bool => ($field['field'] ?? null) === 'attackStyle') + ?? throw new RuntimeException('The Actors database offers no Attack Style row.'); +} + +it('offers unarmed and every Engine weapon type, and writes the Engine spelling over the authored actor', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $before = require $path; + $editor = attackStyleEditor($root); + $row = attackStyleRow($editor); + + expect($row['options'])->toBe(['unarmed', ...array_map(static fn(WeaponType $type): string => $type->value, WeaponType::cases())]) + ->and($row['value'])->toBe('Unarmed'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $row, 'sword'); + getEditorProperty($editor, 'workspace')->actorDatabase->save(); + $saved = require $path; + + expect($saved['data']['attackStyle'])->toBe('Sword') + ->and(array_diff_key($saved['data'], ['attackStyle' => true]))->toBe($before['data']) + ->and(attackStyleRow($editor)['value'])->toBe('Sword'); +}); + +it('removes the key for unarmed, undoes and redoes, and refuses a weapon type the Engine does not have', function () { + $root = makeTemporaryProject(); + $editor = attackStyleEditor($root); + /** @var CommandHistory $history */ + $history = getEditorProperty($editor, 'history'); + $actor = getEditorProperty($editor, 'workspace')->actorDatabase->getActorByIndex(0); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', attackStyleRow($editor), 'Dagger'); + expect($actor->getAttackStyle())->toBe('Dagger'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', attackStyleRow($editor), 'unarmed'); + expect($actor->getData())->not->toHaveKey('attackStyle'); + + $history->undo(); + expect($actor->getAttackStyle())->toBe('Dagger'); + $history->undo(); + expect($actor->getData())->not->toHaveKey('attackStyle'); + $history->redo(); + expect($actor->getAttackStyle())->toBe('Dagger'); + + expect(fn() => $actor->setField('attackStyle', 'Lance'))->toThrow(RuntimeException::class, 'The Engine has no weapon type "Lance".') + ->and($actor->getAttackStyle())->toBe('Dagger'); +}); + +it('reports a written attack style the Engine does not know, and none for a known one in any case', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $write = static function (string $style) use ($path): void { + $actor = require $path; + $actor['data']['attackStyle'] = $style; + file_put_contents($path, " array_map(static fn($issue): string => $issue->message, + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root))); + + $write('Lance'); + expect($messages())->toContain('Its attack style "Lance" is not a weapon type the Engine knows.'); + + $write('staff'); + expect(implode("\n", $messages()))->not->toContain('attack style') + ->and(ProjectActorDatabase::fromProject($root)->getActorByIndex(0)->getAttackStyle())->toBe('staff'); +}); diff --git a/tests/Unit/ActorClassFieldTest.php b/tests/Unit/ActorClassFieldTest.php index c3dd832f..e5f250e8 100644 --- a/tests/Unit/ActorClassFieldTest.php +++ b/tests/Unit/ActorClassFieldTest.php @@ -30,9 +30,7 @@ function actorClassEditor(string $root): Editor try { $editor = actorClassEditor($root); - expect(callEditorMethod($editor, 'getActorClassOptions'))->toBe(['none', 'Vanguard', 'Oracle']); - - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $classField = null; foreach ($fields as $field) { @@ -58,7 +56,7 @@ function actorClassEditor(string $root): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $classIndex = array_search('class', array_column($fields, 'field'), true); setEditorProperty($editor, 'databaseSelectedSettingIndex', $classIndex); @@ -92,7 +90,7 @@ function actorClassEditor(string $root): Editor /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $fields = callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); setEditorProperty($editor, 'databaseSelectedSettingIndex', array_search('class', array_column($fields, 'field'), true)); callEditorMethod($editor, 'dispatchInput', "\033[C"); callEditorMethod($editor, 'dispatchInput', "\x13"); @@ -165,3 +163,22 @@ function actorClassEditor(string $root): Editor removeDirectoryRecursively($root); } }); + +it('saves a class cleared and chosen again where the file already holds it', function () { + $root = makeTemporaryProject(); + + try { + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace(" 'level' => 1,", " 'class' => 'Vanguard',\n 'level' => 1,", (string) file_get_contents($path))); + $actor = ProjectActor::fromFile($path); + + $actor->setField('class', 'none'); + $actor->setField('class', 'Oracle'); + $actor->save(); + + expect((string) file_get_contents($path))->toContain(" 'class' => 'Oracle',\n 'level' => 1,") + ->and((require $path)['data']['class'])->toBe('Oracle'); + } finally { + removeDirectoryRecursively($root); + } +}); diff --git a/tests/Unit/ActorIdentityMigrationTest.php b/tests/Unit/ActorIdentityMigrationTest.php new file mode 100644 index 00000000..ebe571c6 --- /dev/null +++ b/tests/Unit/ActorIdentityMigrationTest.php @@ -0,0 +1,453 @@ + Character::class, + 'data' => [ + // Keep this name comment. + 'name' => 'Kaelion', + 'description' => $profile, + 'level' => 1 + 2, + ], +]; +PHP; + file_put_contents($path, $source); + return [$root, $path, $source]; +} + +it('shares an explicit source preserving batch repair and keeps ids stable after later rename', function () { + [$root, $path, $source] = createLegacyActorProject(); + $before = sourceHashTree($root); + $database = ProjectActorDatabase::fromProject($root); + expect(ActorIdentityMigration::getPendingActors($database))->toHaveCount(1) + ->and(sourceHashTree($root))->toBe($before); + expect(ActorIdentityMigration::migrateProject($root))->toBe([$path, $root . '/assets/Data/Skits/breakfast-banter.php']); + $written = file_get_contents($path); + expect($written)->toContain("'id' => 'Kaelion'", '// Keep this authored header.', '// Keep this name comment.', "'level' => 1 + 2", "'description' => \$profile") + ->and(ActorIdentityMigration::migrateProject($root))->toBe([]); + $database = ProjectActorDatabase::fromProject($root); + $actor = $database->getActors()[0]; + $actor->setField('name', 'Kaelion Renamed'); + $database->save(); + expect(file_get_contents($path))->toBe(str_replace("'name' => 'Kaelion'", "'name' => 'Kaelion Renamed'", $written)); + $store = $database->createActorStore(); + expect($store->require('Kaelion', 'restoring identity')->id)->toBe('Kaelion') + ->and($store->has('Kaelion Renamed'))->toBeFalse(); +}); + +it('supports source exact repair undo and redo across saves without allowing another identity', function () { + [$root, $path, $source] = createLegacyActorProject(); + $database = ProjectActorDatabase::fromProject($root); + $actor = $database->getActors()[0]; + $before = $actor->getData(); + ActorIdentityMigration::freezeCurrentName($database, $actor); + $after = $actor->getData(); + expect(file_get_contents($path))->toBe($source); + $database->save(); + $repaired = file_get_contents($path); + $actor->restoreData($before); + $database->save(); + expect(file_get_contents($path))->toBe($source); + $actor->restoreData($after); + $database->save(); + expect(file_get_contents($path))->toBe($repaired); + expect(fn() => $actor->setField('id', 'another'))->toThrow(RuntimeException::class, 'permanent'); +}); + +it('refuses unsupported source anywhere in the batch before writing any actor', function () { + [$root, $path] = createLegacyActorProject(); + $other = dirname($path) . '/Other.php'; + file_put_contents($other, " ['name' => 'Other']]; return \$actor;"); + $before = sourceHashTree($root); + // The public migration supplies file context without discarding the source parser cause. + try { + ActorIdentityMigration::migrateProject($root); + $this->fail('Unsupported actor source must be refused.'); + } catch (RuntimeException $failure) { + expect($failure->getPrevious())->toBeInstanceOf(SourceUnreadable::class) + ->and(substr_count($failure->getMessage(), $other))->toBe(1); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses a data block the file builds in code without flattening it', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " 'Kaelion'], []); return ['data' => \$data];"); + $before = sourceHashTree($root); + try { + ActorIdentityMigration::migrateProject($root); + $this->fail('Actor data built in code must be refused.'); + } catch (RuntimeException $failure) { + expect($failure->getPrevious())->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal::class) + ->and($failure->getMessage())->toContain('refusing to flatten') + ->and(substr_count($failure->getMessage(), $path))->toBe(1); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('migrates a data block written in a variable as data where the variable is written', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " 'Kaelion']; return ['data' => \$data];"); + ActorIdentityMigration::migrateProject($root); + + expect((string) file_get_contents($path))->toContain("return ['data' => \$data];") + ->and((require $path)['data'])->toBe(['name' => 'Kaelion', 'id' => 'Kaelion']); +}); + +it('rolls back all staged actors when readback cannot reproduce the authored value', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents(dirname($path) . '/Other.php', " ['name' => 'Other', 'description' => __FILE__]];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(RuntimeException::class, 'would not read back'); + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses legacy identity collisions and never repairs malformed explicit ids', function () { + [$root, $path] = createLegacyActorProject(); + $other = dirname($path) . '/Other.php'; + file_put_contents($other, " ['id' => 'Kaelion', 'name' => 'Different']];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::migrateProject($root))->toThrow(RuntimeException::class, 'conflicts'); + expect(sourceHashTree($root))->toBe($before); + foreach (['', null, 42] as $invalidId) { + file_put_contents($path, ' ['id' => $invalidId, 'name' => 'Kaelion']], true) . ';'); + $database = ProjectActorDatabase::fromProject($root); + expect(ActorIdentityMigration::getPendingActors($database))->toBe([]); + expect(fn() => $database->getActors()[0]->setField('id', 'Kaelion'))->toThrow(RuntimeException::class, 'freeze'); + } +}); + +it('allows duplicate display names and a display name matching another id in the authoring registry', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'one', 'name' => 'two']];"); + file_put_contents(dirname($path) . '/Second.php', " ['id' => 'two', 'name' => 'Shared']];"); + file_put_contents(dirname($path) . '/Third.php', " ['id' => 'three', 'name' => 'Shared']];"); + $store = ProjectActorDatabase::fromProject($root)->createActorStore(); + expect($store->get('two')->id)->toBe('two')->and($store->has('Shared'))->toBeFalse()->and($store->has('Kaelion'))->toBeFalse(); +}); + +it('refuses outside source changes and changed expressions rather than flattening the actor', function () { + [$root, $path, $source] = createLegacyActorProject(); + ActorIdentityMigration::migrateProject($root); + $database = ProjectActorDatabase::fromProject($root); + $actor = $database->getActors()[0]; + $before = file_get_contents($path); + $actor->setField('level', 9); + expect(fn() => $database->save())->toThrow(RuntimeException::class, 'expression'); + expect(file_get_contents($path))->toBe($before); + $actor->setField('level', 3); + $actor->setField('name', 'Renamed'); + file_put_contents($path, $before . "\n// Author's later edit\n"); + expect(fn() => $database->save())->toThrow(RuntimeException::class, 'changed outside'); + expect(file_get_contents($path))->toBe($before . "\n// Author's later edit\n"); +}); + +it('plans every actor reference without writes and atomically applies undoes and redoes exact source', function () { + [$root, $actorPath] = createLegacyActorProject(); + // Already-frozen IDs still need repair of the original name and file references. + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + $sources = [ + 'assets/Data/system.php' => " [/* member */ 'Kaelion'], 'note' => 'Kaelion'];", + 'assets/Data/Skits/old.php' => " [['actor' => 'Kaelion', 'text' => 'Kaelion'], ['speaker' => 'Kaelion', 'text' => 'Hello'], ['speaker' => 'Stranger', 'text' => 'Kaelion']]];", + 'assets/Data/battle-entry-rules.php' => " [['actors' => [['actor' => 'Kaelion']], 'effects' => [['type' => 'stat_stage', 'actor' => 'Kaelion']]]]];", + 'assets/Events/actor-reference.php' => " 'sequence', 'commands' => [['type' => 'stage_actor', 'actor' => ['id' => 'Kaelion']]]], ['type' => 'show_actor', 'actorId' => 'Kaelion'], ['type' => 'text', 'name' => 'Kaelion', 'text' => 'Hello']];", + 'assets/Data/troops.php' => " 'Kaelion', 'enemies' => [['enemy' => 'Kaelion']], 'events' => [['type' => 'text', 'name' => 'Kaelion', 'text' => 'Hello']]]];", + 'assets/Data/Presentation/dialogue.php' => " ['emotions' => ['Neutral' => 'Kaelion.png']]]);", + 'assets/Data/Presentation/battle.php' => " new BattlerArtwork('hero.png', 1, 1, 0, 0)], []);", + 'assets/Data/Presentation/menus.php' => " [/* keep */ 'Kaelion' => 'Kaelion.png']];", + ]; + foreach ($sources as $relative => $source) { + if (! is_dir(dirname($root . '/' . $relative))) { mkdir(dirname($root . '/' . $relative), 0777, true); } + file_put_contents($root . '/' . $relative, $source); + } + $before = sourceHashTree($root); + $plan = ActorIdentityMigration::planProject($root); + expect($plan->getChangedPaths())->not->toContain($actorPath)->and(sourceHashTree($root))->toBe($before); + foreach (array_keys($sources) as $relative) { + if (in_array($relative, ['assets/Events/actor-reference.php', 'assets/Data/troops.php'], true)) { + expect($plan->getChangedPaths())->not->toContain($root . '/' . $relative); + } else { expect($plan->getChangedPaths())->toContain($root . '/' . $relative); } + } + $plan->apply(); + $after = sourceHashTree($root); + expect(file_get_contents($root . '/assets/Data/system.php'))->toBe(str_replace("/* member */ 'Kaelion'", "/* member */ 'hero'", $sources['assets/Data/system.php'])) + ->and(file_get_contents($root . '/assets/Events/actor-reference.php'))->toBe($sources['assets/Events/actor-reference.php']) + ->and(file_get_contents($root . '/assets/Data/troops.php'))->toBe($sources['assets/Data/troops.php']) + ->and(file_get_contents($root . '/assets/Events/actor-reference.php'))->toContain("'actorId' => 'Kaelion'", "'id' => 'Kaelion'", "'name' => 'Kaelion'") + ->and(file_get_contents($root . '/assets/Data/Presentation/dialogue.php'))->toContain("/* portrait */ 'hero'", "'Neutral' => 'Kaelion.png'") + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]); + $plan->revert(); + expect(sourceHashTree($root))->toBe($before); + $plan->apply(); + expect(sourceHashTree($root))->toBe($after); + file_put_contents($root . '/assets/Data/system.php', $sources['assets/Data/system.php'] . '// outside edit'); + $outside = sourceHashTree($root); + expect(fn() => $plan->revert())->toThrow(RuntimeException::class, 'changed outside'); + expect(sourceHashTree($root))->toBe($outside); +}); + +it('refuses ambiguous references and key collisions before modifying any file but honours explicit ids first', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'first', 'name' => 'Shared']];"); + file_put_contents(dirname($path) . '/Other.php', " ['id' => 'second', 'name' => 'Shared']];"); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " ['Shared']];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'ambiguous'); + expect(sourceHashTree($root))->toBe($before); + file_put_contents(dirname($path) . '/Shared.php', " ['id' => 'Shared', 'name' => 'Modern']];"); + expect(ActorIdentityMigration::planProject($root)->getChangedPaths())->not->toContain($system); + mkdir($root . '/assets/Data/Presentation'); + file_put_contents($root . '/assets/Data/Presentation/menus.php', " ['Kaelion' => 'old.png', 'first' => 'new.png']];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'overwrite key'); + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses dynamic reference expressions with file-qualified diagnostics and no partial actor migration', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['name' => 'Full Name']];"); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " [\$member]];"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'system.php:') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('validates unresolved starting party and all explicit actor references against stable ids', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'hero', 'name' => 'Kaelion']];"); + file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $issues = new \Ichiloto\Editor\Validation\ProjectValidator()->validate($workspace); + expect(array_filter($issues, static fn($issue) => str_contains($issue->where, 'startingParty') && str_contains($issue->message, 'Unresolved actor')))->not->toBeEmpty(); + ActorIdentityMigration::migrateProject($root); + $issues = new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($issues)->toBe([]); +}); + +it('uses each consumer case semantics without restoring display name aliases', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'HERO', 'name' => 'Kaelion']];"); + file_put_contents($root . '/assets/Data/system.php', " [' hero ']];"); + $validator = new \Ichiloto\Editor\Validation\ActorReferenceValidator(); + expect($validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); + file_put_contents($root . '/assets/Data/Skits/case.php', " [['actor' => 'hero', 'text' => 'Hello']]];"); + $issues = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toBe('assets/Data/Skits/case.php:beats.0.actor') + ->and($issues[0]->code)->toBe(\Ichiloto\Editor\Validation\ActorReferenceValidator::UNRESOLVED_ACTOR_REFERENCE); + file_put_contents($root . '/assets/Data/system.php', " ['Kaelion']];"); + $issues = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(2); +}); + +it('keeps skit diagnostics attached to their files when sorted record positions change', function () { + [$root, $path] = createLegacyActorProject(); + file_put_contents($path, " ['id' => 'Kaelion', 'name' => 'Kaelion']];"); + $skits = $root . '/assets/Data/Skits'; + file_put_contents($skits . '/z-last-scene.php', " [['actor' => 'missing', 'text' => 'Hello']]];"); + $validator = new \Ichiloto\Editor\Validation\ActorReferenceValidator(); + $before = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + file_put_contents($skits . '/a-first-scene.php', " [['actor' => 'Kaelion', 'text' => 'Hello']]];"); + $after = $validator->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + expect($before)->toHaveCount(1)->and($after)->toEqual($before) + ->and($after[0]->where)->toBe('assets/Data/Skits/z-last-scene.php:beats.0.actor') + ->and(\Ichiloto\Editor\Validation\Issue::error('elsewhere', 'Other failure')->code)->toBeNull(); +}); + +it('repairs every actor keyed presentation reference without touching enemies or artwork resources', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + $battle = <<<'PHP' + new BattlePoseSet([], displayWidth: 90)], + // Party poses follow the same stable identity as their artwork. + actorPoses: [/* lead */ 'Kaelion' => new BattlePoseSet([], displayWidth: 120)], + arenas: [], + enemies: ['Kaelion' => new BattlerArtwork('shade.png', 1, 1, 0, 0)], + actors: ['Kaelion' => new BattlerArtwork('hero.png', 1, 1, 0, 0)], +); +PHP; + $dialogue = <<<'PHP' + ['emotions' => []]], + resources: ['Fira' => ['emotions' => []]], + speakers: ['Kael' => 'Kaelion', 'Fira Nel' => 'Fira'], +); +PHP; + file_put_contents($root . '/assets/Data/Presentation/battle.php', $battle); + file_put_contents($root . '/assets/Data/Presentation/dialogue.php', $dialogue); + ActorIdentityMigration::migrateProject($root); + expect(file_get_contents($root . '/assets/Data/Presentation/battle.php'))->toBe(str_replace( + ["/* lead */ 'Kaelion'", "actors: ['Kaelion'"], ["/* lead */ 'hero'", "actors: ['hero'"], $battle)) + ->and(file_get_contents($root . '/assets/Data/Presentation/dialogue.php'))->toBe(str_replace( + ["actors: ['Kaelion'", "'Kael' => 'Kaelion'"], ["actors: ['hero'", "'Kael' => 'hero'"], $dialogue)) + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]) + ->and(new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); +}); + +it('refuses a variable backed presentation argument rather than stranding or flattening its actor keys', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + file_put_contents($root . '/assets/Data/Presentation/battle.php', <<<'PHP' + new BattlePoseSet([], displayWidth: 120)]; +return new BattlePresentationCatalog([], [], [], actorPoses: $poses); +PHP); + $before = sourceHashTree($root); + try { + ActorIdentityMigration::planProject($root); + $this->fail('A variable-backed pose catalog must be refused.'); + } catch (RuntimeException $failure) { + expect($failure->getPrevious())->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal::class) + ->and($failure->getMessage())->toContain('battle.php', 'actorPoses', 'refusing to flatten'); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('repairs the battle scale reference actor and actor profiles while preserving enemies and calibration', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + $battle = <<<'PHP' + new BattlerArtwork('hero.png', 1, 1, 0, 0)], + enemies: ['Kaelion' => new BattlerArtwork('shade.png', 1, 1, 0, 0)], + scale: new BattleScale( + // The party lead is the body every battler is sized against. + referenceActorId: 'Kaelion', + referenceHeight: 210.0, + actors: ['Kaelion' => new BattlerScale(1.0, 0.82)], + enemies: ['Kaelion' => new BattlerScale(0.4, 0.6, horizontal: true)], + ), +); +PHP; + file_put_contents($root . '/assets/Data/Presentation/battle.php', $battle); + ActorIdentityMigration::migrateProject($root); + expect(file_get_contents($root . '/assets/Data/Presentation/battle.php'))->toBe(str_replace( + ["actors: ['Kaelion' => new BattlerArtwork", "referenceActorId: 'Kaelion'", "actors: ['Kaelion' => new BattlerScale"], + ["actors: ['hero' => new BattlerArtwork", "referenceActorId: 'hero'", "actors: ['hero' => new BattlerScale"], $battle)) + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]) + ->and(new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); +}); + +it('repairs battle art bound as data: actor keys and the scale reference, never enemies or image paths', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + mkdir($root . '/assets/Data/Presentation'); + $battlers = <<<'SOURCE' + ['actor' => 'Kaelion', 'height' => 150], + 'actors' => [ + 'Kaelion' => ['artwork' => ['image' => 'Graphics/Kaelion/Idle.png'], 'scale' => ['relativeSize' => 1, 'sourceSpan' => 0.7]], + ], + 'enemies' => [ + 'Kaelion' => ['artwork' => ['image' => 'Graphics/Kaelion/Shade.png']], + ], +]; +SOURCE; + file_put_contents($root . '/assets/Data/Presentation/battlers.php', $battlers); + ActorIdentityMigration::migrateProject($root); + + expect(file_get_contents($root . '/assets/Data/Presentation/battlers.php'))->toBe(str_replace( + ["'actor' => 'Kaelion'", " 'Kaelion' => ['artwork' => ['image' => 'Graphics/Kaelion/Idle.png']"], + ["'actor' => 'hero'", " 'hero' => ['artwork' => ['image' => 'Graphics/Kaelion/Idle.png']"], $battlers)) + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]); +}); + +/** A summon whose wielder policy names actors, beside the other policy fields a migration must leave alone. */ +function writeWielderSummon(string $root, string $characters): string +{ + $directory = $root . '/assets/Cutscenes/Summons/ember'; + @mkdir($directory, 0777, true); + file_put_contents($directory . '/ember.timeline.php', " 12, 'lengthFrames' => 1, 'tracks' => [], 'cues' => []];"); + $path = $directory . '/ember.data.php'; + file_put_contents($path, " 'ember', 'name' => 'Ember', 'linkedActionId' => 'Fireball',\n 'wielders' => ['mode' => 'characters', 'characters' => [{$characters}], 'tenancy' => 'exclusive']];\n"); + + return $path; +} + +it('repairs summon wielders to stable ids, source preserving, leaving mode and tenancy as they are', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion']];"); + $summon = writeWielderSummon($root, "/* lead */ 'Kaelion'"); + $before = sourceHashTree($root); + + $plan = ActorIdentityMigration::planProject($root); + expect($plan->getChangedPaths())->toContain($summon)->and(sourceHashTree($root))->toBe($before); + $plan->apply(); + + $written = (string) file_get_contents($summon); + expect($written)->toContain('// Keep this summon header.', "/* lead */ 'hero'", "'mode' => 'characters'", "'tenancy' => 'exclusive'") + ->and(ActorIdentityMigration::planProject($root)->getChangedPaths())->toBe([]) + ->and(new \Ichiloto\Editor\Validation\ActorReferenceValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)))->toBe([]); + $plan->revert(); + expect(sourceHashTree($root))->toBe($before); +}); + +it('refuses a summon wielder two actors are displayed as, and never hands one actor\'s id to another', function () { + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'first', 'name' => 'Twin']];"); + file_put_contents(dirname($actorPath) . '/Second.php', " ['id' => 'second', 'name' => 'Twin']];"); + $summon = writeWielderSummon($root, "'Twin'"); + $before = sourceHashTree($root); + expect(fn() => ActorIdentityMigration::planProject($root))->toThrow(RuntimeException::class, 'ambiguous') + ->and(sourceHashTree($root))->toBe($before); + + // An explicit id wins over another actor displayed under the same text. + file_put_contents(dirname($actorPath) . '/Third.php', " ['id' => 'Twin', 'name' => 'Modern']];"); + expect(ActorIdentityMigration::planProject($root)->getChangedPaths())->not->toContain($summon); +}); + +it('judges summon eligibility by stable id, so a display rename keeps a holder and a shared name grants nothing', function () { + $diagnostics = new \Ichiloto\Editor\Database\SummonAssignmentDiagnostics([ + 'ember' => ['id' => 'ember', 'wielders' => ['mode' => 'characters', 'characters' => ['Hero'], 'tenancy' => 'exclusive']], + ]); + [$root, $actorPath] = createLegacyActorProject(); + file_put_contents($actorPath, " ['id' => 'hero', 'name' => 'Kaelion Renamed']];"); + file_put_contents(dirname($actorPath) . '/Impostor.php', " ['id' => 'impostor', 'name' => 'Hero']];"); + file_put_contents(dirname($actorPath) . '/Legacy.php', " ['name' => 'Hero']];"); + $actors = []; + foreach (ProjectActorDatabase::fromProject($root)->getActors() as $actor) { + $actors[$actor->getName() . '/' . ($actor->getDefinitionId() ?: 'legacy')] = $actor->getRuntimeId(); + } + + expect($diagnostics->forActor($actors['Kaelion Renamed/hero'], 'Vanguard', ['ember'])[0]['problems'])->toBe([]) + ->and($diagnostics->forActor($actors['Hero/impostor'], 'Vanguard', ['ember'])[0]['problems'])->not->toBe([]) + // A legacy actor declaring no id is known by its authored name until the migration writes one. + ->and($actors['Hero/legacy'])->toBe('Hero') + ->and($diagnostics->forActor($actors['Hero/legacy'], 'Vanguard', ['ember'])[0]['problems'])->toBe([]); +}); diff --git a/tests/Unit/ActorIdentityMigrationUiTest.php b/tests/Unit/ActorIdentityMigrationUiTest.php new file mode 100644 index 00000000..847c95b0 --- /dev/null +++ b/tests/Unit/ActorIdentityMigrationUiTest.php @@ -0,0 +1,163 @@ + 'Kaelion',\R/m", '', $source); + file_put_contents($path, $source); + // This case has no legacy references to repair, so the single-file edit stays deferred. + $skit = $root . '/assets/Data/Skits/breakfast-banter.php'; + file_put_contents($skit, str_replace("'speaker' => 'Kaelion'", "'speaker' => 'Narrator'", (string) file_get_contents($skit))); + expect((require $path)['data'])->not->toHaveKey('id'); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'actors'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $index = array_search('id', array_column($fields, 'field'), true); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + $workspace = getEditorProperty($editor, 'workspace'); + $actor = $workspace->actorDatabase->getActors()[0]; + + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse() + ->and(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($actor->hasDefinitionId())->toBeFalse(); + + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'statusMessage'))->toBe('Actor identity frozen. Save the project to write it.'); + expect($actor->getDefinitionId())->toBe('Kaelion') + ->and(file_get_contents($path))->toBe($source); + $actor->save(); + expect((require $path)['data']['id'])->toBe('Kaelion'); + callEditorMethod($editor, 'performUndo'); + $actor->save(); + expect(file_get_contents($path))->toBe($source); + callEditorMethod($editor, 'performRedo'); + $actor->save(); + expect((require $path)['data']['id'])->toBe('Kaelion'); +}); + +it('repairs actor references through the palette only after confirmation and preserves migration undo redo', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace("'id' => 'Kaelion'", "'id' => 'Aria Vale'", (string) file_get_contents($path))); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " ['Kaelion']];\n"); + $originalActor = file_get_contents($path); + $originalSystem = file_get_contents($system); + $editor = deletionEditor($root); + $openRepair = static function () use ($editor): void { + $items = callEditorMethod($editor, 'buildPaletteItems'); + $item = array_values(array_filter($items, static fn($item) => $item->label === 'Repair Actor Identities and References'))[0]; + ($item->action)(); + }; + $openRepair(); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and(file_get_contents($system))->toBe($originalSystem); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect($entries[1]['description'])->toContain('Writes now, not on Save') + ->and(array_column($entries, 'label'))->toContain('assets/Data/system.php'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and(file_get_contents($system))->toBe($originalSystem); + callEditorMethod($editor, 'dispatchInput', "\033[A"); + callEditorMethod($editor, 'dispatchInput', "\033[A"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(file_get_contents($system))->toBe($originalSystem); + + $openRepair(); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect((require $system)['startingParty'])->toBe(['Aria Vale']) + ->and(file_get_contents($path))->toBe($originalActor); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(file_get_contents($system))->toBe($originalSystem); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect((require $system)['startingParty'])->toBe(['Aria Vale']); + + $migratedSystem = file_get_contents($system); + file_put_contents($system, $migratedSystem . "// External author edit.\n"); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(getEditorProperty($editor, 'statusMessage'))->toContain('changed outside') + ->and(getEditorProperty($editor, 'history')->canUndo())->toBeTrue() + ->and(file_get_contents($system))->toBe($migratedSystem . "// External author edit.\n"); + file_put_contents($system, $migratedSystem); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(file_get_contents($system))->toBe($originalSystem); +}); + +it('retains earlier saved editor commands across actor migration undo and redo', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace("'id' => 'Kaelion'", "'id' => 'Aria Vale'", (string) file_get_contents($path))); + $system = $root . '/assets/Data/system.php'; + file_put_contents($system, " ['Kaelion']];\n"); + $editor = deletionEditor($root); + $before = getEditorProperty($editor, 'workspace'); + $actor = $before->actorDatabase->getActors()[0]; + $rename = new \Ichiloto\Editor\History\GenericCommand('Rename actor', + static fn() => $actor->setField('name', 'Current Display Name'), + static fn() => $actor->setField('name', 'Kaelion'), + ); + $rename->execute(); + callEditorMethod($editor, 'recordCommand', $rename); + $actor->save(); + callEditorMethod($editor, 'openActorReferenceMigration'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\r"); + $after = getEditorProperty($editor, 'workspace'); + expect($after)->not->toBe($before) + ->and((require $system)['startingParty'])->toBe(['Aria Vale']); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(getEditorProperty($editor, 'workspace'))->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($actor->getName())->toBe('Kaelion'); + $actor->save(); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect($actor->getName())->toBe('Current Display Name'); + $actor->save(); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect(getEditorProperty($editor, 'workspace'))->toBe($after) + ->and((require $system)['startingParty'])->toBe(['Aria Vale']); +}); + +it('refuses project actor migration while unrelated edits are pending without saving or discarding them', function (): void { + $root = makeTemporaryProject(); + $editor = deletionEditor($root); + $workspace = getEditorProperty($editor, 'workspace'); + $database = $workspace->getRecordDatabase('skills'); + $index = $database->addRecord(); + $database->setField((int) $index, 'cost', '123'); + callEditorMethod($editor, 'openActorReferenceMigration'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('pending edits') + ->and($database->isDirty())->toBeTrue() + ->and(glob($root . '/assets/Data/Skills/*.php') ?: [])->toBe([]); +}); + +it('explains malformed explicit ids without offering a migration or opening input', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + file_put_contents($path, str_replace("'id' => 'Kaelion'", "'id' => ''", (string) file_get_contents($path))); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'actors'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $index = array_search('id', array_column($fields, 'field'), true); + expect($fields[$index]['editable'])->toBeFalse() + ->and($fields[$index]['displayDefault'])->toContain('Malformed explicit id') + ->and($fields[$index]['action'])->toBeNull(); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse() + ->and(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse(); +}); diff --git a/tests/Unit/ActorNaturalCompositionTest.php b/tests/Unit/ActorNaturalCompositionTest.php index b0581d0a..63d65d10 100644 --- a/tests/Unit/ActorNaturalCompositionTest.php +++ b/tests/Unit/ActorNaturalCompositionTest.php @@ -150,7 +150,7 @@ function actorWithNature(array $nature): array callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); $rowFor = static function (Editor $editor, string $field): array { - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $row) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $row) { if (($row['field'] ?? null) === $field) { return $row; } @@ -158,7 +158,7 @@ function actorWithNature(array $nature): array return []; }; - $labels = array_column(callEditorMethod($editor, 'getDatabaseActorSettingsFields'), 'label'); + $labels = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), 'label'); // Both layers are on the pane, named for what they do, with what they // come to beside them. @@ -168,7 +168,7 @@ function actorWithNature(array $nature): array ->and($rowFor($editor, 'actorNaturalAdjustments.attack')['value'])->toBe('4') ->and($rowFor($editor, 'naturalVariants.awakened.attack')['value'])->toBe('12'); - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $row) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $row) { if (($row['label'] ?? null) === ' In force') { expect($row['value'])->toContain('+16 attack'); } @@ -179,7 +179,7 @@ function actorWithNature(array $nature): array // Editing the fixed layer moves what is in force, without touching the // variant that is added to it. - callEditorMethod($editor, 'applyDatabaseFieldValue', 'actorNaturalAdjustments.attack', '10'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', array_values(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $row): bool => ($row['field'] ?? null) === 'actorNaturalAdjustments.attack'))[0], '10'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); diff --git a/tests/Unit/ActorNaturalStatsTest.php b/tests/Unit/ActorNaturalStatsTest.php index 8f6cdb1e..a9e99f6e 100644 --- a/tests/Unit/ActorNaturalStatsTest.php +++ b/tests/Unit/ActorNaturalStatsTest.php @@ -42,7 +42,7 @@ function actorEditorOn(string $root): Editor */ function actorRow(Editor $editor, string $fieldId): array { - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $field) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { if (($field['field'] ?? null) === $fieldId) { return $field; } @@ -51,25 +51,70 @@ function actorRow(Editor $editor, string $fieldId): array throw new RuntimeException(sprintf('No actor row "%s".', $fieldId)); } -it('authors a durable definition id, and says what a save resolves without one', function () { +it('keeps explicit actor identity read-only while renaming display text', function () { [$root, $database, $actor] = actorUnderTest(); - // A project that declares no id is resolved by name, which is exactly - // what strands a save when the actor is renamed. - expect($actor->hasDefinitionId())->toBeFalse() + expect($actor->hasDefinitionId())->toBeTrue() ->and($actor->getDefinitionId())->toBe('Kaelion'); $editor = actorEditorOn($root); - expect(actorRow($editor, 'id')['displayDefault'])->toContain('Kaelion (the name;'); - - $database->setField(0, 'id', 'actor.kaelion'); - - expect($database->getActors()[0]->hasDefinitionId())->toBeTrue() - ->and($database->getActors()[0]->getDefinitionId())->toBe('actor.kaelion'); + expect(actorRow($editor, 'id')['editable'])->toBeFalse(); + expect(fn() => $database->setField(0, 'id', 'actor.kaelion'))->toThrow(RuntimeException::class, 'permanent'); + expect(fn() => $database->setField(0, 'id', ''))->toThrow(RuntimeException::class, 'permanent'); // Renaming now leaves the identity where it was. $database->setField(0, 'name', 'Kaelion the Elder'); - expect($database->getActors()[0]->getDefinitionId())->toBe('actor.kaelion'); + expect($database->getActors()[0]->getDefinitionId())->toBe('Kaelion'); + $database->save(); + $reloaded = ProjectActorDatabase::fromProject($root)->getActors()[0]; + expect($reloaded->getName())->toBe('Kaelion the Elder')->and($reloaded->getDefinitionId())->toBe('Kaelion'); +}); + +it('assigns an explicit immutable id to a new actor before its first save', function () { + [$root, $database] = actorUnderTest(); + $index = $database->addActor('Liora'); + $actor = $database->getActorByIndex($index); + expect($actor->hasDefinitionId())->toBeTrue()->and($actor->getDefinitionId())->toBe('Liora'); + $database->setField($index, 'name', 'Liora Vey'); + expect($actor->getDefinitionId())->toBe('Liora'); + $database->save(); + expect((require $actor->path)['data'])->toMatchArray(['id' => 'Liora', 'name' => 'Liora Vey']); +}); + +it('flags missing explicit ids and requires repairing identity before a legacy rename', function () { + [$root] = actorUnderTest(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $payload = require $path; + unset($payload['data']['id']); + file_put_contents($path, 'actorDatabase->getActors()[0]; + $validator = new \Ichiloto\Editor\Validation\ProjectValidator(); + $issues = new ReflectionMethod($validator, 'checkActorDefinitions')->invoke($validator, $workspace); + expect(array_column($issues, 'message'))->toContain('Actor has no explicit stable id.'); + expect(fn() => $actor->setField('name', 'Renamed'))->toThrow(RuntimeException::class, 'before renaming'); + $actor->setField('id', 'Kaelion'); + $actor->setField('name', 'Renamed'); + expect($actor->getDefinitionId())->toBe('Kaelion'); +}); + +it('undoes and redoes a legacy identity repair and display rename without retargeting the actor', function () { + [$root] = actorUnderTest(); + $path = $root . '/assets/Data/Actors/Kaelion.php'; + $payload = require $path; + unset($payload['data']['id']); + file_put_contents($path, 'actorDatabase->getActors()[0]; + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, 'id'), 'Kaelion'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, 'name'), 'Kaelion the Elder'); + callEditorMethod($editor, 'performUndo'); + expect($actor->getName())->toBe('Kaelion')->and($actor->getDefinitionId())->toBe('Kaelion'); + callEditorMethod($editor, 'performUndo'); + expect($actor->hasDefinitionId())->toBeFalse()->and($actor->isDirty())->toBeFalse(); + callEditorMethod($editor, 'performRedo'); + callEditorMethod($editor, 'performRedo'); + expect($actor->getName())->toBe('Kaelion the Elder')->and($actor->getDefinitionId())->toBe('Kaelion'); }); it('authors actor-natural adjustments, keeping their sign and dropping zeroes', function () { @@ -221,7 +266,7 @@ function actorRow(Editor $editor, string $fieldId): array $database->save(); $editor = actorEditorOn($root); - $labels = array_column(callEditorMethod($editor, 'getDatabaseActorSettingsFields'), 'label'); + $labels = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), 'label'); expect($labels)->toContain('Identity') ->toContain('Nature') @@ -231,7 +276,7 @@ function actorRow(Editor $editor, string $fieldId): array // The rows edit the default variant until another is chosen. expect(actorRow($editor, 'naturalVariants.awakened.attack')['value'])->toBe('12'); - callEditorMethod($editor, 'applyDatabaseFieldValue', '__actor_variant', 'dormant'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, '__actor_variant'), 'dormant'); expect(actorRow($editor, 'naturalVariants.dormant.attack')['value'])->toBe('1'); @@ -242,6 +287,6 @@ function actorRow(Editor $editor, string $fieldId): array expect($workspace->actorDatabase->isDirty())->toBeFalse(); // Editing through the pane writes into that variant. - callEditorMethod($editor, 'applyDatabaseFieldValue', 'naturalVariants.dormant.attack', '7'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', actorRow($editor, 'naturalVariants.dormant.attack'), '7'); expect($workspace->actorDatabase->getActors()[0]->getNaturalVariants()['dormant'])->toBe(['attack' => 7]); }); diff --git a/tests/Unit/AnimationDatabaseRoundTripTest.php b/tests/Unit/AnimationDatabaseRoundTripTest.php new file mode 100644 index 00000000..714ff067 --- /dev/null +++ b/tests/Unit/AnimationDatabaseRoundTripTest.php @@ -0,0 +1,69 @@ + 1, + 'name' => 'Slash', + 'position' => AnimationTargetPosition::CENTER->value, + 'maxFrames' => 2, + // Played for sword attacks. + 'roles' => ['attack'], + 'sourceEffect' => 'battle-blade-slash', + 'targetEffect' => 'battle-physical-impact', + 'futureField' => ['kept' => true], + 'frames' => [['index' => 1, 'cells' => [['symbol' => '/', 'x' => 0, 'y' => 0, 'color' => 'red']]]], + 'cues' => [], + ], + ['id' => 4, 'name' => 'Spark', 'targetEffect' => 'battle-physical-impact'], + ]; + PHP); + + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + $legacy = array_column($database->getSettingsFields(0), null, 'label'); + $current = array_column($database->getSettingsFields(1), 'label'); + + // An older record shows its own frames and cues read-only; a current one has none. + expect($legacy['Legacy Frames'])->toMatchArray(['value' => '(1)', 'editable' => false]) + ->and($legacy['Legacy Position']['value'])->toBe('center') + ->and($current)->toBe(['Id', 'Name', 'Caster Effect', 'Target Effect', 'Roles']); + + $database->setField(0, 'name', 'Blade Slash'); + $database->save(); + $saved = require $path; + $source = (string) file_get_contents($path); + + expect($saved[0]['name'])->toBe('Blade Slash') + ->and($saved[0]['roles'])->toBe(['attack']) + ->and($saved[0]['sourceEffect'])->toBe('battle-blade-slash') + ->and($saved[0]['futureField'])->toBe(['kept' => true]) + ->and($saved[0]['frames'][0]['cells'][0]['symbol'])->toBe('/') + ->and($source)->toContain('// Battle and field animations.', '// Played for sword attacks.', 'use Ichiloto\Engine\Animations\AnimationTargetPosition;'); + + // A new animation takes the next number after the largest, and only what the editor writes. + $index = $database->addRecord(); + $database->save(); + $again = require $path; + + expect($again)->toHaveCount(3) + ->and($again[$index])->toBe(['id' => 5, 'name' => 'New Animation']) + ->and($again[0]['targetEffect'])->toBe('battle-physical-impact'); +}); diff --git a/tests/Unit/AnimationReferenceTest.php b/tests/Unit/AnimationReferenceTest.php new file mode 100644 index 00000000..161a3185 --- /dev/null +++ b/tests/Unit/AnimationReferenceTest.php @@ -0,0 +1,160 @@ +fields, + static fn($field): bool => $field->key === 'effectTiming.mode'))[0]; + expect($timing->displayDefault)->toBe(SummonEffectTiming::DEFAULT_MODE) + ->and($timing->options)->toBe(SummonEffectTiming::AUTHORING_MODES); +}); + +it('offers animation names while storing stable numeric ids', function () { + $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject(makeTemporaryProject())); + expect($catalog->valuesFor('animation_ids'))->toBe(['1']) + ->and($catalog->labelsFor('animation_ids'))->toBe([1 => 'Slash (1)']) + ->and($catalog->valuesFor('animations'))->toBe(['Slash']); + + $picker = new ReferencePicker(); + $picker->open('animationId', 'Animation', 'animation_ids', $catalog->valuesFor('animation_ids'), '1', $catalog->labelsFor('animation_ids')); + expect($picker->selected())->toBe('1')->and($picker->labelFor('1'))->toBe('Slash (1)'); +}); + +it('round trips explicit animation ids for every skill subtype and allows clearing them', function (string $class) { + $root = makeTemporaryProject(); + writeSkillRecords($root, new $class('Renamed', '', '', 0, 0, animationId: 1)); + $path = $root . '/assets/Data/Skills/0001-renamed.php'; + $loaded = loadRecordDatabase($root, 'skills'); + expect($loaded->getRecordByIndex(0)?->get('animationId'))->toBe(1); + $loaded->setField(0, 'animationId', ''); + $loaded->save(); + expect(SkillCatalog::load($root . '/assets')->findSkill('Renamed')?->animationId)->toBeNull() + ->and(file_get_contents($path))->not->toContain('animationId'); +})->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); + +it('round trips item animation references through the existing typed resource picker schema', function () { + $root = makeTemporaryProject(); + $database = loadRecordDatabase($root, 'items'); + $database->setField(0, 'animationId', '1'); + expect($database->getRecords()[0]->get('animationId'))->toBe(1); + $database->save(); + $loaded = loadRecordDatabase($root, 'items'); + expect($loaded->getRecords()[0]->get('animationId'))->toBe(1); + $loaded->setField(0, 'animationId', '(None)'); + $loaded->save(); + expect(loadRecordDatabase($root, 'items')->getRecords()[0]->get('animationId'))->toBeNull(); +}); + +it('reports deprecated name fallback and stale ids without rejecting gameplay', function () { + $root = makeTemporaryProject(); + writeSkillRecords( + $root, + new SpecialSkill('Slash', '', '', 0, 0), + new SpecialSkill('Renamed', '', '', 0, 0, animationId: 1), + new SpecialSkill('Missing', '', '', 0, 0, animationId: 999), + ); + $issues = new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(2) + ->and($issues[0]->severity)->toBe(Severity::WARNING) + ->and($issues[0]->message)->toContain('deprecated') + ->and($issues[1]->severity)->toBe(Severity::WARNING) + ->and($issues[1]->message)->toContain('animationId'); +}); + +it('uses the engine fallback rules for magic animation diagnostics', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/animations.php', " 2, 'name' => 'Healing Aura']];"); + writeSkillRecords( + $root, + new MagicSkill('Cure', '', '', 0, 0, effectType: MagicEffectType::RESTORATIVE), + new MagicSkill('Flare', '', '', 0, 0, effectType: MagicEffectType::DESTRUCTIVE), + ); + // Only the skills' diagnostics; this project binds no roles, which is + // reported separately. + $issues = array_values(array_filter( + new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn($issue): bool => str_starts_with($issue->where, 'assets/Data/Skills/'), + )); + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toContain('Cure'); +}); + +it('routes skill animation edits through the existing picker and undo transaction', function () { + $root = makeTemporaryProject(); + writeSkillRecords($root, new SpecialSkill('Skill', '', '', 0, 0)); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skills'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $field = array_values(array_filter($fields, static fn(array $field): bool => ($field['field'] ?? null) === 'animationId'))[0]; + expect($field['reference'])->toBe('animation_ids')->and($field['allowsNone'])->toBeTrue() + ->and(isset($field['control']))->toBeFalse(); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field, '1'); + $skills = getEditorProperty($editor, 'workspace')->getRecordDatabase('skills'); + $animation = static fn(): mixed => $skills->getRecordByIndex(0)?->get('animationId'); + expect($animation())->toBe(1); + callEditorMethod($editor, 'performUndo'); + expect($animation())->toBeNull(); + callEditorMethod($editor, 'performRedo'); + expect($animation())->toBe(1); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field, '(Legacy fallback)'); + expect($animation())->toBeNull(); +}); + +it('offers a spell its effect type alone, and leaves a no-op pick unchanged', function (string $class) { + $root = makeTemporaryProject(); + writeSkillRecords($root, new $class('Skill', '', '', 0, 0)); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skills'); + $byName = array_column(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $field): bool => isset($field['field'])), null, 'field'); + $skills = getEditorProperty($editor, 'workspace')->getRecordDatabase('skills'); + $before = $skills->getRecordByIndex(0)?->toArray(); + expect($byName)->toHaveKey('kind'); + if ($class === MagicSkill::class) { + expect($byName)->toHaveKey('effectType'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $byName['effectType'], $byName['effectType']['value']); + } else { + expect($byName)->not->toHaveKey('effectType'); + } + expect($skills->getRecordByIndex(0)?->toArray())->toBe($before)->and($skills->isDirty())->toBeFalse(); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $byName['animationId'], '1'); + $skills->save(); + expect(SkillCatalog::load($root . '/assets')->findSkill('Skill')?->animationId)->toBe(1); +})->with([BasicSkill::class, MagicSkill::class, SpecialSkill::class]); + +it('drops a spell\'s effect type when it becomes another kind of skill', function () { + $root = makeTemporaryProject(); + writeSkillRecords($root, new MagicSkill('Mend', '', '', 0, 0, effectType: MagicEffectType::BUFF)); + $skills = loadRecordDatabase($root, 'skills'); + + $skills->setField(0, 'kind', 'special'); + $skills->save(); + + expect($skills->getRecordByIndex(0)?->get('effectType'))->toBeNull() + ->and(SkillCatalog::load($root . '/assets')->findSkill('Mend'))->toBeInstanceOf(SpecialSkill::class); +}); + +it('does not enter text editing for an established actor id', function () { + $root = makeTemporaryProject(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'actors'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $index = array_search('id', array_column($fields, 'field'), true); + expect($index)->not->toBeFalse()->and($fields[$index]['editable'])->toBeFalse(); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeFalse(); +}); diff --git a/tests/Unit/AnimationRolesTest.php b/tests/Unit/AnimationRolesTest.php new file mode 100644 index 00000000..b9e81052 --- /dev/null +++ b/tests/Unit/AnimationRolesTest.php @@ -0,0 +1,163 @@ + [ + 'id' => $id, 'name' => $name, + ...($roles === [] ? [] : ['roles' => $roles]), + 'targetEffect' => $id === 1 ? 'battle-blade-slash' : 'battle-physical-impact', + ]; + file_put_contents($root . '/assets/Data/animations.php', ' 120, 'height' => 40]); + setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('animations')); + + return $editor; +} + +/** @return list The validation messages about animations. */ +function roleIssues(string $root): array +{ + return array_map( + static fn($issue): string => $issue->message, + new AnimationReferenceValidator()->validate(ProjectWorkspace::fromProject($root)), + ); +} + +it('binds and unbinds roles as one list on the record, keeping everything else', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + + $database->setField(0, 'roles', 'attack-sword, attack-dagger'); + $database->setField(1, 'roles', ''); + $database->save(); + $saved = require $root . '/assets/Data/animations.php'; + + expect($saved[0]['roles'])->toBe(['attack-sword', 'attack-dagger']) + ->and($saved[1])->not->toHaveKey('roles') + ->and($saved[0]['targetEffect'])->toBe('battle-blade-slash'); +}); + +it('refuses a role another animation holds, naming it and changing nothing', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + + expect(fn() => $database->setField(1, 'roles', 'attack, attack-unarmed, attack-sword')) + ->toThrow(InvalidArgumentException::class, 'Blade Slash already has roles attack-sword; take it off there first.') + ->and($database->getRecordByIndex(1)->get('roles'))->toBe(['attack', 'attack-unarmed']) + ->and($database->isDirty())->toBeFalse(); +}); + +it('offers every supported role in the roles picker, naming the animation that holds it', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject($root)); + $row = array_find(callEditorMethod(roleEditor($root), 'getDatabaseSettingsFields'), static fn(array $field): bool => ($field['field'] ?? null) === 'roles'); + + expect($row)->toMatchArray(['reference' => 'animation_roles', 'multi' => true, 'value' => 'attack-sword']) + ->and($catalog->valuesFor('animation_roles'))->toBe(ActionAnimationResolver::getSupportedRoles()) + ->and($catalog->labelsFor('animation_roles')['attack-unarmed'])->toBe('attack-unarmed (on Hit Spark)') + ->and($catalog->labelsFor('animation_roles')['attack-axe'])->toBe('attack-axe'); +}); + +it('takes a role from the picker as one undo step, and leaves no step when refused', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $editor = roleEditor($root); + /** @var CommandHistory $history */ + $history = getEditorProperty($editor, 'history'); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('animations'); + $roles = static fn(): array => array_find(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $field): bool => ($field['field'] ?? null) === 'roles'); + + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $roles(), 'attack-sword, attack-axe'); + expect($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword', 'attack-axe']); + + $history->undo(); + expect($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword']); + + $history->redo(); + $history->undo(); + $steps = new ReflectionProperty(CommandHistory::class, 'undoStack')->getValue($history); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $roles(), 'attack-sword, attack-unarmed'); + + expect($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword']) + ->and(new ReflectionProperty(CommandHistory::class, 'undoStack')->getValue($history))->toBe($steps) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Hit Spark already has roles attack-unarmed'); +}); + +it('reports roles the runtime cannot play and roles the project reaches with no animation', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root, ['attack-sword', 'attack-lance'], ['attack-sword']); + $issues = roleIssues($root); + + expect($issues)->toContain( + 'Its role "attack-lance" is not one the Engine supports, so the whole animation is skipped.', + 'Role attack-sword is bound to Blade Slash and Hit Spark, so neither plays for it.', + 'No animation holds role attack, so enemy and unclassified attacks play no effect.', + 'No animation holds role attack-unarmed, so attacks with no weapon equipped play no effect.', + ); + + writeRoleAnimations($root, [], ['attack', 'attack-unarmed']); + + // The fixture's Wooden Sword makes swords a weapon type the battles reach. + expect(implode("\n", roleIssues($root)))->toContain('No animation holds role attack-sword, so attacks with a sword such as Wooden Sword play no effect.') + ->and(implode("\n", roleIssues($root)))->not->toContain('role attack,'); + + writeRoleAnimations($root); + + expect(array_filter(roleIssues($root), static fn(string $message): bool => str_contains($message, 'role')))->toBe([]); +}); + +it('numbers a new animation after the largest id, even one deleted this session', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/animations.php', " 2, 'name' => 'Spark'], ['id' => 4, 'name' => 'Burst']];"); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + + $added = $database->addRecord(); + expect($database->getRecordByIndex($added)->get('id'))->toBe(5); + + $database->removeRecord($added); + $database->removeRecord(1); + $again = $database->addRecord(); + // 4 and 5 were deleted, and something may still name them. + expect($database->getRecordByIndex($again)->get('id'))->toBe(6); +}); + +it('copies an animation without the roles it holds alone, so each role still plays one animation', function () { + $root = makeTemporaryProject(); + writeRoleAnimations($root); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('animations'); + + $copy = $database->duplicateRecord(0); + expect($database->getRecordByIndex($copy)->toArray())->toBe(['id' => 3, 'name' => 'Blade Slash', 'targetEffect' => 'battle-blade-slash']) + ->and($database->getRecordByIndex(0)->get('roles'))->toBe(['attack-sword']); + + $database->save(); + $saved = require $root . '/assets/Data/animations.php'; + $holders = array_filter($saved, static fn(array $entry): bool => in_array('attack-sword', $entry['roles'] ?? [], true)); + + expect($holders)->toHaveCount(1) + ->and(array_filter(roleIssues($root), static fn(string $message): bool => str_contains($message, 'bound to')))->toBe([]); +}); diff --git a/tests/Unit/Assets/PngPixelsTest.php b/tests/Unit/Assets/PngPixelsTest.php new file mode 100644 index 00000000..0a357494 --- /dev/null +++ b/tests/Unit/Assets/PngPixelsTest.php @@ -0,0 +1,80 @@ +width, $png->height])->toBe([5, 3]); + for ($y = 0; $y < 3; $y++) { + for ($x = 0; $x < 5; $x++) { + $color = imagecolorsforindex($gd, imagecolorat($gd, $x, $y)); + [$red, $green, $blue, $alpha] = $png->getPixel($x, $y); + expect([$red, $green, $blue])->toBe([$color['red'], $color['green'], $color['blue']]) + // GD keeps alpha in 7 bits; the PNG's own 8 bits read within its rounding. + ->and(abs((127 - $color['alpha']) * 255 / 127 - $alpha))->toBeLessThan(2.1); + } + } + } +}); + +it('writes pixels back without loss and lays one image\'s cells over another\'s, nothing else changing', function () { + $sheet = PngPixels::decode(encodeWithGd('rgba'), 'sheet'); + $again = PngPixels::decode($sheet->encode(), 'again'); + expect($again->hasSamePixels($sheet))->toBeTrue() + ->and($again->getPixelHash())->toBe($sheet->getPixelHash()); + + $art = PngPixels::createBlank(2, 2); + $art->setPixel(0, 0, [1, 2, 3, 4]); + $art->setPixel(1, 1, [250, 251, 252, 253]); + $laid = $sheet->withPixelsFrom($art, 0, 0, 2, 2, 3, 1); + for ($y = 0; $y < 3; $y++) { + for ($x = 0; $x < 5; $x++) { + $inside = $x >= 3 && $y >= 1; + expect($laid->getPixel($x, $y))->toBe($inside ? $art->getPixel($x - 3, $y - 1) : $sheet->getPixel($x, $y)); + } + } + expect($laid->isTransparent(4, 1, 1, 1))->toBeTrue() + ->and($laid->isTransparent(3, 1, 1, 1))->toBeFalse() + ->and($sheet->isTransparent(0, 0, 1, 1))->toBeFalse(); +}); + +it('refuses what it cannot read without loss, saying what to do', function () { + $bytes = encodeWithGd('rgb'); + expect(fn() => PngPixels::decode(encodeWithGd('interlaced'), 'Hut.png')) + ->toThrow(InvalidArgumentException::class, 'Hut.png is interlaced. Save it without interlacing and try again.') + ->and(fn() => PngPixels::decode('GIF89a', 'Hut.png'))->toThrow(InvalidArgumentException::class, 'Hut.png is not a PNG image.') + ->and(fn() => PngPixels::decode(substr($bytes, 0, 40), 'Hut.png'))->toThrow(InvalidArgumentException::class, 'Hut.png is cut short') + ->and(fn() => PngPixels::decode(substr_replace($bytes, 'X', 20, 1), 'Hut.png'))->toThrow(InvalidArgumentException::class, 'does not match its checksum'); + // A 16-bit header, which would lose detail if reduced. + $header = pack('NNCCCCC', 1, 1, 16, 6, 0, 0, 0); + $deep = "\x89PNG\r\n\x1a\n" . pack('N', 13) . 'IHDR' . $header . pack('N', crc32('IHDR' . $header)); + expect(fn() => PngPixels::decode($deep, 'Deep.png'))->toThrow(InvalidArgumentException::class, 'Deep.png has 16 bits per channel'); +}); diff --git a/tests/Unit/Audio/AudioAuditionTest.php b/tests/Unit/Audio/AudioAuditionTest.php new file mode 100644 index 00000000..f966abea --- /dev/null +++ b/tests/Unit/Audio/AudioAuditionTest.php @@ -0,0 +1,89 @@ + */ + public array $files = []; + + public function __construct(private float $seconds) {} + public function getExecutableName(): string { return 'sleep'; } + public function isAvailable(): bool { return true; } + public function supportsNativeLooping(): bool { return false; } + public function supports(string $filePath): bool { return str_ends_with($filePath, '.ogg'); } + public function supportsSeeking(): bool { return false; } + public function buildCommand(string $filePath, float $volume, bool $loop, float $startAtSeconds = 0.0): array + { + $this->files[] = $filePath; + + return ['sleep', (string) $this->seconds]; + } + }; +} + +/** A project with one music track, `theme.ogg`, and one sound the stand-in player cannot play. */ +function auditionProject(): string +{ + $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-audition-' . bin2hex(random_bytes(4))); + mkdir($root . '/assets/Audio/BGM', 0o777, true); + mkdir($root . '/assets/Audio/SFX', 0o777, true); + touch($root . '/assets/Audio/BGM/theme.ogg'); + touch($root . '/assets/Audio/SFX/chime.wav'); + + return $root; +} + +it('finds a track by the name a field stores, as the game does, and plays it until it ends or is stopped', function () { + $root = auditionProject(); + $player = silentAuditionPlayer(5); + $audition = new AudioAudition(new PreviewGame(), $root, [$player]); + + expect($audition->play('bgm', 'theme'))->toBeNull() + ->and($player->files)->toBe([$root . '/assets/Audio/BGM/theme.ogg']) + ->and($audition->describePlaying())->toBe(['kind' => 'bgm', 'name' => 'theme']); + $audition->stop(); + expect($audition->describePlaying())->toBeNull(); + + $finishing = new AudioAudition(new PreviewGame(), $root, [silentAuditionPlayer(0)]); + $finishing->play('bgm', 'theme'); + usleep(300_000); + expect($finishing->describePlaying())->toBeNull(); +}); + +it('says why a track cannot play: missing, unplayable here, or not chosen', function () { + $audition = new AudioAudition(new PreviewGame(), auditionProject(), [silentAuditionPlayer(5)]); + + expect($audition->play('bgm', 'missing'))->toBe('missing was not found in assets/Audio/BGM.') + ->and($audition->play('sfx', 'chime'))->toBe('No audio player on this computer can play chime.wav.') + ->and($audition->play('bgm', ''))->toBe('Choose a track first.') + ->and($audition->play('bgm', '../BGM/theme'))->toBe('Choose a track first.') + ->and($audition->describePlaying())->toBeNull(); +}); + +it('marks music and sound fields as sounds to play, and refuses what is not one', function () { + // A project with no audio at all, so nothing this session is asked for can be found, let alone heard. + $session = EditorSession::open(mapGraphicsProject()); + + expect(\Ichiloto\Editor\Database\ReferenceCatalog::describeMedia('bgm'))->toBe(['kind' => 'audio', 'root' => 'assets/Audio/BGM']) + ->and(\Ichiloto\Editor\Database\ReferenceCatalog::describeMedia('sfx'))->toBe(['kind' => 'audio', 'root' => 'assets/Audio/SFX']) + ->and(fn() => $session->playAudio('maps', 'theme'))->toThrow(SessionRefusal::class, 'not music or a sound effect') + ->and(fn() => $session->playAudio('bgm', 'missing'))->toThrow(SessionRefusal::class, 'was not found') + ->and($session->describeAudio())->toBe(['playing' => null]) + ->and($session->stopAudio())->toBe(['playing' => null]); +}); diff --git a/tests/Unit/BattleEntryParityTest.php b/tests/Unit/BattleEntryParityTest.php index 1eca454f..fdd5affa 100644 --- a/tests/Unit/BattleEntryParityTest.php +++ b/tests/Unit/BattleEntryParityTest.php @@ -48,7 +48,8 @@ function hydrateThroughAcceptedEngine(string $engineRoot, string $ruleFile, stri */ function parityActorsDirectory(array $actors): string { - $directory = sys_get_temp_dir() . '/ichiloto-parity-actors-' . bin2hex(random_bytes(4)); + // Registered, so the suite removes it after the test whatever its outcome. + $directory = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-parity-actors-' . bin2hex(random_bytes(4))); mkdir($directory, 0755, true); foreach ($actors as $fileStem => [$name, $id]) { @@ -159,6 +160,18 @@ function parityRuleCorpus(): array $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile); $problems = BattleEntryRuleContract::problems(require $ruleFile, $ruleFile); + // Runtime now removes only the invalid-actor rule instead of aborting + // startup; authoring still rejects it with the same root diagnostic. + if ($case === 'actor-empty-identity') { + expect($engine['ok'] ?? false)->toBeTrue() + ->and($engine['order'] ?? null)->toBe([]) + ->and($problems)->toHaveCount(1) + ->and($engine['diagnostics'] ?? null)->toBe([ + $problems[0] . ' This battle-entry rule is disabled; correct its actor reference.', + ]); + continue; + } + if (($engine['ok'] ?? false) === true) { expect($problems)->toBe([], sprintf( '%s: the engine accepted what the editor refused: %s', @@ -192,36 +205,49 @@ function parityRuleCorpus(): array try { $ruleFile = $directory . '/rules.php'; - // Resolution: id, display name, and file stem all reach the same - // durable identity; an unknown reference is refused with the same - // wording; a healthy store accepts all three reference kinds. - file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'actor.rook', 'presence' => 'any'], ['actor' => 'Rook', 'presence' => 'active'], ['actor' => 'RookFile', 'presence' => 'reserve']], 'effects' => [['type' => 'stat_stage', 'actor' => 'ROOK', 'stat' => 'speed', 'delta' => 1]]]]];"); - $healthy = parityActorsDirectory(['RookFile' => ['Rook', 'actor.rook'], 'Vale' => ['Vale', null]]); + // ID-only resolution removes inferred display-name and filename aliases. + file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'actor.rook', 'presence' => 'any']], 'effects' => [['type' => 'stat_stage', 'actor' => 'ACTOR.ROOK', 'stat' => 'speed', 'delta' => 1]]]]];"); + $healthy = parityActorsDirectory(['RookFile' => ['Rook', 'actor.rook'], 'Vale' => ['Vale', 'Vale']]); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $healthy); $resolver = parityResolver($healthy); expect($engine['ok'] ?? false)->toBeTrue() ->and($resolver->problems())->toBe([]) ->and(BattleEntryRuleContract::problems(require $ruleFile, $ruleFile, $resolver))->toBe([]) - ->and($resolver->canonicalId('ROOK'))->toBe('actor.rook') - ->and($resolver->canonicalId('RookFile'))->toBe('actor.rook'); + ->and($resolver->canonicalId('ACTOR.ROOK'))->toBe('actor.rook') + ->and($resolver->canonicalId('ROOK'))->toBeNull() + ->and($resolver->canonicalId('RookFile'))->toBeNull(); - // Unknown actor: same verdict, same wording. + // Unknown actor: rule disabled, startup-wide failure removed. Authoring + // remains strict, with the same root diagnostic and no alias fallback. file_put_contents($ruleFile, " [['id' => 'one', 'actors' => [['actor' => 'Nobody', 'presence' => 'any']], 'effects' => [['type' => 'stat_stage', 'actor' => 'Rook', 'stat' => 'speed', 'delta' => 1]]]]];"); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $healthy); $problems = BattleEntryRuleContract::problems(require $ruleFile, $ruleFile, $resolver); - expect($engine['ok'] ?? true)->toBeFalse() - ->and($problems[0] ?? null)->toBe(strval($engine['error'] ?? '')); + expect($engine['ok'] ?? false)->toBeTrue() + ->and($engine['order'] ?? null)->toBe([]) + ->and($problems)->not->toBeEmpty() + ->and($engine['diagnostics'] ?? null)->toBe([ + $problems[0] . ' This battle-entry rule is disabled; correct its actor reference.', + ]); - // A contested reference refuses the store itself, with the same - // wording the resolver reports. - $contested = parityActorsDirectory(['Kael' => ['Kael', null], 'Impostor' => ['Kael', 'actor.impostor']]); + // A shared name is not a contested identity anymore. + file_put_contents($ruleFile, " []];"); + $contested = parityActorsDirectory(['Kael' => ['Kael', 'Kael'], 'Impostor' => ['Kael', 'actor.impostor']]); $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $contested); $resolver = parityResolver($contested); - expect($engine['phase'] ?? null)->toBe('store') - ->and($resolver->problems())->toContain(strval($engine['error'] ?? '')); + expect($engine['ok'] ?? false)->toBeTrue() + ->and($resolver->problems())->toBe([]) + ->and($resolver->canonicalId('Kael'))->toBe('Kael'); + + // Runtime file compatibility is provisional; authoring still requires explicit repair. + $missing = parityActorsDirectory(['Missing' => ['Display Name', null]]); + $engine = hydrateThroughAcceptedEngine($engineRoot, $ruleFile, $missing); + $resolver = parityResolver($missing); + expect($engine['ok'] ?? false)->toBeTrue() + ->and($resolver->problems())->not->toBeEmpty() + ->and($resolver->canonicalId('Display Name'))->toBeNull(); // Two definitions sharing an identity refuse the store the same way. $duplicated = parityActorsDirectory(['EchoOne' => ['Echo', 'actor.echo'], 'EchoTwo' => ['Echo Again', 'actor.echo']]); diff --git a/tests/Unit/BattleEntryRulesTest.php b/tests/Unit/BattleEntryRulesTest.php index a3e9c200..ae745976 100644 --- a/tests/Unit/BattleEntryRulesTest.php +++ b/tests/Unit/BattleEntryRulesTest.php @@ -323,7 +323,7 @@ } }); -it('keeps missing, ambiguous, and contested actor identities visible', function (): void { +it('keeps missing and duplicate actor ids visible while removing display-name ambiguity', function (): void { $root = makeTemporaryProject(); try { @@ -347,14 +347,13 @@ 'assets/Data/battle-entry-rules.php rule "ghost-rule" field "actors[0]" field "actor" references unknown actor "Nobody".', ]); - // A reference two definitions claim is contested, and the rules are - // then checked shape-only, exactly as the runtime never reaches them. + // Display names are no longer lookup aliases, so this is not a collision. addProjectActor($root, 'Impostor', 'Kaelion', 'actor.impostor'); $messages = battleEntryProblemMessages($root); - expect($messages)->toContain('Actor reference "kaelion" is ambiguous.') - ->and($messages)->not->toContain( + expect($messages)->not->toContain('Actor reference "kaelion" is ambiguous.') + ->and($messages)->toContain( 'assets/Data/battle-entry-rules.php rule "ghost-rule" field "actors[0]" field "actor" references unknown actor "Nobody".', ); @@ -673,7 +672,7 @@ function editorBattleEntryRuleFixture(): array $editor = deletionEditor($root); openDatabaseCategory($editor, 'battle_entry_rules'); - $cue = callEditorMethod($editor, 'getDatabaseBattleEntryCueLines'); + $cue = callEditorMethod($editor, 'getDatabaseCueLines'); expect($cue[0])->toBe('Runs in this order:') ->and($cue[1])->toContain('1. first-declared-of-ties') diff --git a/tests/Unit/Canvas/ConnectedKeptUnderlayTest.php b/tests/Unit/Canvas/ConnectedKeptUnderlayTest.php new file mode 100644 index 00000000..e59247b8 --- /dev/null +++ b/tests/Unit/Canvas/ConnectedKeptUnderlayTest.php @@ -0,0 +1,76 @@ + ['name' => 'Dirt', 'layer' => 'terrain', 'glyphs' => ['.'], 'tiles' => ['substrate' => ['2864'], 'floor' => ['2816']]], + 'kept-border' => ['name' => 'Kept Border', 'layer' => 'terrain', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '=', 'vertical' => '!', 'corner' => '#'], 'tiles' => ['decor' => '6'], 'keeps' => ['substrate']], + 'plain-border' => ['name' => 'Plain Border', 'layer' => 'terrain', 'connects' => 'lines', + 'glyphs' => ['horizontal' => '~', 'vertical' => ':', 'corner' => '%'], 'tiles' => ['decor' => '7']], + ]); + $directory = $root . '/assets/Maps/test-map'; + writeTileLayer($directory, '00.substrate.tiles.php', "2864 2864 2864 2864\n2864 2864 2864 2864"); + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + + return [$root, EditorSession::open($root)]; +} + +/** @return list One tile layer's first row, as the session reads it. */ +function readUnderlayRow(EditorSession $session, string $layer): array +{ + return array_find($session->readTiles('test-map')['layers'], static fn(array $found): bool => $found['name'] === $layer)['rows'][0]; +} + +function drawUnderlayBorder(EditorSession $session, string $piece): void +{ + $session->placePiece('test-map', $session->readMap('test-map')['revision'], $piece, [0, 0], [3, 0]); +} + +it('keeps the base beneath a connected border drawn over a piece that owned it', function () { + [, $session] = createConnectedUnderlayProject(); + drawUnderlayBorder($session, 'kept-border'); + + expect(readUnderlayRow($session, 'substrate'))->toBe([2864, 2864, 2864, 2864]) + // The dirt's own ground leaves with it; the border draws its own tile. + ->and(readUnderlayRow($session, 'floor'))->toBe([0, 0, 0, 0]) + ->and(readUnderlayRow($session, 'decor'))->toBe([6, 6, 6, 6]); +}); + +it('takes the base with the dirt when a connected border does not keep it', function () { + [, $session] = createConnectedUnderlayProject(); + drawUnderlayBorder($session, 'plain-border'); + + expect(readUnderlayRow($session, 'substrate'))->toBe([0, 0, 0, 0]); +}); + +it('keeps the base when the border reshapes, is erased, undone and saved', function () { + [$root, $session] = createConnectedUnderlayProject(); + drawUnderlayBorder($session, 'kept-border'); + // A second line joining the first reshapes its end into a corner. + $session->placePiece('test-map', $session->readMap('test-map')['revision'], 'kept-border', [3, 0], [3, 1]); + expect(readUnderlayRow($session, 'substrate'))->toBe([2864, 2864, 2864, 2864]); + + $map = $session->readMap('test-map'); + $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0], [1, 0]], ' '); + expect(readUnderlayRow($session, 'substrate'))->toBe([2864, 2864, 2864, 2864]) + ->and(array_slice(readUnderlayRow($session, 'decor'), 0, 2))->toBe([0, 0]); + + $session->undo(); + expect(array_slice(readUnderlayRow($session, 'decor'), 0, 2))->toBe([6, 6]); + $session->saveMap('test-map'); + expect(readUnderlayRow(EditorSession::open($root), 'substrate'))->toBe([2864, 2864, 2864, 2864]); +}); diff --git a/tests/Unit/Canvas/KeptUnderlayErasureTest.php b/tests/Unit/Canvas/KeptUnderlayErasureTest.php new file mode 100644 index 00000000..dcab7c38 --- /dev/null +++ b/tests/Unit/Canvas/KeptUnderlayErasureTest.php @@ -0,0 +1,146 @@ + $id, 'layer' => 'buildings'] + $data, 'Synthetic underlay'); +} + +/** @param list $pieces */ +function planUnderlayErase(array $pieces, string $old, array $tiles, array $choices = []): array +{ + return GlyphTilePlanner::fromPieces($pieces, 'buildings')->plan([['x' => 0, 'y' => 0, 'old' => $old, 'new' => ' ']], + static fn(int $x, int $y): ?string => $x === 0 && $y === 0 ? $old : null, + static fn(string $layer, int $x, int $y): string => $x === 0 && $y === 0 ? ($tiles[$layer] ?? '0') : '0', + $choices); +} + +/** @return array The face, window and an unrelated piece. */ +function underlayPieces(): array +{ + return [ + createUnderlayPiece('plaster', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]), + createUnderlayPiece('stone', ['glyphs' => ['%'], 'tiles' => ['walls' => ['11']]]), + createUnderlayPiece('window', ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']]), + createUnderlayPiece('sign', ['glyphs' => ['s'], 'tiles' => ['decor' => ['6']]]), + ]; +} + +it('gives an erased mounted window back to the wall face its kept tiles prove', function () { + $plan = planUnderlayErase(underlayPieces(), 'w', ['walls' => '10', 'decor' => '5']); + + expect(array_map(static fn(array $glyph): array => [$glyph['symbol'], $glyph['role']->key], $plan['glyphs'])) + ->toBe([['#', 'plaster:0:0']]) + // The window's own tile goes; the face it kept stays exactly as it was. + ->and($plan['tiles'])->toBe(['decor' => [['x' => 0, 'y' => 0, 'entry' => '0']]]) + ->and($plan['unresolved'])->toBe([]); +}); + +it('leaves an erased cell blank when nothing kept proves a role', function (string $old, array $tiles) { + $plan = planUnderlayErase(underlayPieces(), $old, $tiles); + + expect($plan['glyphs'])->toBe([])->and($plan['unresolved'])->toBe([]); +})->with([ + 'a piece that keeps nothing' => ['s', ['walls' => '10', 'decor' => '6']], + 'a mounted window with no face behind it' => ['w', ['decor' => '5']], + 'a kept tile no piece draws' => ['w', ['walls' => '42', 'decor' => '5']], +]); + +it('asks which face an erase restores when the kept tiles prove several, and keeps the answer', function () { + $pieces = [...underlayPieces(), createUnderlayPiece('panel', ['glyphs' => ['P'], 'tiles' => ['walls' => ['10']]])]; + $tiles = ['walls' => '10', 'decor' => '5']; + + expect(array_map(static fn($role): string => $role->key, planUnderlayErase($pieces, 'w', $tiles)['unresolved'][' '])) + ->toBe(['plaster:0:0', 'panel:0:0']) + ->and(planUnderlayErase($pieces, 'w', $tiles)['glyphs'])->toBe([]) + ->and(planUnderlayErase($pieces, 'w', $tiles, [' ' => 'panel:0:0'])['glyphs'][0]['symbol'])->toBe('P') + // Choosing none leaves the cell blank, as an author may want. + ->and(planUnderlayErase($pieces, 'w', $tiles, [' ' => null])['glyphs'])->toBe([]) + ->and(planUnderlayErase($pieces, 'w', $tiles, [' ' => null])['unresolved'])->toBe([]); +}); + +/** A synthetic map with a plaster face at (1,0) and (2,0), a window mounted on (1,0), and the editor's buildings layer id. */ +function mountedWindowMap(): array +{ + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'plaster' => ['name' => 'Plaster', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]], + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']], + ]); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/04.buildings.map.php', + Ichiloto\Engine\Field\MapGridSource::buildSource(" w# \n ", 'AUTHORED', "// keep 04.buildings.map.php\n")); + writeTileLayer($directory, '02.decor.tiles.php', "0 5 0 0\n0 0 0 0"); + writeTileLayer($directory, '03.walls.tiles.php', "0 10 10 0\n0 0 0 0"); + $map = loadLayeredMap($root); + $layerId = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'buildings')['id']; + + return [$root, $map, $layerId]; +} + +/** @return list Validation's warnings about piece tiles whose glyph has gone, for the map as it stands in memory. */ +function readStaleUnderlayIssues(ProjectMap $map): array +{ + return array_values(array_map(static fn($issue): string => $issue->message, array_filter(MapGraphicsValidator::validateCoverage($map), + static fn($issue): bool => str_contains($issue->message, 'no longer there')))); +} + +/** @return array{string, string, string} The buildings glyph at (1,0) and the decor and walls tiles there. */ +function readMountedCell(ProjectMap $map, string $layerId): array +{ + $tile = CanvasEditor::createTileReader($map); + + return [$map->getLayerSymbol($layerId, 1, 0), $tile('decor', 1, 0), $tile('walls', 1, 0)]; +} + +it('restores the face when a window is erased, as one undoable step that saves as authored', function () { + [$root, $map, $layerId] = mountedWindowMap(); + $writes = [['x' => 1, 'y' => 0, 'symbol' => ' ', 'color' => null]]; + $plan = CanvasEditor::plan($map, $layerId, $writes); + $applied = CanvasEditor::apply($map, $layerId, $plan['writes'], 'Erase', $plan['tiles']); + + expect(readMountedCell($map, $layerId))->toBe(['#', '0', '10']) + ->and(readStaleUnderlayIssues($map))->toBe([]); + $applied['command']->undo(); + expect(readMountedCell($map, $layerId))->toBe(['w', '5', '10']); + $applied['command']->execute(); + expect(readMountedCell($map, $layerId))->toBe(['#', '0', '10']); + + $map->save(); + $reloaded = loadLayeredMap($root); + expect(readMountedCell($reloaded, $layerId))->toBe(['#', '0', '10']) + ->and((string) file_get_contents($root . '/assets/Maps/test-map/layers/04.buildings.map.php')) + ->toContain("// keep 04.buildings.map.php\n")->toContain(" ## \n"); +}); + +it('moves a mounted window with the face it sits on, leaving nothing stale behind', function () { + [$root, $map, $layerId] = mountedWindowMap(); + $clipboard = new Clipboard(); + CanvasClipboard::cut($map, $layerId, 1, 0, 1, 1, $clipboard); + + // A cut takes the whole block, face included: the cell it leaves holds nothing. + expect(readMountedCell($map, $layerId))->toBe([' ', '0', '0']); + CanvasClipboard::paste($map, $layerId, $clipboard, 1, 1); + $tile = CanvasEditor::createTileReader($map); + expect([$map->getLayerSymbol($layerId, 1, 1), $tile('decor', 1, 1), $tile('walls', 1, 1)])->toBe(['w', '5', '10']) + // The face beside it, and its other layers, are untouched. + ->and([$map->getLayerSymbol($layerId, 2, 0), $tile('walls', 2, 0)])->toBe(['#', '10']) + ->and(readStaleUnderlayIssues($map))->toBe([]); +}); diff --git a/tests/Unit/CanvasInputRegressionTest.php b/tests/Unit/CanvasInputRegressionTest.php index b7b3e1f9..a8b9aeb7 100644 --- a/tests/Unit/CanvasInputRegressionTest.php +++ b/tests/Unit/CanvasInputRegressionTest.php @@ -121,3 +121,40 @@ function createCanvasRegressionEditor(): array callEditorMethod($editor, 'dispatchInput', sprintf("\033[<67;%d;%dM", $bounds['right'], $bounds['bottom'])); expect(getEditorProperty($editor, 'canvasOffsetX'))->toBeGreaterThan(10); })->with(['map', 'event']); + +it('uses the rendered viewport width through wheel undo tab cursor movement and resize', function (int $terminalWidth) { + [$editor, $map] = createCanvasRegressionEditor(); + setEditorProperty($editor, 'lastTerminalSize', ['width' => $terminalWidth, 'height' => 40]); + $map->resize(200, 100); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + $lastOffset = $map->getWidth() - $bounds['width']; + setEditorProperty($editor, 'cursorX', $map->getWidth() - 1); + callEditorMethod($editor, 'syncViewportToCursor'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + + callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 199, 'y' => 0, 'symbol' => 'Q']], 'test stroke'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<67;%d;%dM", $bounds['right'], $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + callEditorMethod($editor, 'dispatchInput', "\t"); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe($lastOffset); + + setEditorProperty($editor, 'canvasOffsetX', 0); + setEditorProperty($editor, 'cursorX', $bounds['width']); + callEditorMethod($editor, 'syncViewportToCursor'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(1); + + setEditorProperty($editor, 'lastTerminalSize', ['width' => $terminalWidth + 20, 'height' => 45]); + setEditorProperty($editor, 'canvasOffsetX', 199); + setEditorProperty($editor, 'canvasOffsetY', 99); + callEditorMethod($editor, 'clampCanvasOffsets'); + $resized = callEditorMethod($editor, 'getCanvasPreviewBounds'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(200 - $resized['width']) + ->and(getEditorProperty($editor, 'canvasOffsetY'))->toBe(100 - $resized['height']); + $map->resize(2, 2); + callEditorMethod($editor, 'clampCanvasOffsets'); + expect(getEditorProperty($editor, 'canvasOffsetX'))->toBe(0) + ->and(getEditorProperty($editor, 'canvasOffsetY'))->toBe(0); +})->with([100, 120, 160]); diff --git a/tests/Unit/CanvasToolsTest.php b/tests/Unit/CanvasToolsTest.php index 5472b48d..42397709 100644 --- a/tests/Unit/CanvasToolsTest.php +++ b/tests/Unit/CanvasToolsTest.php @@ -526,13 +526,15 @@ function tileRows(Editor $editor): array ->and($map->getTileColor(5, 2))->toBe('black'); }); -it('refuses the colour picker on the event layer and in Paint mode', function () { +it('allows event colour authoring while Paint mode still treats o as a glyph', function () { $editor = canvasEditor(); callEditorMethod($editor, 'dispatchInput', 'e'); callEditorMethod($editor, 'dispatchInput', 'o'); - expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeFalse() - ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Map layer'); + // Phase 2 removes the event-layer colour restriction; all layers use + // the same styled-cell workflow now. + expect(getEditorProperty($editor, 'isColorPickerOpen'))->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', "\033"); callEditorMethod($editor, 'dispatchInput', 'm'); callEditorMethod($editor, 'dispatchInput', 'i'); @@ -745,3 +747,99 @@ function canvasCellAt(Editor $editor, int $mapX, int $mapY): array expect($lines[1])->toContain('cursor 7,3'); }); + +it('recolours every cell of an active selection from the colour picker', function () { + $editor = canvasEditor(); + /** @var ProjectWorkspace $workspace */ + $workspace = getEditorProperty($editor, 'workspace'); + $map = $workspace->getMapByIndex(0); + + foreach ([2, 3, 4, 5] as $x) { + $map->setTileSymbol($x, 2, '#'); + } + + setEditorProperty($editor, 'canvasSelection', ['x' => 2, 'y' => 2, 'width' => 4, 'height' => 1]); + callEditorMethod($editor, 'dispatchInput', 'o'); + // Down twice from 'Keep cell colour' lands on the first ANSI colour. + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + + foreach ([2, 3, 4, 5] as $x) { + expect($map->getTileSymbol($x, 2))->toBe('#') + ->and($map->getTileColor($x, 2))->toBe('black', "cell {$x} of the selection"); + } + + // One undo restores the whole recolour. + callEditorMethod($editor, 'dispatchInput', 'u'); + + foreach ([2, 3, 4, 5] as $x) { + expect($map->getTileColor($x, 2))->toBeNull(); + } +}); + +it('draws a filled rectangle by dragging in Paint mode, previewing it on the way, as one undo step', function () { + $editor = canvasEditor(); + $before = tileRows($editor); + setEditorProperty($editor, 'selectedPaintSymbol', 'Q'); + callEditorMethod($editor, 'dispatchInput', 'R'); + callEditorMethod($editor, 'dispatchInput', 'i'); + [$fromColumn, $fromRow] = canvasCellAt($editor, 1, 1); + [$overColumn, $overRow] = canvasCellAt($editor, 2, 2); + [$toColumn, $toRow] = canvasCellAt($editor, 3, 2); + + callEditorMethod($editor, 'dispatchInput', mousePress(0, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', mousePress(32, $overColumn, $overRow)); + // Nothing is painted while dragging; the canvas previews the shape. + expect(tileRows($editor))->toBe($before) + ->and(callEditorMethod($editor, 'getCanvasPreviewCells'))->toBe([1 => [1 => 'Q', 2 => 'Q'], 2 => [1 => 'Q', 2 => 'Q']]); + + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $toColumn, $toRow)); + $rows = tileRows($editor); + expect(substr($rows[1], 1, 3))->toBe('QQQ')->and(substr($rows[2], 1, 3))->toBe('QQQ') + ->and(getEditorProperty($editor, 'canvasToolAnchor'))->toBeNull() + ->and(callEditorMethod($editor, 'getCanvasPreviewCells'))->toBe([]); + + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect(tileRows($editor))->toBe($before); +}); + +it('erases a dragged line with the right button', function () { + $editor = canvasEditor(); + setEditorProperty($editor, 'selectedPaintSymbol', 'Q'); + callEditorMethod($editor, 'dispatchInput', 'l'); + callEditorMethod($editor, 'dispatchInput', 'i'); + [$fromColumn, $fromRow] = canvasCellAt($editor, 0, 0); + [$toColumn, $toRow] = canvasCellAt($editor, 2, 0); + + callEditorMethod($editor, 'dispatchInput', mousePress(2, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', mousePress(34, $toColumn, $toRow)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<2;%d;%dm", $toColumn, $toRow)); + + expect(substr(tileRows($editor)[0], 0, 3))->toBe(' '); +}); + +it('selects by dragging with the Select tool in Normal mode, and a click still only moves the cursor', function () { + $editor = canvasEditor(); + $before = tileRows($editor); + callEditorMethod($editor, 'dispatchInput', 's'); + [$fromColumn, $fromRow] = canvasCellAt($editor, 1, 1); + [$toColumn, $toRow] = canvasCellAt($editor, 3, 2); + + callEditorMethod($editor, 'dispatchInput', mousePress(0, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $fromColumn, $fromRow)); + expect(getEditorProperty($editor, 'canvasSelection'))->toBeNull() + ->and([getEditorProperty($editor, 'cursorX'), getEditorProperty($editor, 'cursorY')])->toBe([1, 1]); + + callEditorMethod($editor, 'dispatchInput', mousePress(0, $fromColumn, $fromRow)); + callEditorMethod($editor, 'dispatchInput', mousePress(32, $toColumn, $toRow)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $toColumn, $toRow)); + expect(getEditorProperty($editor, 'canvasSelection'))->toBe(['x' => 1, 'y' => 1, 'width' => 3, 'height' => 2]) + ->and(tileRows($editor))->toBe($before); +}); + +it('names the tool keys in the Normal-mode canvas border', function () { + $editor = canvasEditor(); + + expect(callEditorMethod($editor, 'createCanvasWindow')->help)->toContain('b/l/r/R/s:Tool'); +}); diff --git a/tests/Unit/CharacterSheetPreviewTest.php b/tests/Unit/CharacterSheetPreviewTest.php new file mode 100644 index 00000000..dead5779 --- /dev/null +++ b/tests/Unit/CharacterSheetPreviewTest.php @@ -0,0 +1,41 @@ + $asset, 'index' => $index, 'layer' => 10], $root . '/assets'); + expect($preview)->toBe(['frames' => [ + ['label' => 'South', 'sourceRect' => ['x' => $x, 'y' => $y, 'width' => 4, 'height' => 4]], + ['label' => 'West', 'sourceRect' => ['x' => $x, 'y' => $y + 4, 'width' => 4, 'height' => 4]], + ['label' => 'East', 'sourceRect' => ['x' => $x, 'y' => $y + 8, 'width' => 4, 'height' => 4]], + ['label' => 'North', 'sourceRect' => ['x' => $x, 'y' => $y + 12, 'width' => 4, 'height' => 4]], + ]]); +})->with([ + ['People.png', 0, 48, 32, 4, 0], ['People.png', 3, 48, 32, 40, 0], + ['People.png', 4, 48, 32, 4, 16], ['People.png', 7, 48, 32, 40, 16], + ['$Guard.png', 0, 12, 16, 4, 0], ['!$Door.png', 0, 12, 16, 4, 0], ['$!Door.png', 0, 12, 16, 4, 0], +]); + +it('reports malformed sheet data and unavailable owner roots without manufacturing frames', function (mixed $data, bool $hasRoot) { + $root = makeTemporaryProject('sheet-preview-invalid-'); + writeTilesetTestPng($root . '/assets/People.png', 48, 32); + $preview = CharacterSheetPreview::describe($data, $hasRoot ? $root . '/assets' : null); + expect($preview['frames'])->toBe([])->and($preview['issue'])->not->toBeEmpty(); +})->with([ + [null, true], [[], true], [['sheet' => 'People.png', 'index' => 8], true], + [['sheet' => 'People.png', 'index' => '2'], true], [['sheet' => 'People.png', 'index' => []], true], + [['sheet' => 'People.png', 'layer' => 1000], true], [['sheet' => 'People.png', 'width' => 48], true], + [['sheet' => '../People.png'], true], [['sheet' => 'People.png'], false], +]); + +it('uses runtime filename semantics for character choices rather than inferring from image dimensions', function () { + expect(CharacterSheetPreview::getIndexOptions('People.png'))->toBe(['0', '1', '2', '3', '4', '5', '6', '7']) + ->and(CharacterSheetPreview::getIndexOptions('!$Door.png'))->toBe(['0']) + ->and(CharacterSheetPreview::getIndexOptions('$!Door.png'))->toBe(['0']) + ->and(CharacterSheetPreview::getIndexOptions('People$Guard.png'))->toHaveCount(8) + ->and(CharacterSheetPreview::getIndexOptions('../People.png'))->toBe([]); +}); diff --git a/tests/Unit/ChestRewardValidationTest.php b/tests/Unit/ChestRewardValidationTest.php new file mode 100644 index 00000000..17a431f5 --- /dev/null +++ b/tests/Unit/ChestRewardValidationTest.php @@ -0,0 +1,217 @@ + $data The chest's data. + * @return Issue[] + */ +function checkChestReward(array $data): array +{ + static $catalog = null; + $catalog ??= InventoryCatalog::fromWorkspace(ProjectWorkspace::fromProject(chestRewardProject())); + + return new ChestRewardValidator($catalog)->validate('test-map', 'C', $data); +} + +/** + * Writes a synthetic map whose events are the given definitions. + * + * @param array> $events Event definitions by marker. + */ +function writeChestRewardMap(string $root, array $events): void +{ + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $map = require $path; + $map['events'] = $events; + file_put_contents($path, "validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => $issue->code === ChestRewardValidator::CODE, + )); +} + +// The rules are the Engine's (MapCompletenessValidator::validateChestReward); these tests pin what the +// Editor adds: its inventory identity, where each finding belongs, and that every finding is an error. + +it('reports every chest the Engine cannot grant as an error on that chest', function (array $data) { + $issues = checkChestReward($data); + + expect($issues)->not->toBeEmpty(); + foreach ($issues as $issue) { + expect($issue->severity)->toBe(Severity::ERROR) + ->and($issue->code)->toBe(ChestRewardValidator::CODE) + ->and($issue->where)->toBe('test-map event C') + ->and($issue->message)->not->toBe(''); + } +})->with([ + 'blank item' => [['lootType' => 'item', 'loot' => '', 'quantity' => 1]], + 'null item' => [['lootType' => 'item', 'loot' => null, 'quantity' => 1]], + 'whitespace item' => [['lootType' => 'item', 'loot' => ' ', 'quantity' => 1]], + // The Engine reads a chest with no data as an empty item. + 'no data' => [[]], + 'unknown item' => [['lootType' => 'item', 'loot' => 'item.absent']], + 'ambiguous item' => [['lootType' => 'item', 'loot' => 'Charm']], + 'skill' => [['lootType' => 'skill', 'loot' => 'Twin Cut']], + 'spell' => [['lootType' => 'spell', 'loot' => 'Twin Cut']], + 'experience' => [['lootType' => 'experience', 'loot' => 10]], + 'unknown type' => [['lootType' => 'treasure', 'loot' => 'Tonic']], + // LootType::tryFrom() is case-sensitive, so the Engine does not know this spelling. + 'miscased type' => [['lootType' => 'Gold', 'loot' => 10]], + 'no gold' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 0]], + 'negative gold' => [['lootType' => 'gold', 'loot' => -5]], + 'gold not a number' => [['lootType' => 'gold', 'loot' => 'lots']], + 'no items' => [['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 0]], + 'quantity not a number' => [['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => 'many']], +]); + +it('resolves item loot by the Editor\'s catalogue of the Engine\'s id, name and alias rules', function (string $loot) { + expect(checkChestReward(['lootType' => 'item', 'loot' => $loot]))->toBe([]); +})->with([ + 'stable id' => ['item.tonic'], + 'display name' => ['Tonic'], + 'alias' => ['Old Tonic'], + 'any case, padded' => [' old tonic '], +]); + +it('accepts what the Engine grants, including gold whose amount is its quantity', function (array $data) { + expect(checkChestReward($data))->toBe([]); +})->with([ + 'no loot type' => [['loot' => 'Tonic']], + 'quantity as text' => [['lootType' => 'item', 'loot' => 'Tonic', 'quantity' => '3']], + 'gold, null loot' => [['lootType' => 'gold', 'loot' => null, 'quantity' => 30]], + 'gold, blank loot' => [['lootType' => 'gold', 'loot' => '', 'quantity' => 30]], + 'gold, no loot' => [['lootType' => 'gold', 'quantity' => 30]], + 'gold amount' => [['lootType' => 'gold', 'loot' => 50]], + 'gold amount as text' => [['lootType' => 'gold', 'loot' => '50']], +]); + +it('treats a loot type case and its string value as the same declaration', function (LootType $type, array $data) { + $describe = static fn(array $issues): array => array_map(static fn(Issue $issue): string => $issue->code . ' ' . $issue->message, $issues); + + expect($describe(checkChestReward(['lootType' => $type, ...$data]))) + ->toBe($describe(checkChestReward(['lootType' => $type->value, ...$data]))); +})->with([ + 'gold' => [LootType::GOLD, ['loot' => null, 'quantity' => 10]], + 'item' => [LootType::ITEM, ['loot' => 'Tonic']], + 'blank item' => [LootType::ITEM, ['loot' => '']], + 'skill' => [LootType::SKILL, ['loot' => 'Twin Cut']], +]); + +it('offers only the loot types the Engine\'s chest can give', function () { + $offered = array_column(MapInspector::listLootTypeChoices(), 'value'); + + expect($offered)->toBe(['item', 'gold', 'weapon', 'armor', 'accessory']); + + foreach (LootType::cases() as $type) { + // The Engine answers which kinds its chest gives; the Editor asks it rather than keeping its own list. + expect(in_array($type->value, $offered, true))->toBe(ChestOpeningAction::supportsLootType($type)) + ->and(ChestLoot::isGrantable($type))->toBe(ChestOpeningAction::supportsLootType($type)); + } +}); + +it('reports every chest in the project report and leaves other events alone', function () { + $root = chestRewardProject(); + $chest = 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger'; + writeChestRewardMap($root, [ + 'A' => ['class' => $chest, 'data' => ['lootType' => 'item', 'loot' => '', 'quantity' => 1]], + 'B' => ['class' => $chest, 'data' => ['lootType' => 'skill', 'loot' => 'Twin Cut', 'quantity' => 1]], + 'C' => ['class' => $chest, 'data' => ['lootType' => 'gold', 'loot' => null, 'quantity' => 25]], + 'D' => ['class' => $chest, 'data' => ['lootType' => 'item', 'loot' => 'Old Tonic']], + 'E' => ['class' => $chest], + // Not a chest: what it calls loot means nothing to the Engine. + 'F' => ['class' => 'Ichiloto\Engine\Events\Triggers\DialogueEventTrigger', 'data' => ['loot' => '', 'dialogue' => []]], + ]); + + $where = array_map(static fn(Issue $issue): string => $issue->where, listChestRewardIssues($root)); + $malformed = array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => $issue->code === 'event.definition')); + + expect($where)->toBe(['test-map event A', 'test-map event B']) + // A chest with no data at all is a malformed event, by the Engine's event rule. + ->and(array_map(static fn(Issue $issue): string => $issue->where, $malformed))->toBe(['test-map event E']) + ->and($malformed[0]->severity)->toBe(Severity::ERROR); +}); + +it('diagnoses a new chest on its inspector row and as a save error, and still saves it as authored', function () { + $root = chestRewardProject(); + $session = Ichiloto\Editor\Session\EditorSession::open($root); + $map = $session->readMap('test-map'); + $layer = array_find($map['layers'], static fn(array $layer): bool => $layer['event']); + $cell = null; + foreach ($layer['rows'] as $y => $row) { + $x = array_find_key($row, static fn(string $symbol): bool => trim($symbol) === ''); + if ($x !== null) { + $cell = [$x, $y]; + break; + } + } + $created = $session->createEvent('test-map', $map['revision'], [$cell], 'Chest'); + $marker = $created['marker']; + $rows = $session->readInspector('test-map', $marker)['rows']; + $labels = array_map(static fn(array $row): string => trim($row['label']), $rows); + $error = array_find($rows, static fn(array $row): bool => trim($row['label']) === 'Reward Error'); + + // A new chest starts visibly incomplete: no reward is invented for it. + expect($error)->toMatchArray([ + 'kind' => 'info', + 'tone' => 'error', + ]) + ->and($error['value'] ?? '')->not->toBe('') + // Under the rows it is about. + ->and(array_search('Reward Error', $labels, true))->toBeGreaterThan(array_search('Loot', $labels, true)) + // Only kinds the Engine's chest can give are offered. + ->and(array_find($rows, static fn(array $row): bool => trim($row['label']) === 'Loot Type')['options']) + ->toBe(['item', 'gold', 'weapon', 'armor', 'accessory']); + + $saved = $session->saveMap('test-map'); + + expect($saved['saved'])->toBe('test-map') + ->and(array_filter($saved['errors'], static fn(array $error): bool => $error['where'] === "test-map event {$marker}" + && $error['code'] === ChestRewardValidator::CODE))->toHaveCount(1) + ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events'][$marker]['data']['loot'])->toBe(''); + + $loot = array_find($session->readInspector('test-map', $marker)['rows'], static fn(array $row): bool => trim($row['label']) === 'Loot'); + $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $loot['key'], 'item.tonic'); + + expect(array_column($session->readInspector('test-map', $marker)['rows'], 'label'))->not->toContain('Reward Error') + ->and(array_filter($session->saveMap('test-map')['errors'], static fn(array $error): bool => $error['where'] === "test-map event {$marker}"))->toBe([]); +}); diff --git a/tests/Unit/ClassRecordsTest.php b/tests/Unit/ClassRecordsTest.php new file mode 100644 index 00000000..8b028852 --- /dev/null +++ b/tests/Unit/ClassRecordsTest.php @@ -0,0 +1,85 @@ +listDatabaseRecords('classes'); + + expect($records['records'])->toBe(['Vanguard', 'Oracle']) + ->and($records['editable'])->toBeTrue() + ->and($records['canCreate'])->toBeTrue(); +}); + +it('edits what the engine reads in its own source: curves, equipment types and skills learned', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/classes.php'; + file_put_contents($path, str_replace(" 'name' => 'Oracle',", " // The healers.\n 'name' => 'Oracle',", (string) file_get_contents($path))); + $classes = classRecords($root); + + $classes->setField(1, 'parameterCurves.speed.extraGrowth', '18'); + $classes->setField(1, 'equipment.weapons', 'Staff, Wand'); + $learn = $classes->addSubItem(1); + $classes->setField(1, ProjectRecordDatabase::subFieldId('learn', $learn, 'skill'), 'Fire'); + $classes->setField(1, ProjectRecordDatabase::subFieldId('learn', $learn, 'level'), '3'); + $classes->save(); + + $oracle = (require $path)[1]; + + expect((string) file_get_contents($path))->toContain('// The healers.') + ->and($oracle['parameterCurves']['speed']['extraGrowth'])->toBe(18) + ->and($oracle['equipment']['weapons'])->toBe(['Staff', 'Wand']) + ->and($oracle['skillsToLearn'])->toBe([['level' => 3, 'skill' => 'Fire']]) + ->and($oracle['parameterCurves']['totalHp'])->toBe(['baseValue' => 90, 'extraGrowth' => 360, 'flatIncrement' => 28]) + ->and((require $path)[0])->toBe((require makeTemporaryProject() . '/assets/Data/classes.php')[0]); +}); + +it('edits a role\'s curriculum of Mastery nodes in its own source, and drops the key once it is emptied', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/classes.php'; + $classes = classRecords($root); + + $classes->setField(0, 'curriculum', 'burn-i, rend'); + $classes->save(); + $recommended = (require $path)[0]['curriculum'] ?? null; + $classes = classRecords($root); + $classes->setField(0, 'curriculum', ''); + $classes->save(); + + expect($recommended)->toBe(['burn-i', 'rend']) + ->and((require $path)[0])->not->toHaveKey('curriculum'); +}); + +it('gives a new class the next id and a name of its own', function () { + $classes = classRecords(makeTemporaryProject()); + $index = $classes->addRecord(); + $record = $classes->getRecordByIndex($index); + + expect($record?->get('id'))->toBe(3) + ->and($classes->getEntryLabels())->toBe(['Vanguard', 'Oracle', 'New Class']); +}); + +it('offers the engine\'s weapon and armor types for a class\'s equipment', function () { + $session = EditorSession::open(makeTemporaryProject()); + $map = $session->describeMaps()[0]['id'] ?? ''; + $values = static fn(string $category): array => array_column($session->listReferences($map, $category), 'value'); + + expect($values('weapon_types'))->toContain('Sword', 'Staff') + ->and($values('armor_types'))->toContain('Heavy Armor', 'Large Shield') + ->and($values('classes'))->toBe(['Vanguard', 'Oracle']); +}); + +it('lists classes in the terminal by their number and name', function () { + $editor = deletionEditor(makeTemporaryProject()); + openDatabaseCategory($editor, 'classes'); + + expect(callEditorMethod($editor, 'getDatabaseListLines'))->toBe(['> 0001 Vanguard', ' 0002 Oracle']); +}); diff --git a/tests/Unit/CommandHistoryTest.php b/tests/Unit/CommandHistoryTest.php index ff1dbd04..6493b1f0 100644 --- a/tests/Unit/CommandHistoryTest.php +++ b/tests/Unit/CommandHistoryTest.php @@ -61,6 +61,28 @@ function strokeTestMap(): ProjectMap ->and($history->canRedo())->toBeFalse(); }); +it('retains failed commands for a safe retry without losing undo or redo history', function () { + $history = new CommandHistory(); + $refuse = new ArrayObject(['undo' => true, 'redo' => true]); + $command = new GenericCommand('Guarded source transaction', + static function () use ($refuse): void { + if ($refuse['redo']) { throw new RuntimeException('Changed source'); } + }, + static function () use ($refuse): void { + if ($refuse['undo']) { throw new RuntimeException('Changed source'); } + }, + ); + $history->record($command); + expect(fn() => $history->undo())->toThrow(RuntimeException::class, 'Changed source'); + expect($history->count())->toBe(1)->and($history->canRedo())->toBeFalse(); + $refuse['undo'] = false; + expect($history->undo())->toBe($command); + expect(fn() => $history->redo())->toThrow(RuntimeException::class, 'Changed source'); + expect($history->canUndo())->toBeFalse()->and($history->canRedo())->toBeTrue(); + $refuse['redo'] = false; + expect($history->redo())->toBe($command)->and($history->count())->toBe(1); +}); + it('clears the redo stack when a new command is recorded', function () { $state = new ArrayObject(['value' => 0]); $history = new CommandHistory(); diff --git a/tests/Unit/ConditionEditorTest.php b/tests/Unit/ConditionEditorTest.php index 96b824f0..b21536d9 100644 --- a/tests/Unit/ConditionEditorTest.php +++ b/tests/Unit/ConditionEditorTest.php @@ -158,8 +158,9 @@ function conditionEditorOn(string $encoded): ConditionEditor $editor->open('conditions', 'Conditions', []); $editor->add(); - // A new condition starts as a switch; four steps on is the key item. - $editor->cycleType(4); + // A new condition starts as a switch; the key item is as many steps on as the Engine lists it. + $types = ConditionCodec::types(); + $editor->cycleType(array_search('key_item', $types, true) - array_search('switch', $types, true)); $editor->setName('Rusty Key'); expect($editor->encoded())->toBe('key_item:Rusty Key'); @@ -179,3 +180,26 @@ function conditionEditorOn(string $encoded): ConditionEditor expect($values)->toContain('breakfast-duty'); }); + +it('writes and reads a map event condition by its map and event marker', function () { + $condition = ['type' => 'map_event', 'name' => 'garden-of-roads/lanternrest-wayhouse', 'marker' => 'C', 'negate' => true]; + $line = ConditionCodec::encode($condition); + expect($line)->toBe('!map_event:garden-of-roads/lanternrest-wayhouse:C') + ->and(ConditionCodec::decode($line))->toBe($condition) + ->and(ConditionCodec::decode('map_event:garden-of-roads/lanternrest-wayhouse'))->toBeNull() + ->and(fn() => ConditionCodec::decodeAllStrictly('map_event:town'))->toThrow(InvalidArgumentException::class) + ->and(ConditionEditor::describe($condition))->toBe('NOT Map Event garden-of-roads/lanternrest-wayhouse event C is done'); + $grammar = array_find(ConditionEditor::getGrammar(), static fn(array $type): bool => $type['type'] === 'map_event'); + expect($grammar['nameCategory'])->toBe('maps') + ->and(array_column($grammar['extras'], 'key'))->toBe(['marker']); +}); + +it('sets a map event condition\'s event as picked from its map', function () { + $editor = conditionEditorOn('map_event:town:A'); + $editor->setMarker('C'); + expect($editor->encoded())->toBe('map_event:town:C'); + // Only a map event carries a marker. + $other = conditionEditorOn('switch:lamp'); + $other->setMarker('C'); + expect($other->encoded())->toBe('switch:lamp'); +}); diff --git a/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php b/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php new file mode 100644 index 00000000..0df929eb --- /dev/null +++ b/tests/Unit/Cutscenes/ArrayEntryInsertionTest.php @@ -0,0 +1,150 @@ + $path + */ +function insertEntry(string $source, array $path, int $position, int|string|null $key, string $literal): string +{ + $document = PhpArraySourceDocument::parse($source); + + return $document->withEdits([$document->insertEntryEdit($path, $position, $key, $literal)])->source; +} + +it('opens an empty array after a key onto lines at the surrounding indentation', function (string $step) { + $source = " 'x',\n{$step}'upgrades' => [\n{$step}{$step}'migrations' => [],\n{$step}],\n];\n"; + + expect(insertEntry($source, ['upgrades', 'migrations'], 0, null, "'v1'")) + ->toBe(" 'x',\n{$step}'upgrades' => [\n{$step}{$step}'migrations' => [\n{$step}{$step}{$step}'v1',\n{$step}{$step}],\n{$step}],\n];\n") + ->and(insertEntry($source, ['upgrades', 'migrations'], 0, '1.1.0', "'Upgrade'")) + ->toContain("{$step}{$step}'migrations' => [\n{$step}{$step}{$step}'1.1.0' => 'Upgrade',\n{$step}{$step}],\n{$step}],"); +})->with([ + 'four spaces' => ' ', + 'two spaces' => ' ', + 'tabs' => "\t", +]); + +it('opens an empty root array and a one-line parent child at the indentation step of the file', function () { + expect(insertEntry("toBe(" 1,\n];\n") + ->and(insertEntry(" ['list' => []],\n];\n", ['box', 'list'], 0, null, '1')) + ->toBe(" ['list' => [\n 1,\n ]],\n];\n"); +}); + +it('appends through the writer into an empty array after a key at the surrounding indentation', function () { + $source = " 'x',\n 'migrations' => [],\n];\n"; + $old = ['name' => 'x', 'migrations' => []]; + $new = ['name' => 'x', 'migrations' => ['v1', 'v2']]; + + expect(ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source) + ->toBe(" 'x',\n 'migrations' => [\n 'v1',\n 'v2',\n ],\n];\n"); +}); + +it('appends to a one-line array inline, following its trailing-comma convention', function (string $before, int|string|null $key, string $literal, string $after) { + $source = " {$before}, // kept\n];\n"; + + expect(insertEntry($source, ['list'], 2, $key, $literal))->toBe(" {$after}, // kept\n];\n"); +})->with([ + 'list without a trailing comma' => ["['a', 'b']", null, "'c'", "['a', 'b', 'c']"], + 'list with a trailing comma' => ["['a', 'b',]", null, "'c'", "['a', 'b', 'c',]"], + 'keyed without a trailing comma' => ["['x' => 1, 'y' => 2]", 'z', '3', "['x' => 1, 'y' => 2, 'z' => 3]"], + 'keyed with a trailing comma' => ["['x' => 1, 'y' => 2, ]", 'z', '3', "['x' => 1, 'y' => 2, 'z' => 3, ]"], +]); + +it('inserts ahead of a one-line neighbour without carrying indentation onto the line', function () { + $source = " ['a', 'b'],\n];\n"; + + expect(insertEntry($source, ['list'], 0, null, "'c'"))->toBe(" ['c', 'a', 'b'],\n];\n") + ->and(insertEntry($source, ['list'], 1, null, "'c'"))->toBe(" ['a', 'c', 'b'],\n];\n"); +}); + +it('appends through the writer to a one-line array following its trailing-comma convention', function (string $before, string $after) { + $source = " {$before},\n];\n"; + + expect(ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), ['list' => ['a', 'b']], ['list' => ['a', 'b', 'c', 'd']])->source) + ->toBe(" {$after},\n];\n"); +})->with([ + 'without a trailing comma' => ["['a', 'b']", "['a', 'b', 'c', 'd']"], + 'with a trailing comma' => ["['a', 'b',]", "['a', 'b', 'c', 'd',]"], +]); + +it('appends after a last entry that shares its line with the closing bracket', function () { + $source = " [\n 'a'],\n 'next' => 1,\n];\n"; + + expect(insertEntry($source, ['list'], 1, null, "'b'"))->toBe(" [\n 'a', 'b'],\n 'next' => 1,\n];\n"); +}); + +it('inserts into an array that holds only a comment, after the comment', function () { + $source = " [\n // None yet.\n ],\n 'inline' => [/* none */],\n];\n"; + + expect(insertEntry($source, ['migrations'], 0, null, "'v1'")) + ->toBe(" [\n // None yet.\n 'v1',\n ],\n 'inline' => [/* none */],\n];\n") + ->and(insertEntry($source, ['inline'], 0, null, "'v1'")) + ->toBe(" [\n // None yet.\n ],\n 'inline' => [/* none */ 'v1'],\n];\n"); +}); + +it('appends after the last entry when the closing bracket shares its line, keeping the bracket and the comma style', function (string $comma) { + $source = " [\n ['type' => 'image', 'sheet' => ['columns' => 2],\n 'keyframes' => [['frame' => 0]]{$comma}],\n ['type' => 'text'],\n ],\n];\n"; + $old = eval('?>' . $source); + $new = $old; + $new['tracks'][0]['fit'] = 'contain'; + $new['tracks'][0]['depth'] = 'behind'; + $written = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + + expect($written)->toBe(" [\n ['type' => 'image', 'sheet' => ['columns' => 2],\n 'keyframes' => [['frame' => 0]],\n 'fit' => 'contain',\n 'depth' => 'behind'{$comma}],\n ['type' => 'text'],\n ],\n];\n") + ->and(eval('?>' . $written))->toBe($new); +})->with(['without a trailing comma' => '', 'with a trailing comma' => ',']); + +it('writes entries that land at one place in the order the new list holds them', function () { + // An edited command matched by its speaker's name further down leaves two new + // commands to go ahead of it at the same offset; they must keep their order. + $source = " 'text', 'name' => 'Ivara', 'text' => 'Old line.'],\n" + . " ['type' => 'text', 'name' => '', 'text' => 'Narration.'],\n ['type' => 'record_event', 'name' => 'done'],\n];\n"; + $old = require_once_source($source); + $new = [ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'first'], + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'second'], + ['type' => 'text', 'name' => 'Ivara', 'text' => 'New line.'], + ['type' => 'record_event', 'name' => 'done'], + ]; + + $written = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + + expect(require_once_source($written))->toBe($new); +}); + +it('keeps queued insertions at one offset in their queued order', function () { + $source = "withEdits([ + $document->insertEntryEdit([], 1, null, "'b'"), + $document->insertEntryEdit([], 1, null, "'c'"), + ])->source; + + expect(require_once_source($written))->toBe(['a', 'b', 'c', 'z']); +}); + +/** Evaluates a returned PHP array source without leaving a file behind. */ +function require_once_source(string $source): mixed +{ + $path = tempnam(sys_get_temp_dir(), 'ichiloto-source-'); + file_put_contents($path, $source); + try { + return require $path; + } finally { + unlink($path); + } +} diff --git a/tests/Unit/Cutscenes/ArraySourceWriterTest.php b/tests/Unit/Cutscenes/ArraySourceWriterTest.php index 22f0b677..22384b33 100644 --- a/tests/Unit/Cutscenes/ArraySourceWriterTest.php +++ b/tests/Unit/Cutscenes/ArraySourceWriterTest.php @@ -8,6 +8,10 @@ use Ichiloto\Editor\Cutscenes\Source\SourcePreservationRefusal; use Ichiloto\Editor\Cutscenes\Source\SourceUnreadable; use Ichiloto\Editor\Cutscenes\Source\SourceVariable; +use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked; +use Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit; + +require_once fixturePath('SourceWriterEnums.php'); /** * Editing an authored PHP array file in place. @@ -299,6 +303,57 @@ function rewriteAuthored(callable $change): string expect(ArraySourceWriter::rewrite($document, $old, $new)->source)->toBe(str_replace("'plain' => 1", "'plain' => 2", $source)); }); +it('retains existing non-tile list identity and comment rules without treating arbitrary coordinates as ids', function () { + $source = <<<'PHP' + [ + // The named NPC moves with its source. + ['name' => 'Mira', 'column' => 0, 'row' => 0, 'text' => 'first'], + // The other NPC. + ['name' => 'Tari', 'column' => 1, 'row' => 0, 'text' => 'second'], +], 'keyframes' => [ + // This is an edited position, not a new tile identity. + ['column' => 0, 'row' => 0, 'duration' => 10], + ['column' => 1, 'row' => 0, 'duration' => 20], +]]; +PHP; + $old = evaluateSource($source); + $new = $old; + $new['npcs'] = [$old['npcs'][1], $old['npcs'][0]]; + $new['npcs'][1]['text'] = 'edited'; + $new['npcs'][1]['column'] = 8; + $new['keyframes'][0]['column'] = 9; + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + expect(evaluateSource($rewritten))->toBe($new) + ->and($rewritten)->toContain("// The named NPC moves with its source.\n ['name' => 'Mira', 'column' => 8, 'row' => 0, 'text' => 'edited']") + ->and($rewritten)->toContain("// This is an edited position, not a new tile identity.\n ['column' => 9"); +}); + +it('removes only an indented inline entry and its heading in other existing list consumers', function (string $key) { + $source = " [\n // Removed entry owns this heading.\n ['id'=>'first'], ['id'=>'second'], ['id'=>'third']]];\n"; + $old = evaluateSource($source); + $new = $old; + array_shift($new[$key]); + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + expect(evaluateSource($rewritten))->toBe($new) + ->and($rewritten)->toBe(str_replace(" // Removed entry owns this heading.\n ['id'=>'first'],", '', $source)); +})->with(['npcs', 'tracks', 'cues']); + +it('removes an entry that shares a line with the one before it without leaving spaces behind', function (array $remove, string $expected) { + $source = " 'Bat',\n 'position' => [15, 7], 'placement' => ['x' => 1], 'floor' => 25,\n ],\n];\n"; + $old = evaluateSource($source); + $new = $old; + foreach ($remove as $key) { unset($new[0][$key]); } + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + expect(evaluateSource($rewritten))->toBe($new) + ->and($rewritten)->toContain($expected . "\n") + ->and($rewritten)->not->toMatch('/[ \t]\n/'); +})->with([ + 'last on its line' => [['floor'], " 'position' => [15, 7], 'placement' => ['x' => 1],"], + 'between two' => [['placement'], " 'position' => [15, 7], 'floor' => 25,"], + 'two neighbours together' => [['placement', 'floor'], " 'position' => [15, 7],"], +]); + it('rewrites the real Last Legend summon timelines to themselves and back from any change', function () { $game = gameSourceRoot(); @@ -325,15 +380,24 @@ function rewriteAuthored(callable $change): string // patched in place and evaluates exactly. expect(ArraySourceWriter::rewrite($document, $evaluated, $evaluated)->source)->toBe($source); + // Every sequence the file authors: its own tracks, or each + // presentation's when it pairs terminal and graphical ones. $new = $evaluated; - - foreach ($new['tracks'] as &$track) { - foreach ($track['keyframes'] as &$keyframe) { - $keyframe['frame'] = $keyframe['frame'] + 1; + $shift = static function (array $sequence): array { + foreach ($sequence['tracks'] ?? [] as $track => $authored) { + foreach ($authored['keyframes'] ?? [] as $keyframe => $key) { + $sequence['tracks'][$track]['keyframes'][$keyframe]['frame'] = $key['frame'] + 1; + } } + + return $sequence; + }; + $new = $shift($new); + foreach (array_keys($new['presentations'] ?? []) as $presentation) { + $new['presentations'][$presentation] = $shift($new['presentations'][$presentation]); } - unset($track, $keyframe); + expect($new)->not->toBe($evaluated); $rewritten = ArraySourceWriter::rewrite($document, $evaluated, $new); expect(evaluateSource($rewritten->source))->toBe($new) @@ -343,3 +407,418 @@ function rewriteAuthored(callable $change): string expect($seen)->toBeGreaterThan(0); })->group('engine'); + +it('puts a keyed entry back where the new value orders it, so a removal and its undo leave the bytes', function () { + $source = " 'armor',\n // The armor's type.\n 'equipmentType' => 'Shield',\n 'price' => 10,\n 'inline' => ['type' => 'text', 'name' => '', 'text' => 'A note.'],\n];\n"; + $full = evaluateSource($source); + $without = $full; + unset($without['equipmentType'], $without['inline']['name']); + + $removed = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $full, $without); + $restored = ArraySourceWriter::rewrite($removed, $without, $full); + + expect($removed->source)->toBe(" 'armor',\n 'price' => 10,\n 'inline' => ['type' => 'text', 'text' => 'A note.'],\n];\n") + ->and(evaluateSource($restored->source))->toBe($full) + // The comment went with its entry, so only that is not back. + ->and($restored->source)->toBe(str_replace(" // The armor's type.\n", '', $source)); +}); + +it('cuts the entries of an array written on one line with the separators that joined them', function (array $remove, string $expected, bool $restoresBytes) { + $source = " 'text', 'name' => '', 'text' => 'A note.']];\n"; + $old = evaluateSource($source); + $new = $old; + + foreach ($remove as $key) { + unset($new[0][$key]); + } + + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new); + + expect($rewritten->source)->toBe("and(evaluateSource($rewritten->source))->toBe($new); + + // Put back, the line reads as it did; an array left empty has no line + // to follow, so it opens onto lines of its own. + $restored = ArraySourceWriter::rewrite($rewritten, $new, $old)->source; + + expect(evaluateSource($restored))->toBe($old) + ->and($restored === $source)->toBe($restoresBytes); +})->with([ + 'the first' => [['type'], "['name' => '', 'text' => 'A note.']", true], + 'one between' => [['name'], "['type' => 'text', 'text' => 'A note.']", true], + 'the last two' => [['name', 'text'], "['type' => 'text']", true], + 'every one' => [['type', 'name', 'text'], '[]', false], +]); + +it('rewrites backed and unit enum literals without changing class spelling comments or key order', function ( + string $import, string $expression, string $replacement, UnitEnum $first, UnitEnum $second, +) { + $source = " 'kept',\n // Authored case heading.\n" + . " 'case' => {$expression}, // Authored tail.\n" + . " 'after' => strtoupper('kept'),\n);\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = $old; + $new['case'] = $second; + expect($old['case'])->toBe($first); + $rewritten = ArraySourceWriter::rewrite($document, $old, $new); + expect($rewritten->source)->toBe(str_replace($expression, $replacement, $source)) + ->and(evaluateSource($rewritten->source))->toBe($new) + ->and(array_keys(evaluateSource($rewritten->source)))->toBe(['before', 'case', 'after']) + ->and($document->source)->toBe($source) + ->and(ArraySourceWriter::rewrite($rewritten, $new, $old)->source)->toBe($source); +})->with([ + 'backed import' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked;', + 'SourceWriterBacked::FIRST', 'SourceWriterBacked::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'unit import' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit;', + 'SourceWriterUnit::FIRST', 'SourceWriterUnit::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'backed alias' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice;', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'case-insensitive alias' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice;', + 'choice::FIRST', 'choice::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'inline import' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice; /* kept */', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'commented import' => ['use /* class */ Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit /* alias */ as Choice;', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'unit alias' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit as Choice;', + 'Choice::FIRST', 'Choice::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'namespace alias' => ['use Ichiloto\Editor\Tests\Fixtures as Fixtures;', + 'Fixtures\SourceWriterUnit::FIRST', 'Fixtures\SourceWriterUnit::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'backed fully qualified' => ['', '\Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked::FIRST', + '\Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked::SECOND', SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'unit fully qualified' => ['', '\Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit::FIRST', + '\Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit::SECOND', SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], + 'inter-token comments' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterBacked as Choice;', + "Choice /* class */ :: /* case */ FIRST", "Choice /* class */ :: /* case */ SECOND", SourceWriterBacked::FIRST, SourceWriterBacked::SECOND], + 'inter-token newlines' => ['use Ichiloto\Editor\Tests\Fixtures\SourceWriterUnit as Choice;', + "Choice\n ::\n FIRST", "Choice\n ::\n SECOND", SourceWriterUnit::FIRST, SourceWriterUnit::SECOND], +]); + +it('preserves byte-exact enum source on no-op and refuses scalar backing-value replacements', function (mixed $replacement) { + $source = " Choice::FIRST, 'kept' => 1];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + expect(ArraySourceWriter::rewrite($document, $old, $old))->toBe($document); + $new = $old; + $new['case'] = $replacement; + expect(fn() => ArraySourceWriter::rewrite($document, $old, $new))->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +})->with([ + 'backing value' => ['second'], + 'integer' => [1], +]); + +it('preserves enum literal comments during explicit nullable and cross-enum data replacements', function (?UnitEnum $replacement) { + $source = " Choice /* class */ :: /* case */ FIRST, 'kept' => 1];\n"; + $old = evaluateSource($source); + $new = $old; + $new['case'] = $replacement; + $document = PhpArraySourceDocument::parse($source); + $rewritten = ArraySourceWriter::rewrite($document, $old, $new); + expect(evaluateSource($rewritten->source))->toBe($new) + ->and($rewritten->source)->toContain('/* class */', '/* case */', "'kept' => 1") + ->and($document->source)->toBe($source); +})->with(['nullable' => [null], 'another enum' => [SourceWriterUnit::SECOND]]); + +it('refuses computed enum values and noncase constants rather than flattening their expressions', function (string $expression) { + $source = " {$expression}, 'plain' => 1];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = $old; + $new['case'] = SourceWriterBacked::SECOND; + expect($old['case'])->toBe(SourceWriterBacked::FIRST) + ->and(fn() => ArraySourceWriter::rewrite($document, $old, $new))->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); + $new = $old; + $new['plain'] = 2; + expect(ArraySourceWriter::rewrite($document, $old, $new)->source)->toBe(str_replace("'plain' => 1", "'plain' => 2", $source)); +})->with([ + "Choice::from('first')", + "Choice::tryFrom('first')", + 'Choice::ALIAS', + 'Constants::CASE_ALIAS', + 'Constants::FIRST', + "constant(Choice::class . '::FIRST')", + 'true ? Choice::FIRST : Choice::SECOND', + '(Choice::FIRST)', +]); + +it('refuses unresolved class names and unknown constants without evaluating them', function (string $expression) { + $source = " {$expression}];\n"; + $document = PhpArraySourceDocument::parse($source); + expect(fn() => ArraySourceWriter::rewrite($document, ['case' => SourceWriterBacked::FIRST], ['case' => SourceWriterBacked::SECOND])) + ->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +})->with(['Choice::UNKNOWN_CASE', 'UnknownChoice::FIRST', '\UnloadedUnknownChoice::FIRST']); + +it('requires the loaded enum case to match the authored literal before rewriting it', function () { + $source = " Choice::FIRST];\n"; + $document = PhpArraySourceDocument::parse($source); + expect(fn() => ArraySourceWriter::rewrite($document, ['case' => SourceWriterBacked::SECOND], ['case' => SourceWriterBacked::FIRST])) + ->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +}); + +it('does not mistake imports inside authored text or another namespace for actual enum imports', function (string $source) { + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = $old; + $new['case'] = SourceWriterBacked::SECOND; + expect(fn() => ArraySourceWriter::rewrite($document, $old, $new))->toThrow(SourcePreservationRefusal::class, 'case') + ->and($document->source)->toBe($source); +})->with([ + 'nowdoc' => <<<'PHP' + Choice::FIRST, 'documentation' => $documentation]; + PHP, + 'comment' => <<<'PHP' + Choice::FIRST]; + PHP, + 'previous namespace' => <<<'PHP' + Choice::FIRST]; + PHP, +]); + +it('keeps existing backed enum value expressions editable without switching them to enum objects', function () { + $source = " Choice::FIRST->value];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + $new = ['case' => 'second']; + $rewritten = ArraySourceWriter::rewrite($document, $old, $new)->source; + expect($rewritten)->toBe(str_replace('Choice::FIRST->value', 'Choice::SECOND->value', $source)) + ->and(evaluateSource($rewritten))->toBe($new) + ->and(fn() => ArraySourceWriter::rewrite($document, $old, ['case' => SourceWriterBacked::SECOND])) + ->toThrow(SourcePreservationRefusal::class, 'case'); +}); + +it('replaces a plain-data array with a single value and keeps an array holding a comment', function () { + $plain = " 1, // stays\n 'terminal' => ['color' => 'yellow'],\n];\n"; + $document = PhpArraySourceDocument::parse($plain); + $old = evaluateSource($plain); + $rewritten = ArraySourceWriter::rewrite($document, $old, [...$old, 'terminal' => 'm'])->source; + expect($rewritten)->toBe(str_replace("['color' => 'yellow']", "'m'", $plain)); + + $commented = " [\n // chosen with Andrew\n 'color' => 'yellow',\n ],\n];\n"; + $old = evaluateSource($commented); + expect(fn() => ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($commented), $old, ['terminal' => null])) + ->toThrow(SourcePreservationRefusal::class, 'cannot be replaced by a single value'); +}); + +// -- Variables: written as data, or built in code ------------------------------ + +/** + * Rewrites a source so it evaluates to a changed array, proving it does. + * + * @return string The rewritten source. + */ +function rewriteSource(string $source, callable $change): string +{ + $old = evaluateSource($source); + $new = $old; + $change($new); + $rewritten = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + + expect(evaluateSource($rewritten))->toBe($new); + + return $rewritten; +} + +it('refuses a change under a variable the file builds in code, naming it, and keeps every change beside it', function (string $prelude, string $variable, array $path, mixed $value) { + $source = " \${$variable},\n 'plain' => 1,\n];\n"; + $document = PhpArraySourceDocument::parse($source); + $old = evaluateSource($source); + + $new = $old; + $target = &$new; + foreach ($path as $step) { + $target = &$target[$step]; + } + $target = $value; + unset($target); + + expect(static fn() => ArraySourceWriter::rewrite($document, $old, $new)) + ->toThrow(SourcePreservationRefusal::class, '$' . $variable . ', which the file\'s own code builds or uses'); + + // Nothing under it was flattened by a no-op, and the plain entry beside it still edits. + expect(ArraySourceWriter::rewrite($document, $old, $old)->source)->toBe($source); + $new = $old; + $new['plain'] = 2; + expect(ArraySourceWriter::rewrite($document, $old, $new)->source)->toBe(str_replace("'plain' => 1", "'plain' => 2", $source)); +})->with([ + 'filled by a loop' => ["\$slots = [];\nforeach (['a', 'b'] as \$role) {\n \$slots[\$role] = ['weapon' => ['attack' => 1]];\n}", 'slots', ['built', 'a', 'weapon', 'attack'], 3], + 'a literal changed later' => ["\$slots = ['a' => ['attack' => 1]];\n\$slots['b'] = ['attack' => 2];", 'slots', ['built', 'a', 'attack'], 3], + 'a literal code reads' => ["\$slots = ['a' => 1];\n\$count = count(\$slots);", 'slots', ['built', 'a'], 2], + 'a call' => ["\$slots = array_fill_keys(['a', 'b'], 1);", 'slots', ['built', 'a'], 2], + 'computed text' => ["\$slots = strtoupper('quiet');", 'slots', ['built'], 'LOUD'], + 'interpolated text' => ["\$name = 'Mira';\n\$slots = << ["\$slots = [];\nforeach (['a'] as \$role) {\n \$slots[\$role] = 1;\n}", 'slots', ['built'], ['b' => 2]], +]); + +it('refuses a variable the file never assigns at the top level', function () { + $source = " PhpArraySourceDocument::parse($source))->toThrow(SourceUnreadable::class); + + $source = " \$undefined ?? [],\n];\n"; + $document = PhpArraySourceDocument::parse($source); + expect(static fn() => ArraySourceWriter::rewrite($document, ['built' => []], ['built' => ['a' => 1]])) + ->toThrow(SourcePreservationRefusal::class, 'expression'); +}); + +it('edits a literal only one entry names where it is assigned, comments and reference intact', function () { + $source = <<<'PHP_SOURCE' + 25, + 'lengthFrames' => 18, + 'tracks' => [[ + 'id' => 'aura', + 'keyframes' => [ + ['frame' => 0, 'duration' => 3, 'content' => '* *'], + ], + ]], + // No sound cues yet. + 'cues' => [], +]; + +return [ + 'presentations' => [ + 'terminal' => $terminal, + ], +]; +PHP_SOURCE; + + $rewritten = rewriteSource($source, static function (array &$new): void { + $new['presentations']['terminal']['fps'] = 12; + $new['presentations']['terminal']['playback'] = 'once'; + $new['presentations']['terminal']['tracks'][0]['keyframes'][] = ['frame' => 3, 'duration' => 3, 'content' => "+\n+"]; + unset($new['presentations']['terminal']['lengthFrames']); + }); + + expect($rewritten)->toContain(" 'terminal' => \$terminal,\n") + ->and($rewritten)->toContain("// Field cadence, kept apart for reading.\n") + ->and($rewritten)->toContain(" // No sound cues yet.\n 'cues' => [],\n") + ->and($rewritten)->toContain(" 'fps' => 12,\n") + ->and($rewritten)->toContain(" 'playback' => 'once',\n") + ->and($rewritten)->not->toContain('lengthFrames') + // New art is a nowdoc defined ahead of the literal that names it. + ->and(strpos($rewritten, "\$keyframes_1_content = <<<'ART'\n+\n+\nART;"))->toBeLessThan(strpos($rewritten, '$terminal = [')) + ->and($rewritten)->toContain("['frame' => 3, 'duration' => 3, 'content' => \$keyframes_1_content],"); +}); + +it('edits a scalar literal only one entry names where it is assigned', function () { + $source = " \$speed,\n];\n"; + expect(rewriteSource($source, static function (array &$new): void { $new['speed'] = 4; })) + ->toBe(str_replace('$speed = 3;', '$speed = 4;', $source)); +}); + +it('gives one entry its own copy of plain data several entries share, and refuses a copy that would lose source', function () { + $shared = " 'event', 'name' => 'cards_found'];\n\nreturn [\n 'brann' => ['conditions' => [\$found]],\n 'vale' => ['conditions' => [\$found]],\n];\n"; + $rewritten = rewriteSource($shared, static function (array &$new): void { + $new['brann']['conditions'][0]['name'] = 'brann_found'; + }); + + expect($rewritten)->toContain("\$found = ['type' => 'event', 'name' => 'cards_found'];") + ->and($rewritten)->toContain("'vale' => ['conditions' => [\$found]],") + ->and($rewritten)->toContain("'brann' => ['conditions' => [['type' => 'event', 'name' => 'brann_found']]],"); + + $commented = str_replace("['type' => 'event', 'name' => 'cards_found'];", "[\n // The shared gate.\n 'type' => 'event',\n 'name' => 'cards_found',\n];", $shared); + $document = PhpArraySourceDocument::parse($commented); + $old = evaluateSource($commented); + $new = $old; + $new['brann']['conditions'][0]['name'] = 'brann_found'; + + expect(static fn() => ArraySourceWriter::rewrite($document, $old, $new)) + ->toThrow(SourcePreservationRefusal::class, '$found, which 2 values share'); +}); + +it('counts a literal named inside another literal as shared, so neither edit changes the other', function () { + $source = " 'event', 'name' => 'met'];\n\$scene = ['when' => \$gate, 'line' => 'Hello'];\n\nreturn [\n 'scene' => \$scene,\n 'gate' => \$gate,\n];\n"; + $document = PhpArraySourceDocument::parse($source); + + expect($document->referenceCount('gate'))->toBe(2) + ->and($document->variable('scene')?->kind)->toBe(SourceVariable::LITERAL); + + $rewritten = rewriteSource($source, static function (array &$new): void { + $new['scene']['when']['name'] = 'left'; + $new['scene']['line'] = 'Goodbye'; + }); + + expect($rewritten)->toContain("\$gate = ['type' => 'event', 'name' => 'met'];") + ->and($rewritten)->toContain("\$scene = ['when' => ['type' => 'event', 'name' => 'left'], 'line' => 'Goodbye'];") + ->and($rewritten)->toContain(" 'gate' => \$gate,\n"); +}); + +it('plans a moving list entry\'s own removals inside the bytes it carries', function () { + $source = " [\n ['id' => 'a', 'points' => [['x' => 1], ['x' => 2], ['x' => 3]]],\n ['id' => 'b'],\n ['id' => 'c'],\n ],\n];\n"; + $old = evaluateSource($source); + // b and c keep their order, so a is the entry that moves, losing points as it goes. + $new = ['steps' => [['id' => 'b'], ['id' => 'c'], ['id' => 'a', 'points' => [['x' => 2]]]]]; + + expect(evaluateSource(ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source))->toBe($new); +}); + +it('aligns a value with the key order its source writes, lists keeping their own', function () { + $document = PhpArraySourceDocument::parse(" 'here', 'waypoints' => [['y' => 1, 'x' => 2]]];\nreturn ['type' => 'move', 'route' => \$route, 'list' => [3, 1]];\n"); + + expect($document->alignValue(['list' => [1, 3], 'route' => ['waypoints' => [['x' => 2, 'y' => 1]], 'remember' => 'here'], 'extra' => true, 'type' => 'move'])) + ->toBe(['type' => 'move', 'route' => ['remember' => 'here', 'waypoints' => [['y' => 1, 'x' => 2]]], 'list' => [1, 3], 'extra' => true]); +}); + +it('writes a new entry\'s long list of small values one per line, and keeps a short one on its line', function () { + $source = " [\n ],\n];\n"; + $old = ['branches' => []]; + $nodes = array_map(static fn(int $grade): array => ['id' => "spark-{$grade}", 'kind' => 'application', 'grade' => $grade, 'skill' => "Spark {$grade}"], range(1, 3)); + $new = ['branches' => [['id' => 'storm', 'tags' => ['a', 'b'], 'nodes' => $nodes]]]; + + $written = \Ichiloto\Editor\Cutscenes\Source\ArraySourceWriter::rewrite(\Ichiloto\Editor\Cutscenes\Source\PhpArraySourceDocument::parse($source), $old, $new)->source; + + $path = tempnam(sys_get_temp_dir(), 'ichiloto-writer-') . '.php'; + file_put_contents($path, $written); + $read = require $path; + unlink($path); + + expect($read)->toBe($new) + ->and($written)->toContain("'tags' => ['a', 'b'],") + ->and($written)->toContain(" ['id' => 'spark-1', 'kind' => 'application', 'grade' => 1, 'skill' => 'Spark 1'],\n") + ->and(max(array_map('strlen', explode("\n", $written))))->toBeLessThan(110); +}); + +it('writes a one-line list that grows past the width an entry per line, and leaves one that still fits on its line', function () { + $source = " ['a'],\n 'nodes' => [['id' => 'warp', 'kind' => 'advanced', 'skill' => 'Warp']],\n];\n"; + $old = ['short' => ['a'], 'nodes' => [['id' => 'warp', 'kind' => 'advanced', 'skill' => 'Warp']]]; + $new = ['short' => ['a', 'b'], 'nodes' => [['id' => 'accelerate', 'kind' => 'application', 'skill' => 'Accelerate'], + ['id' => 'crawl', 'kind' => 'application', 'skill' => 'Crawl'], ['id' => 'warp', 'kind' => 'advanced', 'skill' => 'Warp']]]; + + $written = ArraySourceWriter::rewrite(PhpArraySourceDocument::parse($source), $old, $new)->source; + $path = tempnam(sys_get_temp_dir(), 'ichiloto-writer-') . '.php'; + file_put_contents($path, $written); + $read = require $path; + unlink($path); + + expect($read)->toBe($new) + ->and($written)->toContain("'short' => ['a', 'b'],") + ->and($written)->toContain(" ['id' => 'crawl', 'kind' => 'application', 'skill' => 'Crawl'],\n") + ->and(max(array_map('strlen', explode("\n", $written))))->toBeLessThan(110); +}); diff --git a/tests/Unit/Cutscenes/CinematicEndToEndTest.php b/tests/Unit/Cutscenes/CinematicEndToEndTest.php index dfaf51ea..98f66865 100644 --- a/tests/Unit/Cutscenes/CinematicEndToEndTest.php +++ b/tests/Unit/Cutscenes/CinematicEndToEndTest.php @@ -4,7 +4,7 @@ use Ichiloto\Editor\Cutscenes\CutsceneType; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\UI\CutscenesScreen; use Ichiloto\Editor\Validation\Issue; @@ -261,25 +261,16 @@ $cutsceneIssues = array_values(array_filter($issues, static fn(Issue $issue): bool => str_starts_with($issue->where, 'cinematic '))); expect(array_map(static fn(Issue $issue): string => $issue->message, $cutsceneIssues))->toBe([]); - // 16. The playtest route: an overlay whose start map launches the cinematic on arrival. - file_put_contents($root . '/assets/Data/system.php', " ['members' => ['Kaelion']], 'startingPositions' => ['player' => ['destinationMap' => 'harbour', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['^']]]];\n"); - $overlay = PlaytestOverlay::createForCinematic($root, 'skyfield-night', 2, 2, 'dawn-crossing'); + // 16. The playtest route: a new game on the cinematic's origin that the Engine plays it on, the project untouched. + $mapFiles = sourceHashTree($root . '/assets/Maps'); + $launch = PlaytestLaunch::fromCinematic($root, 'skyfield-night', 2, 2, 'dawn-crossing'); try { - $mapData = require $overlay->root . '/assets/Maps/skyfield-night/skyfield-night.data.php'; - $eventLayer = require $overlay->root . '/assets/Maps/skyfield-night/skyfield-night.event.php'; - $triggers = array_filter($mapData['events'], static fn(array $event): bool => str_contains($event['class'], 'CinematicEventTrigger')); - $marker = array_key_first($triggers); - expect($triggers)->toHaveCount(1) - ->and($triggers[$marker]['data'])->toBe(['cinematicId' => 'dawn-crossing', 'mode' => 'auto', 'reusable' => false]) - ->and(explode("\n", $eventLayer)[2][2])->toBe($marker) - ->and(is_link($overlay->root . '/assets/Maps/skyfield-night/skyfield-night.map.php'))->toBeTrue() - ->and(is_link($overlay->root . '/assets/Maps/skyfield-dawn'))->toBeTrue() - ->and(is_link($overlay->root . '/assets/Cutscenes'))->toBeTrue(); - // The real project's map is untouched. - expect(file_get_contents($root . '/assets/Maps/skyfield-night/skyfield-night.event.php'))->not->toContain($marker === ' ' ? 'never' : $marker . ' '); + expect($launch->getContext())->toBe(['mode' => 'new-game', 'start' => ['mapId' => 'skyfield-night', 'x' => 2, 'y' => 2], + 'cinematicId' => 'dawn-crossing', 'dataRoot' => $launch->dataRoot]) + ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapFiles); } finally { - $overlay->destroy(); + $launch->destroy(); } // 17. & 18. Exactly-once completion, and the session released so a save may follow. diff --git a/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php b/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php new file mode 100644 index 00000000..d2cd4b02 --- /dev/null +++ b/tests/Unit/Cutscenes/CinematicNarrationPresentationTest.php @@ -0,0 +1,252 @@ +}> $messages */ +function getNarrationPreviewFrame(array $messages): array +{ + $frames = array_values(array_filter($messages, static fn(array $message): bool => $message['type'] === 'frame')); + $last = $frames[array_key_last($frames)]['payload'] ?? throw new RuntimeException('No preview frame was sent.'); + if (! $last['present']) { + throw new RuntimeException('The preview frame upload has not finished.'); + } + $operations = []; + $reset = false; + $viewport = []; + // A retained frame may span several packets, with reset and images before the final present packet. + foreach ($frames as $message) { + $packet = $message['payload']; + if ($packet['frame'] !== $last['frame']) { + continue; + } + array_push($operations, ...$packet['operations']); + $reset = $reset || $packet['reset']; + if (array_key_exists('viewport', $packet)) { + $viewport = ['viewport' => $packet['viewport']]; + } + } + + return [...$last, 'operations' => $operations, 'reset' => $reset, ...$viewport]; +} + +/** Returns the objects written by this frame, not objects retained from earlier frames. */ +function getNarrationPreviewObjects(array $frame, string $kind): array +{ + return array_values(array_map(static fn(array $operation): array => $operation['value'], + array_filter($frame['operations'], static fn(array $operation): bool => $operation['op'] === 'put' + && $operation['kind'] === $kind))); +} + +function acknowledgeNarrationPreviewFrame(array $frame): string +{ + return json_encode(['protocol' => 2, 'type' => 'frame_ack', 'generation' => $frame['generation'], + 'frame' => $frame['frame'], 'presented' => true], JSON_THROW_ON_ERROR); +} + +beforeEach(function () { + $this->root = cutsceneProject(); + writeOpeningMaps($this->root); + $directory = $this->root . '/assets/Data/Presentation'; + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + $artDirectory = $this->root . '/assets/Graphics/UI'; + if (! is_dir($artDirectory)) { + mkdir($artDirectory, 0777, true); + } + writeTilesetTestPng($artDirectory . '/narration-frame.png', 12, 12); + $frame = ['asset' => 'Graphics/UI/narration-frame.png', 'cuts' => [2, 2, 2, 2]]; + file_put_contents($directory . '/dialogue.php', ' 'ichiloto.dialogue/1', + 'theme' => ['schema' => 'ichiloto.menu/1', 'colors' => ['text' => [31, 79, 113]], + 'frames' => ['dialogue' => $frame, 'nameplate' => $frame]], + ], true) . ';'); + $this->text = "Synthetic narration stays readable while the camera travels across the field.\n" + . 'A separate line belongs to the same timed cue.'; + $this->definition = CinematicDefinition::fromArrays([ + 'id' => 'preview-narration', 'name' => 'Preview Narration', 'startMap' => 'skyfield-night', + ], [ + ['type' => 'parallel', 'lanes' => [ + ['id' => 'words', 'commands' => [['type' => 'narration', 'title' => 'Preview narrator', + 'text' => $this->text, 'seconds' => 20.0]]], + // Begin the pan after READY has selected the graphical field's viewport. + ['id' => 'camera', 'commands' => [ + ['type' => 'wait', 'seconds' => 0.2], + ['type' => 'camera', 'operation' => 'pan', + 'target' => ['kind' => 'position', 'x' => 45, 'y' => 8], 'seconds' => 2.0], + ]], + ]], + ]); +}); + +it('uses the themed wide dialogue reading area without controls or duplicate ASCII while the field camera advances', function () { + $preview = CinematicPreviewSession::start($this->root, $this->definition, + ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + expect($preview->failure())->toBeNull(); + $first = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + $images = getNarrationPreviewObjects($first, 'canvas_image'); + $body = array_values(array_filter($images, static fn(array $image): bool => str_contains($image['id'], 'dialogue-body'))); + expect($body)->not->toBeEmpty(); + foreach ($body as $image) { + expect($image['asset'])->toBe('Graphics/UI/narration-frame.png'); + } + + $text = getNarrationPreviewObjects($first, 'canvas_text'); + $prose = array_find($text, static fn(array $layer): bool => str_ends_with($layer['id'], 'dialogue-text')); + expect($prose)->not->toBeNull(); + $grid = $preview->getSceneGrid(); + $width = $grid->columns * $grid->cellWidth; + $height = $grid->rows * $grid->cellHeight; + $bounds = $prose['clipRect']; + expect($bounds['width'])->toBeGreaterThan($width * 0.75) + ->and($bounds['width'])->toBeGreaterThan($bounds['height'] * 3) + ->and($bounds['x'])->toBeGreaterThanOrEqual(0) + ->and($bounds['x'] + $bounds['width'])->toBeLessThanOrEqual($width) + ->and($bounds['y'])->toBeGreaterThanOrEqual(0) + ->and($bounds['y'] + $bounds['height'])->toBeLessThanOrEqual($height) + ->and(implode("\n", array_column($prose['runs'], 'text')))->toBe($this->text) + ->and($prose['runs'][0]['foreground'])->toBe(['kind' => 'rgb', 'r' => 31, 'g' => 79, 'b' => 113]); + expect(array_any([...$text, ...$images], static fn(array $object): bool => preg_match( + '/dialogue-(auto|advance|ready)/', $object['id']) === 1))->toBeFalse(); + $scalarText = getNarrationPreviewObjects($first, 'text'); + expect(array_column($scalarText, 'id'))->not->toContain('cinematic-overlay') + ->and(implode(' ', array_merge([], ...array_map(static fn(array $layer): array => array_column($layer['runs'], 'text'), $text)))) + ->not->toContain('Auto Off', 'Auto On', 'Continue', 'Reveal') + ->and(getNarrationPreviewObjects($first, 'world'))->not->toBeEmpty() + ->and($first['viewport']['worldId'])->not->toBeEmpty() + ->and($preview->confirm())->toBeFalse() + ->and($preview->hasActiveSession())->toBeTrue(); + + $before = $preview->snapshot()->values; + $preview->play(); + $preview->tick(0.5); + $preview->tick(0.5); + $next = getNarrationPreviewFrame($preview->exchangeScene([acknowledgeNarrationPreviewFrame($first)], $preview->getSceneSessionId())); + $after = $preview->snapshot()->values; + expect($after['camera'])->not->toBe($before['camera']) + ->and($after['player position'])->toBe($before['player position']) + ->and($after['map'])->toBe($before['map']) + ->and($next['viewport']['worldOrigin'])->not->toBe($first['viewport']['worldOrigin']) + ->and($next['viewport']['worldId'])->toBe($first['viewport']['worldId']) + ->and($preview->hasActiveSession())->toBeTrue(); + + $preview->stop(); + $stopped = getNarrationPreviewFrame($preview->exchangeScene([acknowledgeNarrationPreviewFrame($next)], $preview->getSceneSessionId())); + $removed = array_column(array_filter($stopped['operations'], static fn(array $operation): bool => $operation['op'] === 'remove'), 'id'); + expect($removed)->toContain($prose['id'], ...array_column($body, 'id')) + ->and($preview->status())->toBe(CinematicPreviewSession::STATUS_STOPPED) + ->and($preview->hasActiveSession())->toBeFalse() + ->and(implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame()))) + ->not->toContain('Synthetic narration', 'Preview narrator'); + } finally { + $preview->dispose(); + } +}); + +it('returns to terminal narration on detach and does not retain stopped text on reattachment', function () { + $preview = CinematicPreviewSession::start($this->root, $this->definition, + ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + $first = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + expect(getNarrationPreviewObjects($first, 'canvas_text'))->not->toBeEmpty(); + $preview->detachScene(); + $terminal = array_map(TerminalText::stripAnsi(...), $preview->frame()); + expect($terminal)->toHaveCount(30) + ->and(mb_strlen($terminal[0]))->toBe(100) + ->and(implode("\n", $terminal))->toContain('Synthetic narration', 'Preview narrator', 'A separate line') + ->and($preview->hasActiveSession())->toBeTrue(); + $preview->stop(); + $again = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + expect($again['reset'])->toBeTrue() + ->and(getNarrationPreviewObjects($again, 'canvas_text'))->toBeEmpty() + ->and(getNarrationPreviewObjects($again, 'canvas_image'))->toBeEmpty() + ->and(array_column(getNarrationPreviewObjects($again, 'text'), 'id'))->not->toContain('cinematic-overlay'); + } finally { + $preview->dispose(); + } +}); + +it('keeps terminal text without graphical theme objects when the preview window has no canvas capability', function () { + $preview = CinematicPreviewSession::start($this->root, $this->definition, + ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + $terminal = implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame())); + expect($terminal)->toContain('Synthetic narration', 'Preview narrator', 'A separate line'); + $frame = getNarrationPreviewFrame($preview->exchangeScene([ + json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => []], JSON_THROW_ON_ERROR), + ], $preview->getSceneSessionId())); + expect(getNarrationPreviewObjects($frame, 'canvas_text'))->toBeEmpty() + ->and(getNarrationPreviewObjects($frame, 'canvas_image'))->toBeEmpty(); + $overlay = array_find(getNarrationPreviewObjects($frame, 'text'), static fn(array $layer): bool => $layer['id'] === 'cinematic-overlay'); + expect($overlay)->not->toBeNull() + ->and(implode(' ', array_column($overlay['runs'], 'text')))->toContain('Synthetic narration', 'Preview narrator') + ->and($preview->hasActiveSession())->toBeTrue(); + } finally { + $preview->dispose(); + } +}); + +/** A second, plainer dialogue theme, so the measure is the preview's screen and not one theme's metrics. */ +function writePlainDialogueTheme(string $root): void +{ + file_put_contents($root . '/assets/Data/Presentation/dialogue.php', ' 'ichiloto.dialogue/1', + 'theme' => ['schema' => 'ichiloto.menu/1', 'colors' => ['text' => [240, 240, 240]], + 'frames' => ['dialogue' => ['asset' => 'Graphics/UI/narration-frame.png', 'cuts' => [4, 4, 4, 4]], + 'nameplate' => ['asset' => 'Graphics/UI/narration-frame.png', 'cuts' => [4, 4, 4, 4]]]], + ], true) . ';'); +} + +it('pages graphical dialogue on the wide text screen, not the few physical cells the field camera counts', function (bool $plainTheme, string $speaker) { + if ($plainTheme) { + writePlainDialogueTheme($this->root); + } + $definition = CinematicDefinition::fromArrays(['id' => 'preview-talk', 'name' => 'Preview Talk', 'startMap' => 'skyfield-night'], [ + // The line begins after READY has selected the graphical field, whose camera counts physical cells. + ['type' => 'wait', 'seconds' => 0.2], + ['type' => 'text', 'name' => $speaker, 'text' => "Second route selected\nThen the bridge."], + ['type' => 'choice', 'prompt' => 'Which way now?', 'options' => [ + ['text' => 'Along the river bank', 'then' => []], + ['text' => 'Over the hill', 'then' => []], + ]], + ]); + $preview = CinematicPreviewSession::start($this->root, $definition, ['x' => 10, 'y' => 8, 'width' => 100, 'height' => 30]); + + try { + $preview->step(); + $first = getNarrationPreviewFrame($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId())); + $preview->play(); + $preview->tick(0.2); + $preview->tick(0.1); + $frame = getNarrationPreviewFrame($preview->exchangeScene([acknowledgeNarrationPreviewFrame($first)], $preview->getSceneSessionId())); + $prose = array_find(getNarrationPreviewObjects($frame, 'canvas_text'), static fn(array $layer): bool => str_ends_with($layer['id'], 'dialogue-text')); + + // Each authored line stays whole; only the authored line break divides them. + expect($prose)->not->toBeNull() + ->and(array_column($prose['runs'], 'text'))->toBe(['Second route selected', 'Then the bridge.']); + + // The Terminal picture places the same window on the text screen, whole. + $preview->detachScene(); + $terminal = implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame())); + expect($terminal)->toContain('Second route selected', 'Then the bridge.'); + + $preview->confirm(); + $preview->tick(0.1); + $terminal = implode("\n", array_map(TerminalText::stripAnsi(...), $preview->frame())); + expect($preview->getWaitKind())->toBe('choice') + ->and($terminal)->toContain('Which way now?', '> Along the river bank', 'Over the hill'); + } finally { + $preview->dispose(); + } +})->with(['menu theme' => false, 'plain theme' => true])->with(['no speaker' => '', 'speaker' => 'Keeper']); diff --git a/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php b/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php index e27dcfb2..4a306077 100644 --- a/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php +++ b/tests/Unit/Cutscenes/CinematicPreviewSessionTest.php @@ -4,27 +4,23 @@ use Ichiloto\Editor\Cutscenes\CutsceneHydration; use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewSession; -use Ichiloto\Editor\ProjectDirectoryContext; +use Ichiloto\Engine\Util\Config\ConfigStore; +use Ichiloto\Engine\Util\Config\ProjectConfig; /** * The Engine plays a cinematic inside the editor: the interpreter, * controller, stage and presentation are the Engine's; the editor supplies a * clock, a frame buffer, and read-only views. */ -function harbourDefinition(string $root): \Ichiloto\Engine\Cutscenes\Cinematics\CinematicDefinition -{ - $folder = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns'; - $data = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.data.php'); - $script = ProjectDirectoryContext::run($root, static fn(): mixed => require $folder . '/harbour-lanterns.script.php'); - - return CutsceneHydration::cinematic($data, $script, $root); -} it('plays the harbour cinematic to completion through the Engine and records its final state', function () { $root = cutsceneProject(); $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); try { + expect(ConfigStore::get(ProjectConfig::class)->get('audio.music'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.sfx'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.voice'))->toBeFalse(); expect($preview->status())->toBe(CinematicPreviewSession::STATUS_PAUSED) ->and($preview->lanes())->not->toBe([]) ->and($preview->lanes()[0]['key'])->toBe('commands.0'); diff --git a/tests/Unit/Cutscenes/CinematicReferenceScopeTest.php b/tests/Unit/Cutscenes/CinematicReferenceScopeTest.php new file mode 100644 index 00000000..f2236349 --- /dev/null +++ b/tests/Unit/Cutscenes/CinematicReferenceScopeTest.php @@ -0,0 +1,27 @@ + 'beacon', 'anchor' => ['x' => 6, 'y' => 3], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]]; + file_put_contents($mapPath, " array_find($session->readDatabaseRecord('cutscenes/cinematic', 0)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [0] && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException('Missing cast row ' . $name); + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('Bind Visual To')['key'], 'world_object'); + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('Bound World Object')['key'], 'elsewhere')) + ->toThrow(SessionRefusal::class, 'Unknown map_world_objects'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('Bound World Object')['key'], 'beacon'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $dataPath)['cast'][0]['subject'])->toBe(['kind' => 'world_object', 'id' => 'beacon']); +}); diff --git a/tests/Unit/Cutscenes/CutsceneSourceHistoryTest.php b/tests/Unit/Cutscenes/CutsceneSourceHistoryTest.php new file mode 100644 index 00000000..47b7bb17 --- /dev/null +++ b/tests/Unit/Cutscenes/CutsceneSourceHistoryTest.php @@ -0,0 +1,127 @@ + 'ember-spark', + CutsceneType::SUMMON => 'lantern-wisp', + CutsceneType::CINEMATIC => 'harbour-lanterns', + }; + if ($type === CutsceneType::EFFECT) { + $path = $root . '/assets/Animations/' . $id . '/' . $id . '.timeline.php'; + $values = require $path; + file_put_contents($path, "ids($type), true); + $asset = $library->find($type, $id); + + return [$root, $library, $index, $asset]; +} + +function readCutsceneHistorySources(CutsceneAsset $asset): array +{ + return array_combine($asset->paths(), array_map(file_get_contents(...), $asset->paths())); +} + +function removeCutsceneHistoryBlock(CutsceneAsset $asset): void +{ + $payload = $asset->payload(); + switch ($asset->type) { + case CutsceneType::EFFECT: + unset($payload['cues']); + break; + case CutsceneType::SUMMON: + unset($payload['playback']); + array_shift($payload['tracks']); + break; + case CutsceneType::CINEMATIC: + unset($payload['authoring']); + array_pop($payload['commands']); + break; + } + $asset->apply($payload); +} + +it('preserves effect summon and cinematic source syntax through remove save undo redo and later edits', function (CutsceneType $type) { + [, $library, $index, $asset] = sourceHistoryCutscene($type); + $before = readCutsceneHistorySources($asset); + $command = $library->changeAsset($type, $index, 'Remove authored block', static fn() => removeCutsceneHistoryBlock($asset))['command']; + expect($command)->not->toBeNull()->and($asset->save())->toBeTrue(); + $removed = readCutsceneHistorySources($asset); + $baseline = []; + foreach (['baselineSources', 'loadedData', 'loadedPartner'] as $property) { + $baseline[$property] = new ReflectionProperty(CutsceneAsset::class, $property)->getValue($asset); + } + $command->undo(); + // History remembers syntax, not an obsolete persisted baseline or an + // obsolete changed-reference checkpoint. + foreach ($baseline as $property => $value) { + expect(new ReflectionProperty(CutsceneAsset::class, $property)->getValue($asset))->toBe($value); + } + $asset->assertSourcesUnchanged(); + expect($asset->save())->toBeTrue()->and(readCutsceneHistorySources($asset))->toBe($before); + $command->execute(); + expect($asset->save())->toBeTrue()->and(readCutsceneHistorySources($asset))->toBe($removed); + $command->undo(); + $payload = $asset->payload(); + if ($type === CutsceneType::CINEMATIC) { + $payload['name'] = 'A newly authored name'; + } else { + $payload['fps'] = 8; + } + $asset->apply($payload); + expect($asset->save())->toBeTrue(); + $changed = readCutsceneHistorySources($asset); + $expected = $before; + $path = $type === CutsceneType::CINEMATIC ? $asset->dataPath() : $asset->partnerPath(); + $expected[$path] = match ($type) { + CutsceneType::EFFECT => str_replace("'fps' => 10", "'fps' => 8", $before[$path]), + CutsceneType::SUMMON => str_replace("'fps' => 12", "'fps' => 8", $before[$path]), + CutsceneType::CINEMATIC => str_replace("'name' => 'Harbour Lanterns'", "'name' => 'A newly authored name'", $before[$path]), + }; + expect($changed)->toBe($expected)->and($asset->save())->toBeFalse(); +})->with(CutsceneType::cases()); + +it('refuses external replacement after saved undo without overwriting it or accepting an old disk baseline', function (CutsceneType $type) { + [, $library, $index, $asset] = sourceHistoryCutscene($type); + $command = $library->changeAsset($type, $index, 'Remove authored block', static fn() => removeCutsceneHistoryBlock($asset))['command']; + $asset->save(); + $command->undo(); + $asset->assertSourcesUnchanged(); + $path = $asset->partnerPath(); + $external = file_get_contents($path) . "\n// Another author changed this source.\n"; + file_put_contents($path, $external); + $state = $asset->captureEditState(); + expect(fn() => $asset->save())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(file_get_contents($path))->toBe($external) + ->and($asset->captureEditState())->toBe($state); +})->with(CutsceneType::cases()); + +it('still validates proposed source templates before installing restored histories', function (CutsceneType $type) { + [, $library, $index, $asset] = sourceHistoryCutscene($type); + $command = $library->changeAsset($type, $index, 'Remove authored block', static fn() => removeCutsceneHistoryBlock($asset))['command']; + $asset->save(); + $written = readCutsceneHistorySources($asset); + $command->undo(); + $payload = $asset->payload(); + if ($type === CutsceneType::CINEMATIC) { + $payload['commands'][0]['type'] = 'not_a_cinematic_command'; + } elseif ($type === CutsceneType::SUMMON) { + $payload['tracks'][0]['presentation'] = 'not_a_presentation'; + } else { + $payload['fps'] = 0; + } + $asset->apply($payload); + expect(fn() => $asset->save())->toThrow(RuntimeException::class) + ->and(readCutsceneHistorySources($asset))->toBe($written); +})->with(CutsceneType::cases()); diff --git a/tests/Unit/Cutscenes/CutsceneValidationTest.php b/tests/Unit/Cutscenes/CutsceneValidationTest.php index 1e939ad3..eeed1fbd 100644 --- a/tests/Unit/Cutscenes/CutsceneValidationTest.php +++ b/tests/Unit/Cutscenes/CutsceneValidationTest.php @@ -5,6 +5,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\ProjectValidator; +use Ichiloto\Editor\Validation\ScriptMapState; /** * Project validation over cutscenes: paired folders, the Engine's own @@ -96,6 +97,32 @@ function insertHarbourCommands(string $root, array $commands): void ->and($messages)->toContain('"nowhere"'); }); +it('flags a staged actor without a graphical look only on a map drawn from a tileset', function () { + $root = cutsceneProject(); + $codes = static fn(): array => array_values(array_map(static fn(Issue $issue): string => $issue->message, + array_filter(cutsceneIssues($root), static fn(Issue $issue): bool => $issue->code === 'cinematic.actor-look'))); + insertHarbourCommands($root, [['type' => 'stage_actor', 'id' => 'gull', 'sprite' => ['v'], 'x' => 3, 'y' => 3]]); + // A Terminal map shows every actor by its glyph, as authored. + expect($codes())->toBe([]); + + $map = $root . '/assets/Maps/harbour/harbour.data.php'; + file_put_contents($map, str_replace("'region' => 'Coast',", "'region' => 'Coast',\n 'tileset' => 'harbour',", (string) file_get_contents($map))); + expect($codes())->toBe([ + 'Staged actor "gull" has no graphical look, so the graphical field draws its terminal glyph on harbour.', + 'Staged actor "boat-east" has no graphical look, so the graphical field draws its terminal glyph on harbour.', + 'Staged actor "boat-west" has no graphical look, so the graphical field draws its terminal glyph on harbour.', + ]); + + // Its own art, the subject it stands for or a map NPC's look each give it one. + $data = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + file_put_contents($data, str_replace( + ["'sprite' => ['~^~'], 'x' => 1, 'y' => 2]", "'sprite' => ['~^~'], 'x' => 1, 'y' => 4]"], + ["'sprite' => ['~^~'], 'sprites2d' => ['sheet' => 'Graphics/Characters/Boat.png'], 'x' => 1, 'y' => 2]", + "'appearance' => ['kind' => 'npc', 'id' => 'keeper'], 'x' => 1, 'y' => 4]"], + (string) file_get_contents($data))); + expect($codes())->toBe(['Staged actor "gull" has no graphical look, so the graphical field draws its terminal glyph on harbour.']); +}); + it('validates nested Common Events in their owning cinematic context', function () { $root = cutsceneProject(); forbidHarbourCinematicSkip($root); @@ -360,6 +387,160 @@ function insertHarbourCommands(string $root, array $commands): void ->and($npcFailures[0])->toContain('not on map "empty-quay"'); }); +it('keeps knowledge battle continuation and movement checks for every common-event owner', function (string $owner) { + $root = cutsceneProject(); + writeValidationCommonEvent($root, 'owned-invalid', [ + ['type' => 'knowledge', 'operation' => 'teleport'], + ['type' => 'knowledge', 'operation' => 'observe', 'subject' => 'unknown-subject', 'confidence' => 1.4], + ['type' => 'start_battle', 'troop' => 'missing-troop', 'resultVariable' => '', 'escapePolicy' => 'sometimes'], + ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => -1]]], + ]); + writeValidationCommonEvent($root, 'owned-wrapper', [ + ['type' => 'sequence', 'commands' => [['type' => 'common_event', 'id' => 'owned-invalid']]], + ]); + if ($owner !== 'legacy') { + forbidHarbourCinematicSkip($root); + insertHarbourCommands($root, [['type' => 'common_event', 'id' => 'owned-wrapper']]); + } + if ($owner !== 'cinematic') { + $path = $root . '/assets/Maps/harbour/harbour.data.php'; + $data = require $path; + $data['events']['L'] = [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['scriptId' => 'owned-wrapper', 'mode' => 'action', 'reusable' => false], + ]; + file_put_contents($path, " str_starts_with($message, $prefix))); + expect($owned)->toContain('knowledge operation "teleport"') + ->toContain('knowledge observe command names no observation') + ->toContain('knowledge confidence "1.4" is not between 0 and 1') + ->toContain('resultVariable is empty or malformed') + ->toContain('invalid escapePolicy') + ->toContain('invalid count'); + } +})->with(['cinematic', 'legacy', 'mixed']); + +it('returns nested legacy transfer context to the caller without leaking across entry points', function () { + $validator = new ProjectValidator(); + $transfer = ['type' => 'transfer', 'map' => 'destination', 'x' => 0, 'y' => 0]; + new ReflectionProperty($validator, 'commonEventScripts')->setValue($validator, [ + 'inner' => [$transfer], + 'outer' => [['type' => 'sequence', 'commands' => [['type' => 'common_event', 'id' => 'inner']]]], + ]); + $route = static fn(string $npc): array => [ + 'type' => 'move_route', 'subject' => 'npc', 'npcId' => $npc, 'steps' => [['direction' => 'right']], + ]; + $known = ['maps' => ['origin', 'destination'], 'common_events' => ['inner', 'outer']]; + $maps = ['origin' => ['origin-npc'], 'destination' => ['destination-npc']]; + $check = new ReflectionMethod($validator, 'checkCommands'); + $messages = issueMessages($check->invoke($validator, [ + ['type' => 'sequence', 'commands' => [['type' => 'common_event', 'id' => 'outer']]], + $route('destination-npc'), $route('origin-npc'), + ], 'nested legacy', $known, $maps['origin'], 'origin', $maps)); + expect($messages)->toHaveCount(1) + ->and($messages[0])->toContain('NPC id "origin-npc", which is not on map "destination"'); + expect($check->invoke($validator, [$route('origin-npc')], 'independent caller', $known, $maps['origin'], 'origin', $maps))->toBe([]); +}); + +it('validates legacy alternative arms from their incoming map and joins their possible destinations', function () { + $validator = new ProjectValidator(); + $route = static fn(string $npc): array => [ + 'type' => 'move_route', 'subject' => 'npc', 'npcId' => $npc, 'steps' => [['direction' => 'right']], + ]; + $maps = ['origin' => ['origin-npc', 'shared'], 'destination' => ['destination-npc', 'shared']]; + $issues = new ReflectionMethod($validator, 'checkCommands')->invoke($validator, [ + ['type' => 'branch', 'then' => [ + ['type' => 'transfer', 'map' => 'destination'], $route('destination-npc'), + ], 'else' => [$route('origin-npc')]], + $route('shared'), + $route('destination-npc'), + ], 'branch context', ['maps' => array_keys($maps)], $maps['origin'], 'origin', $maps); + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toContain('NPC id "destination-npc"'); +}); + +/** + * The Engine ticks parallel lanes together on the game's one current map, + * and a transfer in any lane moves every lane, so a lane can run on the map + * it started on or on any map a sibling lane transfers to. Lane order in the + * file does not decide which. + */ +function parallelLaneIssues(array $commands): array +{ + $validator = new ProjectValidator(); + $maps = [ + 'origin' => ['origin-npc', 'shared'], + 'destination' => ['destination-npc', 'shared'], + 'third' => ['third-npc', 'shared'], + ]; + + return issueMessages(new ReflectionMethod($validator, 'checkCommands')->invoke( + $validator, $commands, 'parallel context', ['maps' => array_keys($maps)], $maps['origin'], 'origin', $maps, + )); +} + +function parallelLaneRoute(string $npc): array +{ + return ['type' => 'move_route', 'subject' => 'npc', 'npcId' => $npc, 'steps' => [['direction' => 'right']]]; +} + +it('checks every legacy parallel lane against each map a sibling lane can move it to, in either lane order', function (bool $transferFirst) { + $transfer = [['type' => 'transfer', 'map' => 'destination', 'x' => 0, 'y' => 0]]; + $routes = [parallelLaneRoute('origin-npc'), parallelLaneRoute('shared')]; + $lanes = $transferFirst ? [$transfer, $routes] : [$routes, $transfer]; + + $messages = parallelLaneIssues([['type' => 'parallel', 'lanes' => $lanes]]); + + expect($messages)->toHaveCount(1) + ->and($messages[0])->toContain('NPC id "origin-npc", which is not on map "origin or destination"'); +})->with(['transfer lane first' => true, 'transfer lane last' => false]); + +it('checks every cinematic parallel lane against each map a sibling lane can move it to', function (bool $transferFirst) { + $validator = new ProjectValidator(); + $maps = ['origin' => ['origin-npc', 'shared'], 'destination' => ['destination-npc', 'shared']]; + $context = [ + 'castIds' => [], 'checkpoints' => [], 'map' => ScriptMapState::startOn('origin', $maps['origin']), + 'npcIdsByMap' => $maps, 'seenCheckpoints' => [], + ]; + $transfer = [['type' => 'transfer', 'map' => 'destination', 'x' => 0, 'y' => 0]]; + $routes = [parallelLaneRoute('origin-npc'), parallelLaneRoute('shared')]; + $walk = new ReflectionMethod($validator, 'walkCinematicCommands'); + $issues = $walk->invokeArgs($validator, [ + [['type' => 'parallel', 'lanes' => $transferFirst ? [$transfer, $routes] : [$routes, $transfer]], parallelLaneRoute('destination-npc')], + 'cinematic lanes', ['maps' => array_keys($maps)], &$context, + ]); + + expect(issueMessages($issues))->toBe(['cinematic lanes: It addresses NPC "origin-npc", which is not on origin or destination.']) + ->and($context['map']->mapIds)->toBe(['destination']); +})->with(['transfer lane first' => true, 'transfer lane last' => false]); + +it('leaves a legacy parallel block on any map its transferring lanes can finish on', function () { + $transfer = static fn(string $map): array => ['type' => 'transfer', 'map' => $map, 'x' => 0, 'y' => 0]; + + expect(parallelLaneIssues([ + ['type' => 'parallel', 'lanes' => [[parallelLaneRoute('shared')], ['commands' => [$transfer('destination')]]]], + parallelLaneRoute('destination-npc'), + ]))->toBe([]); + + $messages = parallelLaneIssues([ + ['type' => 'parallel', 'lanes' => [[$transfer('destination')], [$transfer('third')]]], + parallelLaneRoute('shared'), + parallelLaneRoute('third-npc'), + ]); + expect($messages)->toHaveCount(1) + ->and($messages[0])->toContain('NPC id "third-npc", which is not on map "destination or third"'); + + expect(parallelLaneIssues([ + ['type' => 'parallel', 'lanes' => [[parallelLaneRoute('shared')], [parallelLaneRoute('origin-npc')]]], + parallelLaneRoute('origin-npc'), + ]))->toBe([]); +}); + it('checks the map triggers that launch cinematics', function () { $root = cutsceneProject(); $mapData = <<<'PHP_SOURCE' diff --git a/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php b/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php index d6583428..982ded14 100644 --- a/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php +++ b/tests/Unit/Cutscenes/CutscenesWorkspaceTest.php @@ -537,8 +537,8 @@ // Space compiles the summon as it stands and starts the Engine session. pressKeys($editor, ' '); - $preview = getEditorProperty($editor, 'summonPreview'); - expect($preview)->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Preview\SummonPreviewSession::class) + $preview = getEditorProperty($editor, 'timelinePreview'); + expect($preview)->toBeInstanceOf(\Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession::class) ->and($preview->isPlaying())->toBeTrue() ->and($preview->totalFrames())->toBe(24) ->and($preview->fps())->toBe(12); @@ -596,7 +596,7 @@ pressKeys($editor, 'l'); expect(renderEditorPlainFrame($editor, 150, 45))->toContain('f12–23'); pressKeys($editor, 'x'); - expect(getEditorProperty($editor, 'summonPreview'))->toBeNull(); + expect(getEditorProperty($editor, 'timelinePreview'))->toBeNull(); }); it('reorders, nests, un-nests, duplicates and removes commands from the tree, each undoable', function () { @@ -700,3 +700,27 @@ expect($timeline)->toContain("\$wisp = <<<'ART'") ->and($timeline)->toContain("'id' => 'flare-2'"); }); + +it('offers each summon track the Engine presentations and keeps the choice through a save', function () { + $field = array_values(array_filter( + \Ichiloto\Editor\Database\CutsceneSchemas::trackList()->fields, + static fn($field): bool => $field->key === 'presentation', + ))[0] ?? null; + + expect($field?->options)->toBe(['all', 'terminal', 'graphical']) + ->and($field?->displayDefault)->toBe('all'); + + $root = cutsceneProject(); + $editor = cutscenesEditor($root, 160, 50); + $asset = libraryOf($editor)->find(CutsceneType::SUMMON, 'lantern-wisp'); + $payload = $asset->payload(); + $payload['tracks'][0]['presentation'] = 'terminal'; + $asset->apply($payload); + $asset->save(); + + $timeline = require $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php'; + expect($timeline['tracks'][0]['presentation'])->toBe('terminal') + ->and(file_get_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php'))->toContain("\$wisp = <<<'ART'") + // The Engine compiles the saved choice as authored. + ->and($asset->compiledSummon())->not->toBeNull(); +}); diff --git a/tests/Unit/Cutscenes/EffectFieldPreviewTest.php b/tests/Unit/Cutscenes/EffectFieldPreviewTest.php new file mode 100644 index 00000000..0ce67b3a --- /dev/null +++ b/tests/Unit/Cutscenes/EffectFieldPreviewTest.php @@ -0,0 +1,102 @@ + 4, 'lengthFrames' => 4, 'playback' => 'loop', + 'tracks' => [['id' => 'motes', 'type' => 'image', 'asset' => 'Graphics/Effects/mote.png', + 'sheet' => ['columns' => 4, 'rows' => 1], 'cells' => ['width' => 1, 'height' => 1], 'depth' => 'front', + 'keyframes' => array_map(static fn(int $frame): array => ['frame' => $frame, 'sourceFrame' => $frame], range(0, 3))]]], true) . ';'); + file_put_contents($root . '/assets/Data/system.php', " ['player' => [ + 'destinationMap' => 'harbour', 'spawnPoint' => ['x' => 4, 'y' => 4], 'spawnSprite' => ['^']]]], true) . ";\n"); + + return $root; +} + +function effectIndex(EditorSession $session, string $id): int +{ + return array_search($id, $session->listDatabaseRecords('cutscenes/effect')['records'], true); +} + +/** @param list $lines */ +function plainField(array $lines): string +{ + return implode("\n", array_map(TerminalText::stripAnsi(...), $lines)); +} + +it('shows an effect at the player where a new game starts, held at the playhead\'s frame', function () { + $session = EditorSession::open(effectFieldProject()); + $index = effectIndex($session, 'ember-spark'); + + try { + $first = $session->showEffectOnField($index, 0, 40, 12); + expect($first)->toMatchArray(['frame' => 0, 'totalFrames' => 6, 'fps' => 10, 'mapId' => 'harbour', 'issue' => null]) + ->and(plainField($first['lines']))->toContain('*')->not->toContain('+'); + // Another frame of the same effect is the same field, the effect moved to that frame. + $later = $session->showEffectOnField($index, 4, 40, 12); + expect($later['frame'])->toBe(4) + ->and(plainField($later['lines']))->toContain('+')->not->toContain('*') + // A frame past the end is the last one. + ->and($session->showEffectOnField($index, 99, 40, 12)['frame'])->toBe(5); + } finally { + $session->closeEffectField(); + } +}); + +it('gives the editor window the field with the effect\'s own graphical art', function () { + $session = EditorSession::open(effectFieldProject()); + $shown = $session->showEffectOnField(effectIndex($session, 'mote'), 1, 40, 12); + + try { + $answer = $session->exchangeEffectFieldScene([previewWindowReady()], $shown['sessionId']); + expect($answer['grid'])->toBe(['columns' => 40, 'rows' => 12, 'cellWidth' => 10, 'cellHeight' => 20]) + ->and(json_encode($answer['messages']))->toContain('Graphics\/Effects\/mote.png'); + expect($session->closeEffectField())->toBe(['closed' => true]) + ->and(fn() => $session->exchangeEffectFieldScene([]))->toThrow(SessionRefusal::class); + } finally { + $session->closeEffectField(); + } +}); + +it('keeps one isolated field open: an effect on the field ends a cinematic preview, and the reverse', function () { + $session = EditorSession::open(effectFieldProject()); + $session->startCinematicPreview(0, 40, 12); + + try { + $session->showEffectOnField(effectIndex($session, 'ember-spark'), 0, 40, 12); + expect(fn() => $session->exchangeCinematicScene([]))->toThrow(SessionRefusal::class); + $session->startCinematicPreview(0, 40, 12); + expect($session->closeEffectField())->toBe(['closed' => false]); + } finally { + $session->stopCinematicPreview(); + $session->closeEffectField(); + } +}); + +it('shows an effect over undefined terrain with a diagnostic when no new game start names a map', function () { + $session = EditorSession::open(effectProject()); + + try { + $answer = $session->showEffectOnField(effectIndex($session, 'ember-spark'), 0, 40, 12); + expect($answer['mapId'])->toBe('')->and($answer['lines'])->toHaveCount(12) + ->and(implode('\n', $answer['diagnostics']))->toContain('No map')->toContain('undefined terrain'); + } finally { + $session->close(); + } +}); diff --git a/tests/Unit/Cutscenes/EffectTimelineAssetTest.php b/tests/Unit/Cutscenes/EffectTimelineAssetTest.php new file mode 100644 index 00000000..66ebd2e3 --- /dev/null +++ b/tests/Unit/Cutscenes/EffectTimelineAssetTest.php @@ -0,0 +1,140 @@ +find(CutsceneType::EFFECT, 'ember-spark'); + + expect($library->ids(CutsceneType::EFFECT))->toBe(['dusk-slash', 'ember-spark']) + ->and($ember?->paths())->toBe([effectPath($root, 'ember-spark')]) + ->and($ember?->isEditable())->toBeTrue() + ->and($ember?->name())->toBe('ember-spark') + ->and($ember?->payload()['id'])->toBe('ember-spark') + ->and($ember?->payload()['tracks'][0]['id'])->toBe('spark') + ->and($ember?->hasPresentations())->toBeFalse() + ->and(array_column($library->issues(CutsceneType::EFFECT), 'folder'))->not->toContain('ember-spark'); +}); + +it('edits a flat effect in place, keeping its comment and never writing an id', function () { + $root = effectProject(); + $library = CutsceneLibrary::fromProject($root); + $ember = $library->find(CutsceneType::EFFECT, 'ember-spark'); + $payload = $ember->payload(); + $payload['fps'] = 8; + $ember->apply($payload); + + expect($ember->save())->toBeTrue(); + + $source = (string) file_get_contents(effectPath($root, 'ember-spark')); + $saved = require effectPath($root, 'ember-spark'); + + expect($source)->toContain('// Ember Spark: a small flicker over its target.') + ->and($saved['fps'])->toBe(8) + ->and($saved)->not->toHaveKey('id') + ->and($saved['cues'][0]['payload'])->toBe(['sound' => 'crackle']) + ->and(is_file($root . '/assets/Animations/ember-spark/ember-spark.data.php'))->toBeFalse() + ->and(new EffectTimelineLibrary($root . '/assets')->load('ember-spark', false, EffectPresentation::TERMINAL)->fps)->toBe(8); +}); + +it('edits one sequence of a terminal-and-graphical effect and leaves the other exactly as written', function () { + $root = effectProject(); + $dusk = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'dusk-slash'); + + expect($dusk->hasPresentations())->toBeTrue() + ->and($dusk->getPresentationView())->toBe(EffectPresentation::TERMINAL) + ->and($dusk->payload()['lengthFrames'])->toBe(4); + + $dusk->selectPresentation(EffectPresentation::GRAPHICAL); + $payload = $dusk->payload(); + + expect($payload['tracks'][0]['type'])->toBe('image'); + + $payload['tracks'][0]['keyframes'][1]['flipX'] = true; + $dusk->apply($payload); + $dusk->save(); + $saved = require effectPath($root, 'dusk-slash'); + $original = eval('?>' . duskSlashTimeline()); + + expect($saved['presentations']['terminal'])->toBe($original['presentations']['terminal']) + ->and($saved['presentations']['graphical']['tracks'][0]['keyframes'][1])->toBe(['frame' => 1, 'sourceFrame' => 1, 'flipX' => true]) + ->and(array_keys($saved['presentations']['graphical']))->toBe(array_keys($original['presentations']['graphical'])); +}); + +it('splits a flat effect into terminal and graphical sequences only when asked', function () { + $root = effectProject(); + $ember = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'ember-spark'); + $flat = require effectPath($root, 'ember-spark'); + + expect($ember->splitIntoPresentations())->toBeTrue() + ->and($ember->splitIntoPresentations())->toBeFalse(); + + $ember->save(); + $saved = require effectPath($root, 'ember-spark'); + + expect($saved)->toBe(['presentations' => ['terminal' => $flat, 'graphical' => $flat]]); +}); + +it('creates a new effect as one valid timeline, and the Engine refuses an unplayable save', function () { + $root = effectProject(); + $library = CutsceneLibrary::fromProject($root); + $id = $library->freeId(CutsceneType::EFFECT, 'Smoke.Puff'); + $blank = Ichiloto\Editor\Database\RecordSchemaCatalog::effects()->blank; + $created = CutsceneAsset::create(CutsceneType::EFFECT, $id, $library->rootFor(CutsceneType::EFFECT), [...$blank, 'id' => $id], $root); + + expect($id)->toBe('smoke-puff') + ->and($created->save())->toBeTrue() + ->and(require effectPath($root, 'smoke-puff'))->not->toHaveKey('id') + ->and(new EffectTimelineLibrary($root . '/assets')->findTimelineIds())->toContain('smoke-puff'); + + $broken = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'smoke-puff'); + $payload = $broken->payload(); + $payload['tracks'] = []; + $broken->apply($payload); + + expect(fn() => $broken->save())->toThrow(RuntimeException::class, 'Effect smoke-puff cannot be played') + ->and(require effectPath($root, 'smoke-puff'))->not->toBe([]) + ->and((require effectPath($root, 'smoke-puff'))['tracks'])->not->toBe([]); +}); + +it('deletes only the effect timeline, and reports a folder without one or with a dotted id', function () { + $root = effectProject(); + file_put_contents($root . '/assets/Animations/ember-spark/notes.txt', 'keep me'); + $library = CutsceneLibrary::fromProject($root); + $ember = $library->find(CutsceneType::EFFECT, 'ember-spark'); + $ember->markDeleted(true); + $ember->save(); + + expect(is_file(effectPath($root, 'ember-spark')))->toBeFalse() + ->and(is_file($root . '/assets/Animations/ember-spark/notes.txt'))->toBeTrue(); + + @mkdir($root . '/assets/Animations/empty-effect', 0o777, true); + @mkdir($root . '/assets/Animations/old.style', 0o777, true); + file_put_contents(effectPath($root, 'old.style'), emberSparkTimeline()); + $messages = array_column(CutsceneLibrary::fromProject($root)->issues(CutsceneType::EFFECT), 'message'); + + // A folder of shared artwork, with no PHP at all, is not an effect; the game passes over it as well. + @mkdir($root . '/assets/Animations/shared/graphical', 0o777, true); + file_put_contents($root . '/assets/Animations/shared/graphical/flare.png', 'png'); + $messages = array_column(CutsceneLibrary::fromProject($root)->issues(CutsceneType::EFFECT), 'message'); + expect(array_filter($messages, static fn(string $message): bool => str_contains($message, '"shared"')))->toBe([]); + + expect(implode("\n", $messages))->toContain('Folder "empty-effect" is missing empty-effect.timeline.php; an effect is its timeline file.') + ->and(implode("\n", $messages))->toContain('The folder name "old.style" is not a stable id (lowercase letters, digits, "_" and "-").'); +}); diff --git a/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php new file mode 100644 index 00000000..9a8c7fe8 --- /dev/null +++ b/tests/Unit/Cutscenes/EffectTimelineAuthoringTest.php @@ -0,0 +1,363 @@ + trim((string) ($field['label'] ?? '')), callEditorMethod($editor, 'getDatabaseSettingsFields')); + + expect($labels)->toContain('Identity', 'Sequence', 'Timing', 'Impact (battle)', 'Timeline') + ->and(cutsceneFieldIds($editor))->toContain('id', 'fps', 'lengthFrames', 'playback', 'effectTiming.mode', 'commandListTracks', 'commandListCues') + ->and(cutsceneField($editor, '@sequence')['value'])->toBe('Shared') + ->and(renderEditorPlainFrame($editor, 160, 50))->toContain('The engine reads this effect as it stands.'); +}); + +it('splits a flat effect into two sequences from its Sequence row, and undo puts the flat file back', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'ember-spark'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark'); + $flat = $asset->partner(); + + setCutsceneField($editor, '@sequence', 'separate'); + + expect($asset->hasPresentations())->toBeTrue() + ->and($asset->partner())->toBe(['presentations' => ['terminal' => $flat, 'graphical' => $flat]]) + ->and(cutsceneField($editor, '@sequence')['value'])->toBe('Terminal'); + + callEditorMethod($editor, 'performUndo'); + + expect($asset->hasPresentations())->toBeFalse() + ->and($asset->partner())->toBe($flat) + ->and($asset->isDirty())->toBeFalse(); + + callEditorMethod($editor, 'performRedo'); + expect($asset->hasPresentations())->toBeTrue(); +}); + +it('edits the sequence it shows, and undo returns to that sequence whichever is shown', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + + setCutsceneField($editor, '@sequence', 'graphical'); + expect($asset->getPresentationView())->toBe(EffectPresentation::GRAPHICAL) + ->and(cutsceneField($editor, 'lengthFrames')['value'])->toBe('2'); + + setCutsceneField($editor, 'lengthFrames', '3'); + setCutsceneField($editor, '@sequence', 'terminal'); + + expect(cutsceneField($editor, 'lengthFrames')['value'])->toBe('4') + ->and($asset->partner()['presentations']['graphical']['lengthFrames'])->toBe(3); + + callEditorMethod($editor, 'performUndo'); + + expect($asset->partner()['presentations']['graphical']['lengthFrames'])->toBe(2) + ->and($asset->partner()['presentations']['terminal']['lengthFrames'])->toBe(4) + ->and($asset->getPresentationView())->toBe(EffectPresentation::GRAPHICAL) + ->and($asset->isDirty())->toBeFalse(); +}); + +it("sets and removes a track's battle facing as an ordinary field, and the Engine still plays it", function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'ember-spark'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark'); + + selectCutsceneField($editor, 'commandListTracks'); + pressKeys($editor, "\n"); + $facing = array_values(array_filter(cutsceneFieldIds($editor), static fn(string $id): bool => str_ends_with(strtolower($id), 'facing')))[0]; + + expect(cutsceneField($editor, $facing)['options'])->toBe(['', 'west', 'east']); + + setCutsceneField($editor, $facing, 'west'); + expect($asset->payload()['tracks'][0]['facing'])->toBe('west') + ->and($asset->compiledEffect(EffectPresentation::TERMINAL, true))->not->toBeNull(); + + setCutsceneField($editor, $facing, ''); + expect($asset->payload()['tracks'][0])->not->toHaveKey('facing') + ->and($asset->isDirty())->toBeFalse(); +}); + +it("sets what a facing track turns toward as an ordinary field, and the Engine still plays it", function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'ember-spark'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark'); + + selectCutsceneField($editor, 'commandListTracks'); + pressKeys($editor, "\n"); + $ids = cutsceneFieldIds($editor); + $facing = array_values(array_filter($ids, static fn(string $id): bool => str_ends_with(strtolower($id), 'facing')))[0]; + $toward = array_values(array_filter($ids, static fn(string $id): bool => str_ends_with(strtolower($id), 'toward')))[0]; + + expect(cutsceneField($editor, $toward)['options'])->toBe(['', 'recipient', 'opponents']); + + setCutsceneField($editor, $facing, 'west'); + setCutsceneField($editor, $toward, 'opponents'); + expect($asset->payload()['tracks'][0])->toMatchArray(['facing' => 'west', 'toward' => 'opponents']) + ->and($asset->compiledEffect(EffectPresentation::TERMINAL, true))->not->toBeNull(); + + setCutsceneField($editor, $toward, ''); + setCutsceneField($editor, $facing, ''); + expect($asset->payload()['tracks'][0])->not->toHaveKey('toward') + ->and($asset->isDirty())->toBeFalse(); +}); + +it("sets and removes a battle image's attachment and pivot as whole fields, and the Engine still plays it", function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + setCutsceneField($editor, '@sequence', 'graphical'); + $track = static fn(): array => $asset->partner()['presentations']['graphical']['tracks'][0]; + + selectCutsceneField($editor, 'commandListTracks'); + pressKeys($editor, "\n"); + $find = static fn(string $suffix): string => array_values(array_filter(cutsceneFieldIds($editor), + static fn(string $id): bool => str_ends_with(strtolower($id), $suffix)))[0]; + [$attachment, $pivot] = [$find('attachment'), $find('pivot')]; + + expect(cutsceneField($editor, $attachment)['options']) + ->toBe(['', ...array_map(static fn(EffectImageAttachment $case): string => $case->value, EffectImageAttachment::cases())]); + + // The ring at the cell's foot sits on the ground beneath the battler. + setCutsceneField($editor, $attachment, 'ground'); + setCutsceneField($editor, $pivot, '0.5, 0.92'); + expect($track()['attachment'])->toBe('ground') + ->and($track()['pivot'])->toBe(['x' => 0.5, 'y' => 0.92]) + ->and(cutsceneField($editor, $pivot)['value'])->toBe('0.5, 0.92') + ->and($asset->compiledEffect(EffectPresentation::GRAPHICAL, true))->not->toBeNull(); + + // A pivot outside the cell, or half of one, is refused and nothing changes. + foreach (['1.2, 0.5', '0.5', 'left, top'] as $invalid) { + setCutsceneField($editor, $pivot, $invalid); + expect($track()['pivot'])->toBe(['x' => 0.5, 'y' => 0.92]); + } + + setCutsceneField($editor, $pivot, ''); + setCutsceneField($editor, $attachment, ''); + expect($track())->not->toHaveKey('pivot')->not->toHaveKey('attachment') + ->and($asset->isDirty())->toBeFalse(); +}); + +it('adds tracks, keyframes and cues from the timeline rows with the blanks the effect schema owns', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + setCutsceneField($editor, '@sequence', 'graphical'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_TREE); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + $keys = array_column(callEditorMethod($editor, 'visibleCutsceneTreeRows'), 'key'); + + expect(renderEditorPlainFrame($editor, 160, 50))->toContain('Timeline') + ->and(implode("\n", array_column(callEditorMethod($editor, 'visibleCutsceneTreeRows'), 'text')))->toContain('faces west', 'sheet frame 1'); + + // An image keyframe takes the image keyframe blank, not a glyph one, + // starting where the selected one ends. + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_SETTINGS); + setCutsceneField($editor, 'lengthFrames', '3'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_TREE); + setEditorProperty($editor, 'cutsceneTreeCursor', array_search('tracks.0.keyframes.1', $keys, true)); + pressKeys($editor, 'O'); + expect($asset->payload()['tracks'][0]['keyframes'][2])->toBe(['frame' => 2, 'sourceFrame' => 0]); + + // A track takes the effect track blank, its keyframe positioned as the Engine reads it. + setEditorProperty($editor, 'cutsceneTreeCursor', array_search('tracks.0', $keys, true)); + pressKeys($editor, 'O'); + expect($asset->payload()['tracks'][1]['keyframes'][0]['position'])->toBe(['x' => 0, 'y' => 0]) + ->and(array_unique(array_column($asset->payload()['tracks'], 'id')))->toHaveCount(2); + + // The edited sequence still compiles; the terminal one is untouched. + expect($asset->compiledEffect(EffectPresentation::GRAPHICAL, true)->playbackSegments)->not->toBe([]) + ->and($asset->partner()['presentations']['terminal'])->toBe((eval('?>' . duskSlashTimeline()))['presentations']['terminal']); +}); + +it('names what plays an effect before deleting it: battle animations, field effects and scripts', function () { + $root = effectProject(); + $animations = require $root . '/assets/Data/animations.php'; + $animations[0]['targetEffect'] = 'dusk-slash'; + file_put_contents($root . '/assets/Data/animations.php', ' 'field_animation', 'effect' => 'dusk-slash', 'target' => 'player'],\n", (string) file_get_contents($script))); + $editor = effectsEditor($root); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + + $references = callEditorMethod($editor, 'describeCutsceneReferences', $asset); + + expect(implode("\n", $references))->toContain('assets/Data/animations.php: ' . $animations[0]['name'] . ' targetEffect') + ->toContain('cinematic harbour-lanterns') + ->and(callEditorMethod($editor, 'describeCutsceneReferences', libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark')))->toBe([]); +}); + +it('previews an effect through the Engine playhead, anchored to caster and target, as battle or the field plays it', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'dusk-slash'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + + pressKeys($editor, ' ', ' '); + $preview = getEditorProperty($editor, 'timelinePreview'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'dusk-slash'); + + expect($preview)->not->toBeNull() + ->and($preview->totalFrames())->toBe(4) + ->and(callEditorMethod($editor, 'isEffectPreviewInBattle', $asset))->toBeTrue(); + + // The west-facing stroke is turned toward a target on the right, as the battle turns it. + $stage = callEditorMethod($editor, 'createEffectPreviewStage', $asset); + $row = $stage->drawFrame($preview->activeSegments(), 21, 3)[1]; + expect($row)->toBe(' C \\ '); + + pressKeys($editor, 'd'); + $row = callEditorMethod($editor, 'createEffectPreviewStage', $asset)->drawFrame($preview->activeSegments(), 21, 3)[1]; + expect($row)->toBe(' / C '); + + // On the field the effect has one subject; B switches and recompiles. + pressKeys($editor, 'b'); + // Its facing is battle-only, so the field refuses it, and says why. + expect(callEditorMethod($editor, 'isEffectPreviewInBattle', $asset))->toBeFalse() + ->and(getEditorProperty($editor, 'timelinePreview'))->toBeNull() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('facing'); + + pressKeys($editor, 'b'); + expect(getEditorProperty($editor, 'timelinePreview'))->not->toBeNull(); + + // The graphical sequence: its images are described, not imitated. + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_SETTINGS); + setCutsceneField($editor, '@sequence', 'graphical'); + expect(getEditorProperty($editor, 'timelinePreview'))->toBeNull(); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + pressKeys($editor, ' ', ' '); + $stage = callEditorMethod($editor, 'createEffectPreviewStage', $asset); + + // With the caster on the right the west stroke is drawn as authored; + // D puts the caster back on the left and the battle flips it. + expect($stage->describeUndrawn(getEditorProperty($editor, 'timelinePreview')->activeSegments()))->toBe(['dusk-slash.png frame 0 on target']); + pressKeys($editor, 'd'); + + expect(getEditorProperty($editor, 'timelinePreview')->totalFrames())->toBe(2) + ->and(callEditorMethod($editor, 'createEffectPreviewStage', $asset)->describeUndrawn(getEditorProperty($editor, 'timelinePreview')->activeSegments())) + ->toBe(['dusk-slash.png frame 0 flipX on target']) + ->and(renderEditorPlainFrame($editor, 160, 50))->toContain('dusk-slash.png frame 0'); +}); + +it('puts a sequence\'s FPS aside while the battle paces it, and gives it back for fixed cadence', function () { + $root = effectProject(); + $editor = effectsEditor($root); + selectEffect($editor, 'ember-spark'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'ember-spark'); + + // Paced by the battle, the sequence has no FPS: the Engine refuses one, so its row goes too. + setCutsceneField($editor, 'cadence', 'battle_phase'); + expect($asset->partner())->not->toHaveKey('fps') + ->and($asset->partner()['cadence'])->toBe('battle_phase') + ->and(cutsceneFieldIds($editor))->not->toContain('fps') + ->and($asset->compiledEffect(EffectPresentation::TERMINAL, true)->cadence)->toBe(EffectCadence::BATTLE_PHASE); + + // Fixed again, the FPS the author wrote comes back. + setCutsceneField($editor, 'cadence', 'fixed'); + expect($asset->partner()['fps'])->toBe(10) + ->and(cutsceneFieldIds($editor))->toContain('fps'); + + // Undo walks back through both, and the file was never left without one or the other. + callEditorMethod($editor, 'performUndo'); + expect($asset->partner())->not->toHaveKey('fps'); + callEditorMethod($editor, 'performUndo'); + expect($asset->partner()['fps'])->toBe(10) + ->and($asset->partner())->not->toHaveKey('cadence') + ->and($asset->isDirty())->toBeFalse(); + + // Saved paced, the file holds no FPS and the Engine loads it for battle. + callEditorMethod($editor, 'performRedo'); + expect($asset->save())->toBeTrue(); + $saved = require $root . '/assets/Animations/ember-spark/ember-spark.timeline.php'; + expect($saved)->not->toHaveKey('fps') + ->and((new EffectTimelineLibrary($root . '/assets'))->load('ember-spark', true, EffectPresentation::TERMINAL)->cadence)->toBe(EffectCadence::BATTLE_PHASE); +}); + +it('previews a battle-paced effect over the phase the Engine times for its command, pace and stage', function () { + $root = effectProject(); + file_put_contents($root . '/config.php', " ['battle' => ['animation_pace' => 'fast']]];\n"); + @mkdir($root . '/assets/Animations/paced-burst', 0o777, true); + file_put_contents($root . '/assets/Animations/paced-burst/paced-burst.timeline.php', " 4,\n 'cadence' => 'battle_phase',\n 'tracks' => [['id' => 'burst', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 4, 'content' => '*']]]],\n];\n"); + $phase = static fn(BattleActionCategory $action, BattlePace $pace): BattleTurnTimings => + BattleTurnTimings::fromTotalDuration($action->totalDurationSeconds($pace)); + $editor = effectsEditor($root); + selectEffect($editor, 'paced-burst'); + + // Cadence is a choice of the Engine's cadences, read from the file. + expect(cutsceneField($editor, 'cadence')['value'])->toBe('battle_phase') + ->and(cutsceneField($editor, 'cadence')['options'])->toBe(array_map(static fn(EffectCadence $cadence): string => $cadence->value, EffectCadence::cases())); + + // It starts as the target of a physical attack at the project's own pace. + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + pressKeys($editor, ' ', ' '); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::PHYSICAL_ATTACK, BattlePace::FAST)->effectAnimation); + + // S plays it as the source stage, A as the next kind of command, P at the next pace. + pressKeys($editor, 's'); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::PHYSICAL_ATTACK, BattlePace::FAST)->actionAnimation); + pressKeys($editor, 'a'); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::cases()[1], BattlePace::FAST)->actionAnimation); + $next = BattlePace::cases()[(array_search(BattlePace::FAST, BattlePace::cases(), true) + 1) % count(BattlePace::cases())]; + pressKeys($editor, 'p'); + expect(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds) + ->toBe($phase(BattleActionCategory::cases()[1], $next)->actionAnimation) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('over '); + + // A fixed effect plays at its own fps, whatever the battle's pace. + selectEffect($editor, 'ember-spark'); + callEditorMethod($editor, 'startTimelinePreview', false); + expect(getEditorProperty($editor, 'timelinePreview')->timeline->sourceId)->toBe('ember-spark') + ->and(getEditorProperty($editor, 'timelinePreview')->phaseDurationSeconds)->toBeNull(); +}); + +it('draws a flat field effect around its target, leaving image tracks to the graphical renderer', function () { + $stage = new EffectPreviewStage(EffectPresentation::TERMINAL, false); + $segments = [ + ['layer' => 'glyph', 'startFrame' => 0, 'endFrame' => 0, 'drawCommands' => [['content' => '*', 'position' => ['x' => 1, 'y' => -1], 'payload' => []]]], + ['layer' => 'glyph', 'startFrame' => 0, 'endFrame' => 0, 'drawCommands' => [['content' => '!', 'position' => ['x' => 0, 'y' => 0], 'payload' => ['anchor' => 'screen']]]], + ['layer' => 'image', 'startFrame' => 0, 'endFrame' => 0, 'drawCommands' => [['assetId' => 'Graphics/x.png', 'payload' => ['sourceFrame' => 0]]]], + ]; + + expect($stage->drawFrame($segments, 9, 3))->toBe(['! * ', ' T ', ' ']) + ->and($stage->describeUndrawn($segments))->toBe(['image tracks: graphical only']); +}); diff --git a/tests/Unit/Cutscenes/FieldEffectPivotAuthoringTest.php b/tests/Unit/Cutscenes/FieldEffectPivotAuthoringTest.php new file mode 100644 index 00000000..351c3b6e --- /dev/null +++ b/tests/Unit/Cutscenes/FieldEffectPivotAuthoringTest.php @@ -0,0 +1,194 @@ + [ + 'terminal' => ['fps' => 4, 'lengthFrames' => 2, 'tracks' => [ + ['id' => 'spark', 'type' => 'glyph', 'keyframes' => [ + ['frame' => 0, 'duration' => 2, 'content' => $glyph], + ]], + ]], + 'graphical' => ['fps' => 4, 'lengthFrames' => 2, 'tracks' => [ + ['id' => 'spark', 'type' => 'image', 'asset' => 'Graphics/Effects/dusk-slash.png', + 'sheet' => ['columns' => 2, 'rows' => 1], + 'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 1, 'sourceFrame' => 1]], + ], + ]], +]]; +PHP); + + return [$root, $file]; +} + +function openFieldPivotSession(string $root): array +{ + $session = EditorSession::open($root); + $index = array_search('field-pivot', $session->listDatabaseRecords('cutscenes/effect')['records'], true); + $session->selectCutscenePresentation('cutscenes/effect', $index, 'graphical'); + + return [$session, $index]; +} + +it('sends consumer defaults through the actual timeline request without authoring them', function () { + [$root, $file] = makeFieldPivotProject(); + $source = file_get_contents($file); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + try { + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + $category = 'cutscenes/effect'; + $index = array_search('field-pivot', $request('database.records', ['category' => $category])['result']['records'], true); + expect($request('cutscenes.presentation', ['category' => $category, 'index' => $index, 'presentation' => 'graphical']))->toHaveKey('result'); + $art = $request('cutscenes.timeline', ['category' => $category, 'index' => $index])['result']['tracks'][0]['art']; + expect($art['pivot'])->toBe('') + ->and($art['pivotKey']['field'])->toBe('track0Pivot') + ->and($art['pivotDefaults'])->toBe(['field' => ['x' => 0.5, 'y' => 1.0], 'battle' => ['x' => 0.5, 'y' => 0.5]]) + ->and(file_get_contents($file))->toBe($source); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); + +it('describes consumer-owned pivot defaults without changing the field shape or summon defaults', function () { + $effect = CutsceneSchemas::effectTrackList()->fieldsFor(['type' => 'image']); + $pivot = array_find($effect, static fn(RecordField $field): bool => $field->key === 'pivot'); + $attachment = array_find($effect, static fn(RecordField $field): bool => $field->key === 'attachment'); + $summon = array_find(CutsceneSchemas::imageTrackFields(), static fn(RecordField $field): bool => $field->key === 'pivot'); + + expect($pivot->codec)->toBe(RecordFieldCodec::NORMALIZED_POINT) + ->and($pivot->removeWhenEmpty)->toBeTrue() + ->and($pivot->label)->toContain('field 0.5, 1', 'battle 0.5, 0.5') + ->and($attachment->label)->toContain('battle only') + ->and($summon->displayDefault)->toBe('0.5, 0.5') + ->and(array_column(CutsceneSchemas::effectTrackList()->fieldsFor(['type' => 'glyph']), 'key'))->not->toContain('pivot'); +}); + +it('uses the existing GUI pivot key for source-preserving field edits, undo, clear and reopen', function () { + [$root, $file] = makeFieldPivotProject(); + $source = file_get_contents($file); + $original = require $file; + [$session, $index] = openFieldPivotSession($root); + $art = static fn(): array => $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']; + $key = $art()['pivotKey']; + + expect($art()['pivot'])->toBe('') + ->and($art()['pivotDefaults'])->toBe(['field' => ['x' => 0.5, 'y' => 1.0], 'battle' => ['x' => 0.5, 'y' => 0.5]]) + ->and($art())->toMatchArray(['asset' => 'Graphics/Effects/dusk-slash.png', 'columns' => 2, 'rows' => 1]); + $session->saveDatabase('cutscenes/effect'); + expect(file_get_contents($file))->toBe($source); + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0.25, 1'); + expect($art()['pivot'])->toBe('0.25, 1')->and(file_get_contents($file))->toBe($source); + expect($art()['pivotDefaults']['field'])->toBe(['x' => 0.5, 'y' => 1.0]); + $session->undo(); + expect($art()['pivot'])->toBe('')->and($session->listUnsavedChanges())->toBe([]); + $session->redo(); + $session->saveDatabase('cutscenes/effect'); + $saved = require $file; + expect($saved['presentations']['graphical']['tracks'][0]['pivot'])->toBe(['x' => 0.25, 'y' => 1.0]) + ->and($saved['presentations']['graphical']['tracks'][0])->not->toHaveKey('attachment') + ->and($saved['presentations']['terminal'])->toBe($original['presentations']['terminal']) + ->and(file_get_contents($file))->toContain('// Keep the terminal presentation', '$glyph =', "'content' => $" . 'glyph'); + [$reopened, $reopenedIndex] = openFieldPivotSession($root); + expect($reopened->describeCutsceneTimeline('cutscenes/effect', $reopenedIndex)['tracks'][0]['art']['pivot'])->toBe('0.25, 1'); + + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, ''); + expect($art()['pivot'])->toBe('')->and($art()['pivotDefaults']['battle'])->toBe(['x' => 0.5, 'y' => 0.5]); + $session->saveDatabase('cutscenes/effect'); + expect(require $file)->toBe($original); + $session->undo(); + expect($art()['pivot'])->toBe('0.25, 1'); + $session->selectCutscenePresentation('cutscenes/effect', $index, 'terminal'); + expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0])->not->toHaveKey('art'); +}); + +it('refuses malformed, nonfinite and out-of-range pivot edits without mutation or history', function () { + [$root, $file] = makeFieldPivotProject(); + [$session, $index] = openFieldPivotSession($root); + $source = file_get_contents($file); + $key = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivotKey']; + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0, 1'); + foreach (['-0.01, 1', '0.5, 1.01', '0.5', '0, 1, 0', 'left, top', 'NaN, 0', '0, INF', '1e999, 0'] as $value) { + expect(fn() => $session->applyDatabaseRecord('cutscenes/effect', $index, $key, $value)) + ->toThrow(SessionRefusal::class, 'two numbers from 0 to 1'); + } + expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe('0, 1') + ->and(file_get_contents($file))->toBe($source); + $session->undo(); + expect($session->listUnsavedChanges())->toBe([]); +}); + +it('refuses an expression-backed pivot before writing or recording an undo step', function () { + [$root, $file] = makeFieldPivotProject(); + file_put_contents($file, str_replace("'sheet' =>", "'pivot' => ['x' => 1 / 2, 'y' => 1], 'sheet' =>", file_get_contents($file))); + $source = file_get_contents($file); + [$session, $index] = openFieldPivotSession($root); + $key = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivotKey']; + + expect(fn() => $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0.25, 1')) + ->toThrow(SessionRefusal::class, 'cannot be rewritten safely') + ->and($session->listUnsavedChanges())->toBe([]) + ->and(file_get_contents($file))->toBe($source) + ->and($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe('0.5, 1'); +}); + +it('keeps graphical pivots while terminal authoring edits only its selected sequence', function () { + [$root, $file] = makeFieldPivotProject(); + [$session, $index] = openFieldPivotSession($root); + $key = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivotKey']; + $session->applyDatabaseRecord('cutscenes/effect', $index, $key, '0.5, 1'); + $session->saveDatabase('cutscenes/effect'); + $graphical = (require $file)['presentations']['graphical']; + + $editor = cutscenesEditor($root, 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::EFFECT); + callEditorMethod($editor, 'selectCutsceneById', 'field-pivot'); + setCutsceneField($editor, 'fps', '8'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'field-pivot'); + $asset->save(); + expect((require $file)['presentations']['graphical'])->toBe($graphical) + ->and((require $file)['presentations']['terminal']['fps'])->toBe(8); +}); + +it('passes explicit and omitted pivots to the shared compiler without inventing field battler attachments', function () { + [$root, $file] = makeFieldPivotProject(); + $data = require $file; + $library = new EffectTimelineLibrary($root . '/assets'); + foreach ([false, true] as $battle) { + $compiled = $library->compile('field-pivot', $data, $battle, EffectPresentation::GRAPHICAL); + expect($compiled->playbackSegments[0]['drawCommands'][0]['payload'])->not->toHaveKey('pivot'); + } + $data['presentations']['graphical']['tracks'][0]['pivot'] = ['x' => 0.25, 'y' => 1]; + $compiled = $library->compile('field-pivot', $data, false, EffectPresentation::GRAPHICAL); + expect($compiled->playbackSegments[0]['drawCommands'][0]['payload']['pivot'])->toBe(['x' => 0.25, 'y' => 1]); + $data['presentations']['graphical']['tracks'][0]['attachment'] = 'ground'; + expect(fn() => $library->compile('field-pivot', $data, false, EffectPresentation::GRAPHICAL))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Unit/Cutscenes/FieldEffectReferenceTest.php b/tests/Unit/Cutscenes/FieldEffectReferenceTest.php new file mode 100644 index 00000000..84b13a3c --- /dev/null +++ b/tests/Unit/Cutscenes/FieldEffectReferenceTest.php @@ -0,0 +1,163 @@ + 10, + 'lengthFrames' => $lengthFrames, + 'playback' => $playback, + 'tracks' => [['id' => 'glyph', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => $lengthFrames, 'content' => '*']]]], + ], true) . ';'); +} + +/** Replaces the harbour cinematic's title card with the given command. */ +function writeHarbourCommand(string $root, array $command): void +{ + $script = str_replace( + "['type' => 'title_card', 'title' => 'HARBOUR LANTERNS', 'seconds' => 0.2],", + var_export($command, true) . ',', + harbourCinematicScript(), + ); + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', $script); +} + +/** Writes a Common Event script. */ +function writeFieldEffectScript(string $root, string $id, array $commands): void +{ + file_put_contents($root . '/assets/Events/' . $id . '.php', " */ +function fieldEffectIssues(string $root, string $where): array +{ + return array_values(array_map( + static fn(Issue $issue): string => $issue->message, + array_filter( + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => str_contains($issue->where, $where), + ), + )); +} + +it('offers the project\'s effect timelines by stable id', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow'); + writeFieldEffectTimeline($root, 'ember'); + + expect(new ReferenceCatalog(ProjectWorkspace::fromProject($root))->valuesFor('effects'))->toBe(['ember', 'lantern-glow']); +}); + +it('edits a field effect in place of a legacy animation and its pace, and back', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow'); + writeHarbourCommand($root, [ + 'type' => 'field_animation', + 'animation' => 'Slash', + 'secondsPerFrame' => 0.05, + 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east'], + 'authoring' => ['note' => 'kept'], + ]); + $editor = cutscenesEditor($root); + openCutsceneFrame($editor, 'commandListCommands'); + + expect(cutsceneFieldIds($editor))->toContain('command4Effect', 'command4Animation', 'command4SecondsPerFrame'); + + setCutsceneField($editor, 'command4Effect', 'lantern-glow'); + $command = libraryOf($editor)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')?->commands()[4] ?? []; + + expect($command)->toBe([ + 'type' => 'field_animation', + 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east'], + 'authoring' => ['note' => 'kept'], + 'effect' => 'lantern-glow', + ]) + ->and(cutsceneFieldIds($editor))->toContain('command4Effect') + ->and(cutsceneFieldIds($editor))->not->toContain('command4Animation') + ->and(cutsceneFieldIds($editor))->not->toContain('command4SecondsPerFrame'); + + // Clearing the effect returns the command to a legacy animation. + setCutsceneField($editor, 'command4Effect', ''); + setCutsceneField($editor, 'command4Animation', 'Slash'); + + // The animation the file still holds keeps its authored place. + expect(libraryOf($editor)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')?->commands()[4] ?? [])->toBe([ + 'type' => 'field_animation', + 'animation' => 'Slash', + 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east'], + 'authoring' => ['note' => 'kept'], + ]); +}); + +it('reports a field effect that is missing, loops, or is mixed with a legacy pace', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'tide', 'loop'); + writeHarbourCommand($root, ['type' => 'parallel', 'lanes' => [ + ['id' => 'missing', 'commands' => [['type' => 'field_animation', 'effect' => 'nowhere', 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east']]]], + ['id' => 'loops', 'commands' => [['type' => 'field_animation', 'effect' => 'tide', 'target' => ['kind' => 'staged_actor', 'id' => 'boat-west']]]], + ]]); + writeFieldEffectScript($root, 'mixed-pace', [ + ['type' => 'field_animation', 'effect' => 'tide', 'secondsPerFrame' => 0.1, 'target' => ['kind' => 'player']], + ]); + + $cinematic = fieldEffectIssues($root, 'cinematic harbour-lanterns'); + + expect($cinematic)->toContain( + 'It names the effect "nowhere", which does not exist.', + 'Its effect tide loops in the terminal presentation, and a field_animation waits for its effect to end.', + ) + ->and(implode("\n", fieldEffectIssues($root, 'mixed-pace')))->toContain('Field effect requires one stable timeline identity and uses its authored frame rate'); +}); + +it('accepts a once-played field effect in cinematics and event scripts', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow'); + writeHarbourCommand($root, ['type' => 'field_animation', 'effect' => 'lantern-glow', 'target' => ['kind' => 'staged_actor', 'id' => 'boat-east']]); + writeFieldEffectScript($root, 'glow', [['type' => 'field_animation', 'effect' => 'lantern-glow', 'target' => ['kind' => 'player']]]); + + expect(fieldEffectIssues($root, 'harbour-lanterns'))->toBe([]) + ->and(fieldEffectIssues($root, 'glow'))->toBe([]); +}); + +it('times a field effect by its timeline and marks one it cannot read', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow', lengthFrames: 15); + $effects = ProjectWorkspace::fromProject($root)->catalogs; + $glow = ['type' => 'field_animation', 'effect' => 'lantern-glow', 'target' => ['kind' => 'player']]; + $missing = ['type' => 'field_animation', 'effect' => 'nowhere', 'target' => ['kind' => 'player']]; + + expect(CutsceneLaneOverview::estimate($glow, $effects))->toBe([1.5, []]) + ->and(CutsceneLaneOverview::estimate($missing, $effects))->toBe([0.0, [CutsceneLaneOverview::UNKNOWN]]) + ->and(CutsceneLaneOverview::estimate($glow))->toBe([0.0, [CutsceneLaneOverview::UNKNOWN]]); +}); + +it('times a field effect by its timeline as the Cutscenes workspace holds it, before it is saved', function () { + $root = cutsceneProject(); + writeFieldEffectTimeline($root, 'lantern-glow', lengthFrames: 15); + $workspace = ProjectWorkspace::fromProject($root); + $glow = ['type' => 'field_animation', 'effect' => 'lantern-glow', 'target' => ['kind' => 'player']]; + $asset = $workspace->cutscenes?->find(CutsceneType::EFFECT, 'lantern-glow') ?? throw new RuntimeException('No lantern-glow effect.'); + $payload = $asset->payload(); + $payload['lengthFrames'] = 25; + $payload['tracks'][0]['keyframes'][0]['duration'] = 25; + $asset->apply($payload); + + expect($asset->isDirty())->toBeTrue() + ->and(CutsceneLaneOverview::estimate($glow, $workspace->catalogs))->toBe([2.5, []]) + ->and(new EffectTimelineLibrary($root . '/assets')->load('lantern-glow')->defaults['lengthFrames'])->toBe(15); +}); diff --git a/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php b/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php index 9a03216c..c6952fae 100644 --- a/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php +++ b/tests/Unit/Cutscenes/LastLegendSummonVerificationTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Ichiloto\Editor\Cutscenes\CutsceneType; -use Ichiloto\Editor\Cutscenes\Preview\SummonPreviewSession; +use Ichiloto\Editor\Cutscenes\Preview\TimelinePreviewSession; use Ichiloto\Editor\Database\SummonAssignmentDiagnostics; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\UI\CutscenesScreen; @@ -37,7 +37,7 @@ expect($asset->isEditable())->toBeTrue($asset->id . ' is editable') ->and($asset->isDirty())->toBeFalse(); $compiled = $asset->compiledSummon(); - $preview = new SummonPreviewSession($compiled); + $preview = new TimelinePreviewSession($compiled); $preview->play(); for ($tick = 0; $tick < 5000 && ! $preview->isCompleted(); $tick++) { @@ -55,7 +55,7 @@ callEditorMethod($editor, 'selectCutsceneById', $id); setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); pressKeys($editor, ' ', ' ', '.'); - expect(getEditorProperty($editor, 'summonPreview'))->not->toBeNull(); + expect(getEditorProperty($editor, 'timelinePreview'))->not->toBeNull(); pressKeys($editor, 'x'); } @@ -109,7 +109,7 @@ $workspace = getEditorProperty($editor, 'workspace'); $actor = $workspace->actorDatabase->getActors()[0]; $actorName = $actor->getName(); - $skill = $workspace->skillDatabase->getSkills()[0]->getName(); + $skill = $workspace->getSkillNames()[0]; // 1.–2. Create, name, link a real battle action. setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_LIST); @@ -196,8 +196,8 @@ // 11. Preview through the Engine session: the cue fires once at 24. setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); pressKeys($editor, ' '); - $preview = getEditorProperty($editor, 'summonPreview'); - expect($preview)->toBeInstanceOf(SummonPreviewSession::class); + $preview = getEditorProperty($editor, 'timelinePreview'); + expect($preview)->toBeInstanceOf(TimelinePreviewSession::class); for ($tick = 0; $tick < 200 && ! $preview->isCompleted(); $tick++) { $preview->tick($preview->secondsPerFrame()); @@ -267,3 +267,48 @@ $actorSource = file_get_contents($actor->path); expect($actorSource)->toContain("'ember-moth'"); }); + +it('opens and previews every Last Legend effect read-only, in each of its sequences, leaving every hash unchanged', function () { + $root = disposableLastLegend(); + + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + + $before = authoredHashTree($root); + $editor = cutscenesEditor($root, 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::EFFECT); + $library = libraryOf($editor); + $ids = $library->ids(CutsceneType::EFFECT); + $previewed = 0; + + expect($ids)->not->toBe([]) + ->and($library->issues(CutsceneType::EFFECT))->toBe([]); + + foreach ($ids as $id) { + $asset = $library->find(CutsceneType::EFFECT, $id); + callEditorMethod($editor, 'selectCutsceneById', $id); + + foreach ($asset->hasPresentations() ? \Ichiloto\Engine\Animations\Timelines\EffectPresentation::cases() : [null] as $presentation) { + if ($presentation !== null) { + $asset->selectPresentation($presentation); + } + + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + callEditorMethod($editor, 'disposeCinematicPreview'); + callEditorMethod($editor, 'startTimelinePreview', false); + $preview = getEditorProperty($editor, 'timelinePreview'); + + // Each sequence plays where the project uses it: the preview's + // own context, chosen from those uses. + expect($preview)->toBeInstanceOf(TimelinePreviewSession::class, $id . ' previews') + ->and(renderEditorPlainFrame($editor, 160, 50))->toContain($id); + $previewed++; + } + } + + pressKeys($editor, "\033OS"); + expect($previewed)->toBeGreaterThanOrEqual(count($ids)) + ->and($library->hasUnsavedChanges())->toBeFalse() + ->and(authoredHashTree($root))->toBe($before); +}); diff --git a/tests/Unit/Cutscenes/OwnedStageEffectTest.php b/tests/Unit/Cutscenes/OwnedStageEffectTest.php new file mode 100644 index 00000000..9c690a46 --- /dev/null +++ b/tests/Unit/Cutscenes/OwnedStageEffectTest.php @@ -0,0 +1,366 @@ + 8, 'lengthFrames' => 8, 'restFrame' => 3, + 'stage' => [ + 'canvas' => ['width' => 80, 'height' => 40], 'startFrame' => 1, 'restoreFrame' => 6, + 'subjects' => [['id' => 'visitor', 'position' => ['x' => 40, 'y' => 30], 'size' => ['width' => 16, 'height' => 16], + 'attachments' => [['id' => 'crown', 'x' => 0.5, 'y' => 0]]]], + 'camera' => [ + ['id' => 'wide', 'frame' => 0, 'focus' => ['x' => 40, 'y' => 20], 'zoom' => 1], + ['id' => 'close', 'frame' => 4, 'focus' => ['x' => 44, 'y' => 20], 'zoom' => 2], + ], + 'covers' => [ + ['id' => 'entry', 'frame' => 0, 'color' => 'black', 'opacity' => 1, 'easing' => 'hold'], + ['id' => 'clear', 'frame' => 1, 'color' => 'black', 'opacity' => 0], + ['id' => 'exit', 'frame' => 7, 'color' => 'black', 'opacity' => 0], + ], + ], + 'tracks' => [['id' => 'portrait', 'type' => 'image', 'anchor' => 'stage', + 'asset' => 'Graphics/Effects/visitor.png', 'sheet' => ['columns' => 1, 'rows' => 1], + 'placement' => ['subject' => 'visitor', 'attachment' => 'crown'], + 'keyframes' => [['frame' => 1, 'duration' => 5, 'sourceFrame' => 0]]]], + 'cues' => [], + ]; +} + +function ownedStageEffectProject(bool $paired = false, bool $readOnly = false): string +{ + $root = makeTemporaryProject('owned-stage-effect-'); + $timeline = ownedStageEffectSequence(); + if ($paired) { + $timeline = ['presentations' => [ + 'terminal' => ['fps' => 8, 'lengthFrames' => 8, 'tracks' => [['id' => 'caption', 'type' => 'text', 'anchor' => 'screen', + 'keyframes' => [['frame' => 0, 'duration' => 8, 'content' => 'A quiet interval', 'position' => ['x' => 0, 'y' => 0]]]]], 'cues' => []], + 'graphical' => $timeline, + ]]; + } + mkdir($root . '/assets/Animations/quiet-interval', 0o777, true); + $literal = var_export($timeline, true); + $source = $readOnly ? "\$timeline = {$literal};\nreturn \$timeline;" : "return {$literal};"; + file_put_contents($root . '/assets/Animations/quiet-interval/quiet-interval.timeline.php', "find(CutsceneType::EFFECT, 'quiet-interval'); + $asset->hydrate(); + + expect($asset->isEditable())->toBeTrue() + ->and($asset->isOwnedStageEffect())->toBeTrue() + ->and($asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true)->defaults['stage']['subjects'][0]['id'])->toBe('visitor') + ->and(fn() => $asset->compiledEffect(EffectPresentation::GRAPHICAL, false))->toThrow(RuntimeException::class) + ->and(fn() => $asset->compiledEffect(EffectPresentation::GRAPHICAL, true))->toThrow(RuntimeException::class) + ->and(fn() => $asset->compileEffect(EffectPresentation::GRAPHICAL, true, forStage: true))->toThrow(RuntimeException::class) + ->and(fn() => $asset->compileEffect(EffectPresentation::TERMINAL, true, forStage: true))->toThrow(RuntimeException::class); +})->with([false, true]); + +it('refuses invalid owned-stage context data before installing any source', function () { + $root = ownedStageEffectProject(); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $before = file_get_contents(ownedStageEffectPath($root)); + foreach ([ + static function (array &$payload): void { $payload['tracks'][0]['placement']['subject'] = 'unknown'; }, + static function (array &$payload): void { $payload['cues'] = [['id' => 'sound', 'frame' => 1, 'type' => 'playSound', 'payload' => ['sound' => 'chime']]]; }, + static function (array &$payload): void { $payload['playback'] = 'loop'; }, + static function (array &$payload): void { $payload['stage']['camera'][0]['frame'] = 1; }, + ] as $break) { + $payload = $asset->payload(); + $break($payload); + $state = $asset->captureEditState(); + $asset->apply($payload); + expect(fn() => $asset->save())->toThrow(RuntimeException::class, 'Effect quiet-interval cannot be played') + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($before); + $asset->restoreEditState($state); + } +}); + +it('keeps terminal stage playback independent of graphical decoding and draws no invented subjects', function () { + $root = ownedStageEffectProject(true); + unlink($root . '/assets/Graphics/Effects/visitor.png'); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $terminal = $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: true); + $preview = new TimelinePreviewSession($terminal); + $stage = new EffectPreviewStage(EffectPresentation::TERMINAL, false, forStage: true); + + expect($stage->drawFrame($preview->activeSegments(), 24, 3))->toBe(['A quiet interval ', str_repeat(' ', 24), str_repeat(' ', 24)]) + ->and($stage->renderCanvas($terminal, $root . '/assets', 2, 80, 40))->toBeNull() + ->and(fn() => $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true))->toThrow(RuntimeException::class); +}); + +it('leaves editable and readonly owned-stage source byte-identical on no-op roundtrips and previews', function (bool $readOnly) { + $root = ownedStageEffectProject(true, $readOnly); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $before = sourceHashTree($root); + foreach (EffectPresentation::cases() as $presentation) { + $asset->selectPresentation($presentation); + $asset->apply($asset->payload()); + $asset->compileEffect($presentation, false, forStage: true); + } + expect($asset->isEditable())->toBe(! $readOnly) + ->and($asset->save())->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); +})->with([false, true]); + +it('authors stage effects through shared rows and selectors with source-preserving save and undo', function (bool $paired) { + $root = ownedStageEffectProject($paired); + $before = require ownedStageEffectPath($root); + $source = file_get_contents(ownedStageEffectPath($root)); + $session = EditorSession::open($root); + if ($paired) { + $session->selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); + } + $rows = $session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows']; + $row = array_find($rows, static fn(array $row): bool => str_ends_with($row['label'], 'Subject')); + $opacity = array_find($rows, static fn(array $row): bool => str_ends_with($row['label'], 'Opacity')); + expect($row)->not->toBeNull() + ->and($opacity)->not->toBeNull() + ->and(array_column($rows, 'label'))->not->toContain('Track 1 Cell Width', 'Track 1 Facing', 'Track 1 Attachment'); + $context = ['category' => 'cutscenes/effect', 'index' => 0]; + expect(array_column($session->listReferences(null, 'stage_subjects', $context), 'value'))->toBe(['visitor']) + ->and(array_column($session->listReferences(null, 'stage_attachments', $context), 'value'))->toBe(['crown']) + ->and(array_column($session->listReferences(null, 'png_assets', $context), 'value'))->toContain('Graphics/Effects/visitor.png'); + $session->applyDatabaseRecord('cutscenes/effect', 0, $opacity['key'], '0.5'); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(require ownedStageEffectPath($root))->toBe($before) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $session->redo(); + expect($session->saveAll()['failures'])->toBe([]); + $saved = require ownedStageEffectPath($root); + $sequence = $paired ? $saved['presentations']['graphical'] : $saved; + expect($sequence['tracks'][0]['keyframes'][0]['opacity'])->toBe(0.5) + ->and(file_get_contents(ownedStageEffectPath($root)))->toContain('// Keep the authored stage comment.'); + if ($paired) { + expect($saved['presentations']['terminal'])->toBe($before['presentations']['terminal']); + } +})->with([false, true]); + +it('projects seek camera covers and reduced motion with the shared Engine stage compositor and no retained scene', function () { + $root = ownedStageEffectProject(); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $compiled = $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true); + $preview = new TimelinePreviewSession($compiled); + $stage = new EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true); + $definition = CinematicStage::fromArray($compiled->defaults['stage'], 8, 3); + foreach ([0, 2, 5, 1, 7, 3] as $frame) { + $preview->seek($frame); + foreach ([false, true] as $reduced) { + expect($stage->renderCanvas($compiled, $root . '/assets', $preview->currentFrame(), 160, 80, $reduced)->toArray()) + ->toBe(CinematicStagePresentation::compose($definition->getFrame($frame, $reduced), $compiled->playbackSegments, $root . '/assets', 160, 80)->toArray()); + } + } + expect($stage->renderCanvas($compiled, $root . '/assets', 2, 160, 80)->images[0]->destination) + ->not->toEqual($stage->renderCanvas($compiled, $root . '/assets', 5, 160, 80)->images[0]->destination) + ->and($stage->renderCanvas($compiled, $root . '/assets', 7, 160, 80)->images)->toBe([]) + ->and($stage->renderCanvas($compiled, $root . '/assets', 7, 160, 80)->composites)->toBe([]); + $preview->restart(); + expect($preview->currentFrame())->toBe(0)->and($preview->cueLog())->toBe([]); + $preview->play(); + $preview->tick(2); + expect($preview->isCompleted())->toBeTrue(); +}); + +it('chooses owned stage in the Terminal Editor even when no project consumer references the effect', function () { + $root = ownedStageEffectProject(); + $editor = cutscenesEditor($root, 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::EFFECT); + callEditorMethod($editor, 'selectCutsceneById', 'quiet-interval'); + setEditorProperty($editor, 'cutsceneFocus', CutscenesScreen::PANE_PREVIEW); + callEditorMethod($editor, 'startTimelinePreview', false); + $preview = getEditorProperty($editor, 'timelinePreview'); + $asset = libraryOf($editor)->find(CutsceneType::EFFECT, 'quiet-interval'); + expect($preview)->toBeInstanceOf(TimelinePreviewSession::class) + ->and(callEditorMethod($editor, 'createEffectPreviewStage', $asset)->forStage)->toBeTrue() + ->and(cutsceneFieldIds($editor))->not->toContain('@stage', 'stage.canvas', 'commandListStage.subjects'); + pressKeys($editor, 'b', 'd'); + expect(getEditorProperty($editor, 'timelinePreview'))->toBe($preview) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('owns its stage'); + pressKeys($editor, '.', 'r', 'x'); + expect(getEditorProperty($editor, 'timelinePreview'))->toBeNull(); +}); + +it('reads replacement stage artwork from the current file rather than freezing its bytes or dimensions', function () { + $root = ownedStageEffectProject(); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $stage = new EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true); + $first = $stage->renderCanvas($asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true), $root . '/assets', 1, 160, 80); + writeTilesetTestPng($root . '/assets/Graphics/Effects/visitor.png', 16, 12); + $replacement = $stage->renderCanvas($asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true), $root . '/assets', 1, 160, 80); + + expect($replacement->images[0]->sourceRect->width)->toBe(16) + ->and($replacement->images[0]->sourceRect->height)->toBe(12) + ->and($replacement->images[0]->destination)->toEqual($first->images[0]->destination) + ->and($asset->isDirty())->toBeFalse(); +}); + +it('validates a referenced stage with independent Engine admission for both presentations', function () { + $root = ownedStageEffectProject(true); + file_put_contents($root . '/config.php', " ['inn' => ['presentation' => 'quiet-interval']]];\n"); + $workspace = ProjectWorkspace::fromProject($root); + expect(EffectValidator::findUses($workspace)['quiet-interval'])->toHaveKey('stage') + ->and(new EffectValidator()->validate($workspace))->toBe([]); + $timeline = require ownedStageEffectPath($root); + $timeline['presentations']['terminal']['cues'] = [['id' => 'sound', 'frame' => 1, 'type' => 'playSound', 'payload' => ['sound' => 'chime']]]; + file_put_contents(ownedStageEffectPath($root), 'validate(ProjectWorkspace::fromProject($root)))->toBe([]); + $timeline['presentations']['terminal']['cues'][0]['frame'] = 100; + file_put_contents(ownedStageEffectPath($root), 'validate(ProjectWorkspace::fromProject($root)); + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toContain('quiet-interval cannot be played as a stage', 'invalid field presentation cue'); +}); + +it('creates a graphical effect stage through the source-preserving session with no-op undo and save', function (bool $paired) { + $root = ownedStageEffectProject($paired); + $timeline = require ownedStageEffectPath($root); + $sequence = &$timeline; + if ($paired) { $sequence = &$timeline['presentations']['graphical']; } + unset($sequence['stage'], $sequence['tracks'][0]['placement']); + $sequence['tracks'][0]['anchor'] = 'screen'; + unset($sequence); + file_put_contents(ownedStageEffectPath($root), "selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); } + expect($session->describeCutsceneTimeline('cutscenes/effect', 0))->toMatchArray(['canHaveStage' => true, 'ownedStage' => false, 'stage' => null]); + expect($session->setCutsceneStage('cutscenes/effect', 0, true))->toBe(['changed' => true, 'stage' => true]) + ->and($session->setCutsceneStage('cutscenes/effect', 0, true))->toBe(['changed' => false, 'stage' => true]); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $session->redo(); + // Adding a descriptor does not silently convert an existing track's geometry. + $preview = $session->readCutscenePreview('cutscenes/effect', 0, 3, 24, 3); + expect($preview['stageCanvas'])->toBeNull()->and($preview['stageCanvasError'])->toContain('only stage image tracks'); + $rows = $session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows']; + $anchor = array_find($rows, static fn(array $row): bool => $row['label'] === 'Track 1 Anchor'); + $session->applyDatabaseRecord('cutscenes/effect', 0, $anchor['key'], 'stage'); + $rows = $session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows']; + $size = array_find($rows, static fn(array $row): bool => $row['label'] === 'Track 1 Size'); + $session->applyDatabaseRecord('cutscenes/effect', 0, $size['key'], '16, 16'); + $workspace = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session); + $state = $workspace->cutscenes->find(CutsceneType::EFFECT, 'quiet-interval')->captureEditState(); + expect(eval('?>' . $state['sourceTemplates']['timeline']['source']))->toBe($state['sourceTemplates']['timeline']['values']); + expect($session->saveAll()['failures'])->toBe([]); + $saved = require ownedStageEffectPath($root); + $graphical = $paired ? $saved['presentations']['graphical'] : $saved; + expect($graphical['stage']['subjects'] ?? [])->toBe([]) + ->and($graphical['stage']['camera'][0]['frame'])->toBe(0) + ->and(file_get_contents(ownedStageEffectPath($root)))->toContain('// Keep the authored stage comment.'); + if ($paired) { expect($saved['presentations']['terminal'])->toBe($timeline['presentations']['terminal']); } + CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval')->hydrate(); +})->with([false, true]); + +it('removes an effect stage without erasing tracks and restores it across save and undo', function () { + $root = ownedStageEffectProject(); + $timeline = require ownedStageEffectPath($root); + $timeline['tracks'][] = ['id' => 'caption', 'type' => 'text', 'presentation' => 'terminal', 'anchor' => 'screen', + 'keyframes' => [['frame' => 0, 'duration' => 8, 'content' => 'Independent Terminal']]]; + file_put_contents(ownedStageEffectPath($root), 'setCutsceneStage('cutscenes/effect', 0, false); + expect($session->saveAll()['failures'])->not->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); + $row = array_find($session->readDatabaseRecord('cutscenes/effect', 0, ['tracks'])['rows'], + static fn(array $row): bool => $row['label'] === 'Track 1 Id'); + $session->removeDatabaseItem('cutscenes/effect', 0, $row['key']); + $session->setCutsceneStage('cutscenes/effect', 0, false); + expect($session->saveAll()['failures'])->toBe([]); + $saved = require ownedStageEffectPath($root); + expect($saved)->not->toHaveKey('stage')->and($saved['tracks'])->toBe([$timeline['tracks'][1]]); + $session->undo(); + $session->undo(); + expect($session->saveAll()['failures'])->toBe([]) + ->and(file_get_contents(ownedStageEffectPath($root)))->toBe($source); +}); + +it('serves shared stage canvases on the graphical clock and independent Terminal cues without graphical dependencies', function () { + $root = ownedStageEffectProject(true); + $timeline = require ownedStageEffectPath($root); + $timeline['presentations']['terminal']['fps'] = 4; + $timeline['presentations']['terminal']['lengthFrames'] = 12; + $timeline['presentations']['terminal']['tracks'][0]['keyframes'][0]['duration'] = 12; + $timeline['presentations']['terminal']['cues'] = [['id' => 'chime', 'frame' => 10, 'type' => 'playSound', 'payload' => ['sound' => 'chime']]]; + file_put_contents(ownedStageEffectPath($root), 'selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); + expect($session->describeCutsceneTimeline('cutscenes/effect', 0))->toMatchArray(['ownedStage' => true, 'canHaveStage' => true]); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::EFFECT, 'quiet-interval'); + $compiled = $asset->compileEffect(EffectPresentation::GRAPHICAL, false, forStage: true); + $stage = new EffectPreviewStage(EffectPresentation::GRAPHICAL, false, forStage: true); + foreach ([5, 1, 7, 3, 0, 30] as $frame) { + $preview = $session->readCutscenePreview('cutscenes/effect', 0, $frame, 24, 3); + expect($preview['stageCanvas'])->toBe($stage->renderCanvas($compiled, $root . '/assets', $frame, 80, 40)->toArray()) + ->and($preview['stageCanvasError'])->toBeNull() + ->and($preview['totalFrames'])->toBe(8)->and($preview['fps'])->toBe(8)->and($preview['cues'])->toBe([]); + } + $session->selectCutscenePresentation('cutscenes/effect', 0, 'terminal'); + expect($session->describeCutsceneTimeline('cutscenes/effect', 0))->toMatchArray(['ownedStage' => true, 'canHaveStage' => false, 'stage' => null]); + expect($session->saveAll()['failures'])->toBe([])->and(sourceHashTree($root))->toBe($before); + unlink($root . '/assets/Graphics/Effects/visitor.png'); + $preview = $session->readCutscenePreview('cutscenes/effect', 0, 10, 24, 3); + expect($preview)->toMatchArray(['frame' => 10, 'totalFrames' => 12, 'fps' => 4, 'stageCanvas' => null, 'stageCanvasError' => null]) + ->and($preview['lines'][0])->toBe('A quiet interval ')->and($preview['cues'][0]['id'])->toBe('chime'); +}); + +it('refuses source edits on read-only owned stages while still projecting their graphical preview', function () { + $root = ownedStageEffectProject(true, true); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $session->selectCutscenePresentation('cutscenes/effect', 0, 'graphical'); + expect($session->readCutscenePreview('cutscenes/effect', 0, 3, 24, 3)['stageCanvas']['images'])->not->toBe([]) + ->and(fn() => $session->setCutsceneStage('cutscenes/effect', 0, false))->toThrow(SessionRefusal::class, 'read-only') + ->and(sourceHashTree($root))->toBe($before); +}); + +it('preserves independent authored Terminal tracks and cues without applying graphical stage consumer policy', function () { + $root = ownedStageEffectProject(true); + $timeline = require ownedStageEffectPath($root); + $timeline['presentations']['terminal']['cues'] = [['id' => 'authored-cue', 'frame' => 1, 'type' => 'showMessage', 'payload' => ['text' => 'An authored cue']]]; + file_put_contents(ownedStageEffectPath($root), 'find(CutsceneType::EFFECT, 'quiet-interval'); + $asset->hydrate(); + $compiled = $asset->compileEffect(EffectPresentation::TERMINAL, false, forStage: true); + expect($compiled->cueSchedule)->toBe($timeline['presentations']['terminal']['cues']); + $preview = new TimelinePreviewSession($compiled); + $preview->play(); + $preview->tick(0.25); + expect($preview->cueLog()[0])->toBe(['frame' => 1, 'id' => 'authored-cue', 'type' => 'showMessage']); + $session = EditorSession::open($root); + $session->selectCutscenePresentation('cutscenes/effect', 0, 'terminal'); + expect($session->readCutscenePreview('cutscenes/effect', 0, 1, 24, 3)['cues'])->toBe($compiled->cueSchedule) + ->and($session->describeCutsceneTimeline('cutscenes/effect', 0)['stage'])->toBeNull(); +}); diff --git a/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php b/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php new file mode 100644 index 00000000..bf2ac195 --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewLifetimeDiagnosticsTest.php @@ -0,0 +1,402 @@ + 'missing-preview-map', + 'none' => '', + default => 'harbour', + }; + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $data = require $file; + $data['startMap'] = $mapId === '' ? null : $mapId; + $data['cast'] = []; + file_put_contents($file, ' 'wait', 'seconds' => 1.0]], true) . ';'); + if ($effect) { + file_put_contents($root . '/assets/Data/system.php', ' ['player' => ['destinationMap' => $mapId, 'spawnPoint' => ['x' => 2, 'y' => 3]]], + ], true) . ';'); + } + + return $root; +} + +function readPreviewContractField(EditorSession $session, bool $effect = false): PreviewField +{ + if ($effect) { + return new ReflectionProperty($session, 'effectField')->getValue($session); + } + $preview = new ReflectionProperty($session, 'cinematicPreview')->getValue($session); + + return new ReflectionProperty($preview, 'field')->getValue($preview); +} + +it('forwards the graphical epoch over both wire routes and refuses retired feedback', function (bool $effect) { + $root = createPreviewContractProject(effect: $effect); + $before = sourceHashTree($root); + $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w')); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params])); + $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]); + $session = new ReflectionProperty($host, 'session')->getValue($host); + $index = $effect ? array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true) : 0; + $start = $effect ? 'cutscenes.effectField' : 'cutscenes.cinematicPreview'; + $exchange = $effect ? 'cutscenes.effectFieldScene' : 'cutscenes.cinematicScene'; + try { + $first = $request($start, ['index' => $index, 'width' => 40, 'height' => 12])['result']; + $picture = $request($exchange, ['sessionId' => $first['sessionId'], 'events' => [previewWindowReady()]])['result']; + expect($picture['sessionId'])->toBe($first['sessionId']) + ->and(previewFrameGenerations($picture['messages']))->not->toBe([]); + if ($effect) { + $request('cutscenes.effectFieldClose'); + $current = $request($start, ['index' => $index, 'width' => 40, 'height' => 12])['result']; + } else { + $current = $request('cutscenes.cinematicControl', ['action' => 'restart'])['result']; + } + expect($current['sessionId'])->not->toBe($first['sessionId']) + ->and($request($exchange, ['sessionId' => $first['sessionId'], 'events' => [previewWindowReady()]])['result']) + ->toMatchArray(['sessionId' => $current['sessionId'], 'messages' => [], 'diagnostics' => []]); + expect(previewFrameGenerations($request($exchange, ['sessionId' => $current['sessionId'], 'events' => [previewWindowReady()]])['result']['messages']))->not->toBe([]); + expect($request($exchange, ['sessionId' => 42, 'events' => []])['error']['kind'])->toBe('request'); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +})->with(['cinematic' => [false], 'effect' => [true]]); + +it('describes a stable cinematic epoch without opening a graphical host and uses it for the exchange', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $started = $session->startCinematicPreview(0, 40, 12); + $field = readPreviewContractField($session); + expect($started['sessionId'])->toBeString()->not->toBe('') + ->and($started['diagnostics'])->toBe([]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + foreach (['play', 'tick', 'pause'] as $action) { + expect($session->controlCinematicPreview($action, 0.1)['sessionId'])->toBe($started['sessionId']); + } + expect($session->exchangeCinematicScene([]))->toMatchArray(['sessionId' => $started['sessionId'], 'diagnostics' => [], 'messages' => []]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + $answer = $session->exchangeCinematicScene([previewWindowReady()], $started['sessionId']); + expect($answer['sessionId'])->toBe($started['sessionId']) + ->and(previewFrameGenerations($answer['messages']))->not->toBe([]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field)->sessionId)->toBe($started['sessionId']); + expect(fn() => $session->exchangeCinematicScene(['not json'], $started['sessionId']))->toThrow(SessionRefusal::class); + expect(ConfigStore::get(ProjectConfig::class)->get('audio.music'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.sfx'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.voice'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('save.autosave'))->toBeFalse(); + $backends = new ReflectionProperty($field->scene->previewSceneManager->game->audioManager, 'backends') + ->getValue($field->scene->previewSceneManager->game->audioManager); + expect($backends)->toHaveCount(1)->and($backends[0])->toBeInstanceOf(SilentAudioBackend::class); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('retires the same-grid cinematic epoch at restart before exchange and requires fresh READY', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $started = $session->startCinematicPreview(0, 40, 12); + $old = $session->exchangeCinematicScene([previewWindowReady()], $started['sessionId']); + $generation = max(previewFrameGenerations($old['messages'])); + $session->controlCinematicPreview('play'); + $session->controlCinematicPreview('tick', 0.1); + $restart = $session->controlCinematicPreview('restart'); + expect($restart['sessionId'])->toBeString()->not->toBe('')->not->toBe($started['sessionId']) + ->and($restart['elapsed'])->toBe(0.0) + ->and(new ReflectionProperty(readPreviewContractField($session), 'sceneHost') + ->getValue(readPreviewContractField($session)))->toBeNull(); + $pending = $session->exchangeCinematicScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $generation, 'frame' => 1, 'presented' => true])], $started['sessionId']); + expect($pending)->toMatchArray(['sessionId' => $restart['sessionId'], 'grid' => $old['grid'], 'messages' => []]); + $fresh = $session->exchangeCinematicScene([previewWindowReady()], $restart['sessionId']); + $frames = array_values(array_filter($fresh['messages'], static fn(array $message): bool => $message['type'] === 'frame')); + expect($fresh['sessionId'])->toBe($restart['sessionId'])->and($frames)->not->toBe([]) + ->and($frames[0]['payload']['reset'])->toBeTrue() + ->and($session->startCinematicPreview(0, 40, 12, keep: true)['sessionId'])->toBe($restart['sessionId']); + $replacement = $session->startCinematicPreview(0, 40, 12); + expect($replacement['sessionId'])->not->toBe($restart['sessionId']); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('rotates the shared field epoch on actual detach and resize but not a repeated detach without a host', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $field = PreviewField::open($root, 'harbour', new Vector2(2, 3), 40, 12, static fn(): float => 0.0); + try { + $initial = $field->sessionId; + $field->detachScene(); + expect($field->sessionId)->toBe($initial); + $field->exchangeScene([previewWindowReady()], $field->sessionId); + $field->detachScene(); + $detached = $field->sessionId; + expect($detached)->not->toBe($initial) + ->and($field->scene->getPresentationContext())->toBeNull() + ->and(count($field->frame()))->toBe(12); + $field->detachScene(); + expect($field->sessionId)->toBe($detached); + $field->exchangeScene([], $field->sessionId); + expect($field->sessionId)->toBe($detached); + $field->exchangeScene([previewWindowReady()], $field->sessionId); + $field->resize(40, 12); + $resized = $field->sessionId; + expect($resized)->not->toBe($detached)->and($field->getSceneGrid()->columns)->toBe(40); + $field->resize(50, 16); + expect($field->sessionId)->not->toBe($resized)->and(count($field->frame()))->toBe(16); + } finally { + $field->dispose(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('reports the detached cinematic epoch in controls before reopening the same-grid host', function () { + $session = EditorSession::open(createPreviewContractProject()); + try { + $first = $session->startCinematicPreview(0, 40, 12); + $session->exchangeCinematicScene([previewWindowReady()], $first['sessionId']); + $session->detachCinematicScene(); + $detached = $session->controlCinematicPreview('pause'); + expect($detached['sessionId'])->not->toBe($first['sessionId']) + ->and($session->exchangeCinematicScene([]))->toMatchArray(['sessionId' => $detached['sessionId'], 'messages' => []]) + ->and(previewFrameGenerations($session->exchangeCinematicScene([previewWindowReady()], $detached['sessionId'])['messages']))->not->toBe([]); + } finally { + $session->close(); + } +}); + +it('shares effect controls and exchanges identity across seeks and replaces it on same-grid reopen or resize', function () { + $root = createPreviewContractProject(effect: true); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $index = array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true); + try { + $first = $session->showEffectOnField($index, 0, 40, 12); + $field = readPreviewContractField($session, true); + expect($first['sessionId'])->toBeString()->not->toBe('') + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull() + ->and($session->exchangeEffectFieldScene([]))->toMatchArray(['sessionId' => $first['sessionId'], 'messages' => []]); + $session->exchangeEffectFieldScene([previewWindowReady()], $first['sessionId']); + expect($session->showEffectOnField($index, 4, 40, 12)['sessionId'])->toBe($first['sessionId']); + $session->closeEffectField(); + $reopened = $session->showEffectOnField($index, 0, 40, 12); + expect($reopened['sessionId'])->not->toBe($first['sessionId']) + ->and($session->exchangeEffectFieldScene([]))->toMatchArray(['sessionId' => $reopened['sessionId'], 'messages' => []]); + $session->exchangeEffectFieldScene([previewWindowReady()], $reopened['sessionId']); + $resized = $session->showEffectOnField($index, 0, 50, 16); + expect($resized['sessionId'])->not->toBe($reopened['sessionId']) + ->and($session->exchangeEffectFieldScene([]))->toMatchArray(['sessionId' => $resized['sessionId'], 'messages' => []]); + expect(fn() => $session->exchangeEffectFieldScene(['not json'], $resized['sessionId']))->toThrow(SessionRefusal::class); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('describes current map diagnostics without hiding useful cinematic or effect terrain', function (string $mapKind, bool $effect) { + $root = createPreviewContractProject($mapKind, $effect); + $session = EditorSession::open($root); + if ($mapKind === 'malformed') { + // The workspace is already open: exercise the runtime reader, not the Editor's import refusal. + file_put_contents($root . '/assets/Maps/harbour/harbour.data.php', 'showEffectOnField(array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true), 0, 40, 12) + : $session->startCinematicPreview(0, 40, 12); + expect($answer['diagnostics'])->toBeArray()->and(array_is_list($answer['diagnostics']))->toBeTrue() + ->and($answer['lines'])->toHaveCount(12)->and($answer['sessionId'])->toBeString()->not->toBe(''); + if ($mapKind === 'valid') { + expect($answer['diagnostics'])->toBe([]); + } else { + expect($answer['diagnostics'])->not->toBe([]); + foreach ($answer['diagnostics'] as $diagnostic) { + expect($diagnostic)->toBeString()->not->toBe(''); + } + $diagnostic = implode('\n', $answer['diagnostics']); + expect($diagnostic)->toContain('undefined terrain'); + if ($mapKind !== 'none') { + expect($diagnostic)->toContain($mapKind === 'missing' ? 'missing-preview-map' : 'harbour'); + } else { + expect($diagnostic)->toContain('No map'); + } + } + $field = readPreviewContractField($session, $effect); + if ($effect) { + expect($answer['issue'])->toBe($field->mapFailure); + } else { + expect($answer['failure'])->toBeNull()->and($answer['finished'])->toBeFalse(); + } + $exchange = $effect ? $session->exchangeEffectFieldScene([previewWindowReady()], $answer['sessionId']) + : $session->exchangeCinematicScene([previewWindowReady()], $answer['sessionId']); + expect($exchange['sessionId'])->toBe($answer['sessionId']) + ->and($exchange['diagnostics'])->toBe($answer['diagnostics']) + ->and(previewFrameGenerations($exchange['messages']))->not->toBe([]); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +})->with([ + 'cinematic valid' => ['valid', false], 'cinematic missing' => ['missing', false], + 'cinematic malformed' => ['malformed', false], 'cinematic no map' => ['none', false], + 'effect valid' => ['valid', true], 'effect missing' => ['missing', true], + 'effect malformed' => ['malformed', true], 'effect no map' => ['none', true], +]); + +it('replaces boot map failures through actual cinematic transfers and clears them on recovery without changing host identity', function () { + $root = createPreviewContractProject('missing'); + $script = [ + ['type' => 'wait', 'seconds' => 0.1], + ['type' => 'transfer', 'map' => 'second-missing-map', 'x' => 2, 'y' => 3], + ['type' => 'wait', 'seconds' => 0.1], + ['type' => 'transfer', 'map' => 'harbour', 'x' => 2, 'y' => 3], + ['type' => 'wait', 'seconds' => 1.0], + ]; + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', + 'startCinematicPreview(0, 40, 12); + expect(implode('\n', $started['diagnostics']))->toContain('missing-preview-map'); + $session->exchangeCinematicScene([previewWindowReady()], $started['sessionId']); + $session->controlCinematicPreview('play'); + $transferred = $session->controlCinematicPreview('tick', 0.1); + expect(implode('\n', $transferred['diagnostics']))->toContain('second-missing-map')->not->toContain('missing-preview-map') + ->and($transferred['sessionId'])->toBe($started['sessionId']); + $field = readPreviewContractField($session); + $recovered = $transferred; + for ($tick = 0; $tick < 8 && count($field->scene->transfers) < 2; $tick++) { + $recovered = $session->controlCinematicPreview('tick', 0.1); + } + expect(array_column($field->scene->transfers, 'map'))->toBe(['second-missing-map', 'harbour']); + expect($recovered['diagnostics'])->toBe([])->and($recovered['sessionId'])->toBe($started['sessionId']) + ->and(readPreviewContractField($session)->mapFailure)->toBeNull(); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('discards same-grid retired and unqualified feedback before READY can reach a replacement host', function (bool $effect) { + $root = createPreviewContractProject(effect: $effect); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + $index = $effect ? array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true) : 0; + $exchange = $effect ? $session->exchangeEffectFieldScene(...) : $session->exchangeCinematicScene(...); + try { + $old = $effect ? $session->showEffectOnField($index, 0, 40, 12) : $session->startCinematicPreview(0, 40, 12); + $oldFrame = $exchange([previewWindowReady()], $old['sessionId']); + if ($effect) { + $session->closeEffectField(); + $current = $session->showEffectOnField($index, 0, 40, 12); + } else { + $current = $session->controlCinematicPreview('restart'); + } + $field = readPreviewContractField($session, $effect); + $feedback = [ + previewWindowReady(), + json_encode(['protocol' => 2, 'type' => 'frame_ack', 'generation' => max(previewFrameGenerations($oldFrame['messages'])), + 'frame' => 1, 'presented' => true]), + json_encode(['protocol' => 2, 'type' => 'frame_rejected', 'generation' => 1, 'expectedGeneration' => 777, 'message' => 'retired rejection']), + json_encode(['protocol' => 2, 'type' => 'error', 'message' => 'retired view error']), + ]; + foreach ([null, '', $old['sessionId']] as $retiredId) { + expect($exchange([previewWindowReady()], $retiredId)['messages'])->toBe([]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + $reply = $exchange($feedback, $retiredId); + expect($reply)->toMatchArray(['sessionId' => $current['sessionId'], 'diagnostics' => $current['diagnostics'], + 'grid' => $oldFrame['grid'], 'messages' => []]) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull(); + } + expect($exchange([], null))->toMatchArray(['sessionId' => $current['sessionId'], 'messages' => []]); + $exchange([], $current['sessionId']); + expect($exchange(['not json', ...$feedback], $old['sessionId'])['messages'])->toBe([]); + $fresh = $exchange([previewWindowReady()], $current['sessionId']); + $frames = array_values(array_filter($fresh['messages'], static fn(array $message): bool => $message['type'] === 'frame')); + expect($fresh['sessionId'])->toBe($current['sessionId'])->and($frames)->not->toBe([]) + ->and($frames[0]['payload']['generation'])->toBe(1)->and($frames[0]['payload']['reset'])->toBeTrue(); + expect(fn() => $exchange(['not json'], $current['sessionId']))->toThrow(SessionRefusal::class); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +})->with(['cinematic restart' => [false], 'effect reopen' => [true]]); + +it('resizes a kept cinematic through its control description while preserving playback and retiring only the host epoch', function () { + $root = createPreviewContractProject(); + $before = sourceHashTree($root); + $session = EditorSession::open($root); + try { + $first = $session->startCinematicPreview(0, 40, 12, play: true); + $session->exchangeCinematicScene([previewWindowReady()], $first['sessionId']); + $advanced = $session->controlCinematicPreview('tick', 0.1); + $field = readPreviewContractField($session); + $resized = $session->startCinematicPreview(0, 50, 16, keep: true); + expect($resized['sessionId'])->not->toBe($first['sessionId']) + ->and($resized['elapsed'])->toBe($advanced['elapsed'])->and($resized['playing'])->toBeTrue() + ->and($resized['lines'])->toHaveCount(16)->and(readPreviewContractField($session))->toBe($field) + ->and(new ReflectionProperty($field, 'sceneHost')->getValue($field))->toBeNull() + ->and($session->exchangeCinematicScene([], $first['sessionId']))->toMatchArray([ + 'sessionId' => $resized['sessionId'], 'diagnostics' => [], 'messages' => [], + 'grid' => ['columns' => 50, 'rows' => 16, 'cellWidth' => 10, 'cellHeight' => 20], + ]); + expect($session->startCinematicPreview(0, 50, 16, keep: true)['sessionId'])->toBe($resized['sessionId']); + $fresh = $session->exchangeCinematicScene([previewWindowReady()], $resized['sessionId']); + expect(previewFrameGenerations($fresh['messages']))->toBe([1]); + } finally { + $session->close(); + } + expect(sourceHashTree($root))->toBe($before); +}); + +it('completes a transfer only when its destination map actually loads, keeping the undefined-terrain preview otherwise', function () { + $root = createPreviewContractProject('valid'); + $session = EditorSession::open($root); + try { + $session->startCinematicPreview(0, 40, 12); + $scene = readPreviewContractField($session)->scene; + $completed = 0; + $complete = static function () use (&$completed): void { $completed++; }; + + // Maps resolve against the project, as the session's own preview calls do. + $transfer = static fn(string $map): bool => Ichiloto\Editor\ProjectDirectoryContext::run($root, + static fn(): bool => $scene->transferPlayer(new Ichiloto\Engine\Field\Location($map, new Ichiloto\Engine\Core\Vector2(2, 3), []), true, $complete)); + $failed = $transfer('no-such-preview-map'); + + expect($failed)->toBeTrue() + ->and($completed)->toBe(0) + ->and(readPreviewContractField($session)->mapFailure)->not->toBeNull(); + + $transfer('harbour'); + + expect(readPreviewContractField($session)->mapFailure)->toBeNull() + ->and($completed)->toBe(1); + } finally { + $session->close(); + } +}); diff --git a/tests/Unit/Cutscenes/PreviewPlayerMovementTest.php b/tests/Unit/Cutscenes/PreviewPlayerMovementTest.php new file mode 100644 index 00000000..749a200c --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewPlayerMovementTest.php @@ -0,0 +1,159 @@ + CollisionType::NONE, '#' => CollisionType::SOLID, '=' => CollisionType::SOLID]; +PHP_SOURCE); + writeTilesetTestPng($root . '/assets/Graphics/Characters/$Walker.png', 6, 8); + file_put_contents($root . '/assets/Data/Entities/player.php', ' ['north' => ['N'], 'east' => ['E'], 'south' => ['S'], 'west' => ['W']], + 'sprites2d' => ['sheet' => 'Graphics/Characters/$Walker.png'], + ], true) . ';'); + + return $root; +} + +it('uses the project directional sprites and shared heading for both preview presentations', + function (Vector2 $direction, MovementHeading $heading, string $glyph, string $facing, int $row): void { + $field = PreviewField::open(createPreviewWalkingProject(), 'harbour', new Vector2(4, 4), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field, $direction, $heading, $glyph, $facing, $row): void { + $player = $field->scene->player; + $player->face($direction, $field->scene->camera); + expect($player->heading)->toBe($heading) + ->and($player->facing)->toBe($facing) + ->and($player->sprite)->toBe([$glyph]) + ->and([$player->position->x, $player->position->y])->toBe([4.0, 4.0]) + ->and($player->getGraphicalSpriteDefinition()->sourceRect->toArray()) + ->toBe(['x' => 2, 'y' => $row * 2, 'width' => 2, 'height' => 2]); + }); + expect(implode("\n", array_map(TerminalText::stripAnsi(...), $field->frame())))->toContain($glyph); + } finally { + $field->dispose(); + } + })->with([ + [Vector2::up(), MovementHeading::NORTH, 'N', 'up', 3], + [Vector2::right(), MovementHeading::EAST, 'E', 'right', 2], + [Vector2::down(), MovementHeading::SOUTH, 'S', 'down', 0], + [Vector2::left(), MovementHeading::WEST, 'W', 'left', 1], + ]); + +it('uses the runtime step at the callers pace and ends the walking presentation without moving again', function (): void { + $field = PreviewField::open(createPreviewWalkingProject(), 'harbour', new Vector2(4, 4), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + $player = $field->scene->player; + expect($field->scene->moveAtPace(0.4, fn(): bool => $player->tryMove(Vector2::right(), $field->scene->camera, StepOrigin::PLAYER_INPUT))) + ->toBeTrue() + ->and([$player->position->x, $player->position->y])->toBe([5.0, 4.0]) + ->and($player->heading)->toBe(MovementHeading::EAST) + ->and($player->getGraphicalSpriteMotion()?->seconds)->toBe(0.4) + ->and($player->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(4); + }); + $field->advance(0.7); + $field->run(function () use ($field): void { + $player = $field->scene->player; + expect([$player->position->x, $player->position->y])->toBe([5.0, 4.0]) + ->and($player->getGraphicalSpriteDefinition()->sourceRect->x)->toBe(2); + $player->face(Vector2::up(), $field->scene->camera); + expect($player->getGraphicalSpriteMotion())->toBeNull() + ->and($player->heading)->toBe(MovementHeading::NORTH); + }); + } finally { + $field->dispose(); + } +}); + +it('faces a blocked cell without moving or leaving a stale walking slide', function (): void { + $field = PreviewField::open(createPreviewWalkingProject(), 'harbour', new Vector2(1, 1), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + $player = $field->scene->player; + expect($player->tryMove(Vector2::left(), $field->scene->camera, StepOrigin::PLAYER_INPUT))->toBeFalse() + ->and([$player->position->x, $player->position->y])->toBe([1.0, 1.0]) + ->and($player->heading)->toBe(MovementHeading::WEST) + ->and($player->sprite)->toBe(['W']) + ->and($player->getGraphicalSpriteMotion())->toBeNull(); + }); + } finally { + $field->dispose(); + } +}); + +it('scrolls following previews through the shared map camera and leaves detached cameras alone', function (): void { + $root = createPreviewWalkingProject(); + writeOpeningMaps($root); + $field = PreviewField::open($root, 'skyfield-night', new Vector2(30, 8), 20, 8, fn(): float => 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + $player = $field->scene->player; + $camera = $field->scene->camera; + $before = clone $camera->position; + expect($player->tryMove(Vector2::right(), $camera, StepOrigin::PLAYER_INPUT))->toBeTrue() + ->and($camera->position->x)->toBe($before->x + 1); + $camera->detach(); + $detached = clone $camera->position; + expect($player->tryMove(Vector2::down(), $camera, StepOrigin::PLAYER_INPUT))->toBeTrue() + ->and([$camera->position->x, $camera->position->y])->toBe([$detached->x, $detached->y]); + }); + } finally { + $field->dispose(); + } +}); + +it('keeps ordinary map events and encounter bookkeeping out of shared preview movement', function (): void { + $root = createPreviewWalkingProject(); + $directory = $root . '/assets/Maps/harbour'; + $map = require $directory . '/harbour.data.php'; + $map['events']['g'] = ['class' => CinematicEventTrigger::class, + 'data' => ['cinematicId' => 'harbour-lanterns', 'mode' => 'auto', 'reusable' => false]]; + file_put_contents($directory . '/harbour.data.php', ' 0.0); + + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field): void { + expect($field->scene->player->findEventMarkerPosition('g'))->toBeNull() + ->and($field->scene->encounterManager)->toBeNull() + ->and($field->scene->player->tryMove(Vector2::right(), $field->scene->camera, StepOrigin::PLAYER_INPUT))->toBeTrue(); + }); + $field->advance(1.0); + expect($field->scene->finishedSessions)->toBe([]) + ->and($field->scene->cinematicController->active())->toBeNull() + ->and(ConfigStore::get(ProjectConfig::class)->get('save.autosave'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.music'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.sfx'))->toBeFalse() + ->and(ConfigStore::get(ProjectConfig::class)->get('audio.voice'))->toBeFalse() + ->and(sourceHashTree($root))->toBe($before); + } finally { + $field->dispose(); + } +}); diff --git a/tests/Unit/Cutscenes/PreviewRendererRelayTest.php b/tests/Unit/Cutscenes/PreviewRendererRelayTest.php new file mode 100644 index 00000000..578fba31 --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewRendererRelayTest.php @@ -0,0 +1,66 @@ +start(new RendererSessionConfig('Preview', sys_get_temp_dir(), new RendererGridConfig(10, 4, 16, 24), RendererProtocolVersion::V2)); + $relay->receive([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => $capabilities])]); + $client->pump(); + + return $client; +} + +it('negotiates what the window supports from its READY, as with a renderer process', function () { + $client = startRelayClient(new PreviewRendererRelay(), [RendererSessionConfig::GRAPHICAL_CANVAS]); + + expect($client->supports(RendererSessionConfig::GRAPHICAL_CANVAS))->toBeTrue() + ->and($client->supports(RendererSessionConfig::CANVAS_OVERLAY))->toBeFalse(); +}); + +it('queues each frame for the window in order, payload without envelope, and returns its acknowledgements', function () { + $relay = new PreviewRendererRelay(); + $client = startRelayClient($relay, [RendererSessionConfig::GRAPHICAL_CANVAS]); + $presentation = new RendererPresentation($client, new RendererGridConfig(10, 4, 16, 24)); + + $presentation->presentFrame($presentation->prepareCanvas(new PresentationCanvas(160, 96))); + $sent = $relay->collect(); + expect($sent)->not->toBeEmpty() + ->and(array_column($sent, 'type'))->toContain('frame') + ->and(array_key_exists('protocol', $sent[0]['payload']))->toBeFalse() + ->and($relay->collect())->toBe([]) + ->and($relay->getPendingWriteBytes())->toBe(0); + + $generation = $sent[array_key_last($sent)]['payload']['generation'] ?? 1; + $relay->receive([json_encode(['protocol' => 2, 'type' => 'frame_ack', 'generation' => $generation, 'frame' => 1, 'presented' => true])]); + $client->pump(); + expect(array_map(static fn($event) => $event->generation, $client->drainFrameAcknowledgements()))->toBe([$generation]); +}); + +it('refuses an answer that is not a renderer event, and anything sent once the window is detached', function () { + $relay = new PreviewRendererRelay(); + startRelayClient($relay, []); + + expect(fn() => $relay->receive(['not json']))->toThrow(RendererProtocolException::class); + $relay->shutdown(); + expect(fn() => $relay->trySend(new \Ichiloto\Engine\Rendering\Transport\RendererMessage( + \Ichiloto\Engine\Rendering\Transport\Enumerations\RendererMessageType::FRAME)))->toThrow(RendererTransportException::class); +}); diff --git a/tests/Unit/Cutscenes/PreviewSceneHostTest.php b/tests/Unit/Cutscenes/PreviewSceneHostTest.php new file mode 100644 index 00000000..ba89f41e --- /dev/null +++ b/tests/Unit/Cutscenes/PreviewSceneHostTest.php @@ -0,0 +1,67 @@ + 2, 'y' => 3, 'width' => 40, 'height' => 12]); + + try { + expect($preview->exchangeScene([]))->toBe([]); + $sent = $preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()); + expect(array_column($sent, 'type'))->toContain('frame') + ->and(previewFrameGenerations($sent))->not->toBe([]); + } finally { + $preview->dispose(); + } +}); + +it('sends nothing while nothing visible changes, and starts again from a reset when the view is attached again', function () { + $root = cutsceneProject(); + $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); + + try { + $first = $preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()); + $generation = max(previewFrameGenerations($first)); + expect($preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $generation, 'frame' => 1, 'presented' => true])], $preview->getSceneSessionId()))->toBe([]); + + $preview->detachScene(); + // Detached, the Terminal picture has the preview's own size again. + expect(count($preview->frame()))->toBe(12) + ->and(mb_strlen(Ichiloto\Engine\IO\Console\TerminalText::stripAnsi($preview->frame()[0])))->toBe(40); + $again = array_values(array_filter($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()), + static fn(array $message): bool => $message['type'] === 'frame')); + expect($again)->not->toBe([])->and($again[0]['payload']['reset'] ?? false)->toBeTrue(); + } finally { + $preview->dispose(); + } +}); + +it('follows the playhead: what the cinematic shows next arrives as the next generation once the last is acknowledged', function () { + $root = cutsceneProject(); + $preview = CinematicPreviewSession::start($root, harbourDefinition($root), ['x' => 2, 'y' => 3, 'width' => 40, 'height' => 12]); + + try { + $generation = max(previewFrameGenerations($preview->exchangeScene([previewWindowReady()], $preview->getSceneSessionId()))); + $preview->play(); + for ($tick = 0; $tick < 5; $tick++) { + $preview->tick(CinematicPreviewSession::TICK_SECONDS); + } + // The harbour's narration is on screen by now; the graphical view receives it as the next generation. + $next = previewFrameGenerations($preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'frame_ack', + 'generation' => $generation, 'frame' => 1, 'presented' => true])], $preview->getSceneSessionId())); + expect($next)->not->toBe([])->and(min($next))->toBeGreaterThan($generation); + } finally { + $preview->dispose(); + } +}); diff --git a/tests/Unit/Cutscenes/SignPreviewTextTest.php b/tests/Unit/Cutscenes/SignPreviewTextTest.php new file mode 100644 index 00000000..a33480b8 --- /dev/null +++ b/tests/Unit/Cutscenes/SignPreviewTextTest.php @@ -0,0 +1,52 @@ + ['player' => [ + 'destinationMap' => 'harbour', 'spawnPoint' => ['x' => 4, 'y' => 4], 'spawnSprite' => ['^']]]], true) . ";\n"); + writeTilesetTestPng($root . '/assets/Graphics/Boards/Wood.png', 36, 12); + is_dir($root . '/assets/Fonts') || mkdir($root . '/assets/Fonts', 0o777, true); + file_put_contents($root . '/assets/Fonts/Open.ttf', 'synthetic font'); + is_dir($root . '/assets/Data/Presentation') || mkdir($root . '/assets/Data/Presentation', 0o777, true); + file_put_contents($root . '/assets/Data/Presentation/field.php', " ['wood-sign' => ['name' => 'Wood Sign'," + . " 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sign' => ['asset' => 'Graphics/Boards/Wood.png', 'fontRole' => 'sign', 'left' => 8," + . " 'right' => 8, 'density' => 1, 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 160]]]];\n"); + file_put_contents($root . '/assets/Data/Presentation/text.php', " 'ichiloto.text/1', 'roles' => ['sign' => [" + . "'fonts' => [['asset' => 'Fonts/Open.ttf']], 'fontSize' => 12, 'foreground' => ['kind' => 'rgb', 'r' => 230, 'g' => 215, 'b' => 170]]]];\n"); + $data = $root . '/assets/Maps/harbour/harbour.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'worldObjects' => [['id' => 'inn-sign', 'resource' => 'wood-sign'," + . " 'anchor' => ['x' => 6, 'y' => 1], 'label' => ['message' => 'signs.inn-sign', 'default' => 'Inn']]],", (string) file_get_contents($data))); + + return $root; +} + +it('asks the editor window to lay out a sign\'s label, through the preview\'s own client', function () { + $session = EditorSession::open(signPreviewProject()); + $index = array_search('ember-spark', $session->listDatabaseRecords('cutscenes/effect')['records'], true); + $shown = $session->showEffectOnField($index, 0, 40, 12); + $ready = json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => [...json_decode(previewWindowReady(), true)['capabilities'], + RendererSessionConfig::TEXT_LAYOUT, RendererSessionConfig::CANVAS_LABELS, RendererSessionConfig::WORLD_CANVAS, RendererSessionConfig::SPRITE_PIVOT]]); + + try { + $messages = $session->exchangeEffectFieldScene([$ready], $shown['sessionId'])['messages']; + $requests = array_values(array_filter($messages, static fn(array $message): bool => $message['type'] === 'prepare_text')); + expect($requests)->not->toBe([]) + ->and($requests[0]['payload']['spec']['text'])->toBe('Inn') + ->and($requests[0]['payload']['spec']['fonts'][0]['asset'])->toBe('Fonts/Open.ttf'); + } finally { + $session->closeEffectField(); + } +}); diff --git a/tests/Unit/Cutscenes/StagedActorAuthoringTest.php b/tests/Unit/Cutscenes/StagedActorAuthoringTest.php new file mode 100644 index 00000000..e35c4e07 --- /dev/null +++ b/tests/Unit/Cutscenes/StagedActorAuthoringTest.php @@ -0,0 +1,276 @@ + 'harbour-lanterns', 'name' => 'Pose fixture', 'startMap' => 'harbour', + 'cast' => [ + ['kind' => 'staged_actor', 'id' => 'actor', 'sprite' => [$terminal], 'x' => 1, 'y' => 2], + ], + 'skip' => ['policy' => 'forbidden'], +]; +PHP); + file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', <<<'PHP' + 'sequence', 'commands' => [ + ['type' => 'stage_actor', 'id' => 'actor', 'sprite' => ['@'], 'x' => 1, 'y' => 2], + ['type' => 'wait', 'seconds' => 0.5], + ]], +]; +PHP); + + return $root; +} + +function getStagedAuthoringRow(EditorSession $session, string $field, array $frame = []): array +{ + return array_find($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? '') === $field) + ?? throw new RuntimeException('Missing staged authoring row ' . $field); +} + +function setStagedAuthoringRow(EditorSession $session, string $field, string $value, array $frame = []): array +{ + return $session->applyDatabaseRecord('cutscenes/cinematic', 0, getStagedAuthoringRow($session, $field, $frame)['key'], $value); +} + +it('offers graphical controls only to GUI records with engine-owned animation fields and existing pickers', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + $image = getStagedAuthoringRow($session, 'cast0Sprites2dasset'); + expect($image)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets', 'media' => ['kind' => 'image', 'root' => 'assets']]) + ->and(array_column($session->listReferences(null, 'png_assets'), 'value'))->toContain('Graphics/Poses/Actor.png'); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + $graphical = CutsceneSchemas::stagedActorFields(true, ['sprites2d' => ['asset' => 'Graphics/Poses/Actor.png']]); + $keys = array_map(static fn($field): string => $field->key, $graphical); + foreach (CinematicCommandSchema::export()['stagedPoseAnimation']['fields'] as $key) { + expect($keys)->toContain('sprites2d.animation.' . $key); + } + $terminal = CutsceneSchemas::stagedActorFields(); + expect(array_any($terminal, static fn($field): bool => str_starts_with($field->key, 'sprites2d') || str_starts_with($field->key, 'subject')))->toBeFalse(); +}); + +it('authors a looping pose and crop through GUI history and saves source-preserving runtime data', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $before = file_get_contents($file); + foreach ([ + 'asset' => 'Graphics/Poses/Actor.png', 'cells' => '2, 1', 'sourceRect' => '0, 0, 32, 16', + 'animation.columns' => '2', 'animation.frames' => '0, 1, 1, 0', 'animation.fps' => '4', + 'animation.loop' => 'false', 'animation.restFrame' => '1', + ] as $key => $value) { + expect(setStagedAuthoringRow($session, 'cast0Sprites2d' . str_replace('.', '', $key), $value)['changed'])->toBeTrue(); + } + $session->undo(); + expect(getStagedAuthoringRow($session, 'cast0Sprites2danimationrestFrame')['value'])->toBe('0'); + $session->redo(); + expect(file_get_contents($file))->toBe($before); + $session->saveDatabase('cutscenes/cinematic'); + $data = require $file; + $pose = CinematicStageManager::getGraphicalSprites($data['cast'][0]['sprites2d']); + expect($pose)->toBeInstanceOf(FieldPoseAnimation::class) + ->and($pose->frames)->toBe([0, 1, 1, 0])->and($pose->loop)->toBeFalse() + ->and($pose->fps)->toBe(4)->and($pose->restFrame)->toBe(1) + ->and($pose->getFrame($root . '/assets', 0.25)->sourceRect->x)->toBe(16) + ->and(file_get_contents($file))->toContain('// Keep this authored comment and terminal art.', '$terminal =', "'sprite' => [$" . 'terminal]'); + $reopened = EditorSession::open($root); + expect(getStagedAuthoringRow($reopened, 'cast0Sprites2danimationloop')['value'])->toBe('false'); +}); + +it('switches image forms and clears pose animation as atomic undoable edits without changing terminal sprites', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationcolumns', '2'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationframes', '0, 1'); + $sprite = getStagedAuthoringRow($session, 'cast0Sprite'); + setStagedAuthoringRow($session, 'cast0Sprites2dsheet', 'Graphics/Poses/$Walker.png'); + expect(getStagedAuthoringRow($session, 'cast0Sprite'))->toBe($sprite) + ->and(getStagedAuthoringRow($session, 'cast0Sprites2dindex')['kind'])->toBe('integer'); + $session->undo(); + expect(getStagedAuthoringRow($session, 'cast0Sprites2danimationframes')['value'])->toBe('0, 1'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationframes', ''); + $session->saveDatabase('cutscenes/cinematic'); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns'); + expect($asset->data()['cast'][0]['sprites2d'])->toBe(['asset' => 'Graphics/Poses/Actor.png']); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', ''); + $session->saveDatabase('cutscenes/cinematic'); + expect(CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')->data()['cast'][0])->not->toHaveKey('sprites2d'); +}); + +it('edits nested stage commands and bindings through the same record paths', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + $frame = [CutsceneAsset::COMMANDS_KEY, 0, 'commands']; + setStagedAuthoringRow($session, 'command0Sprites2dasset', 'Graphics/Poses/Actor.png', $frame); + setStagedAuthoringRow($session, 'command0Sprites2danimationfps', '6', $frame); + setStagedAuthoringRow($session, 'command0Subjectkind', 'player', $frame); + setStagedAuthoringRow($session, 'command0Replace', 'true', $frame); + $row = getStagedAuthoringRow($session, 'command0Id', $frame); + expect($row['childNoun'])->toBe('suppressed subject'); + $session->addDatabaseItem('cutscenes/cinematic', 0, $row['key'], child: true); + setStagedAuthoringRow($session, 'command0Suppress0Kind', 'npc', $frame); + $choices = $session->listReferences(null, 'map_npcs', ['category' => 'cutscenes/cinematic', 'index' => 0]); + expect(array_column($choices, 'value'))->toContain('keeper'); + setStagedAuthoringRow($session, 'command0Suppress0Id', 'keeper', $frame); + $session->saveDatabase('cutscenes/cinematic'); + $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns'); + $actor = $asset->commands()[0]['commands'][0]; + CinematicStageManager::validateBinding($actor); + expect($actor)->toMatchArray(['subject' => ['kind' => 'player'], 'suppress' => [['kind' => 'npc', 'id' => 'keeper']], 'replace' => true]) + ->not->toHaveKeys(['x', 'y', 'collision', 'facing']) + ->and($actor['sprites2d']['animation']['fps'])->toBe(6); + setStagedAuthoringRow($session, 'command0Subjectkind', '', $frame); + $session->undo(); + expect(getStagedAuthoringRow($session, 'command0Suppress0Id', $frame)['value'])->toBe('keeper'); +}); + +it('authors actor-envelope commands without flattening their source or writing ignored inline fields', function () { + $root = makeStagedAuthoringProject(); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($file, <<<'PHP' + 'stage_actor', 'actor' => ['id' => 'actor', 'sprite' => ['@'], 'x' => 1, 'y' => 2]], +]; +PHP); + $session = EditorSession::open($root); + $frame = [CutsceneAsset::COMMANDS_KEY]; + setStagedAuthoringRow($session, 'command0Actorsprites2dasset', 'Graphics/Poses/Actor.png', $frame); + setStagedAuthoringRow($session, 'command0Actorsprites2danimationcolumns', '2', $frame); + setStagedAuthoringRow($session, 'command0Actorsprites2danimationframes', '0, 1', $frame); + setStagedAuthoringRow($session, 'command0Actorsubjectkind', 'npc', $frame); + setStagedAuthoringRow($session, 'command0Actorsubjectid', 'keeper', $frame); + $session->addDatabaseItem('cutscenes/cinematic', 0, getStagedAuthoringRow($session, 'command0Actorid', $frame)['key'], child: true); + setStagedAuthoringRow($session, 'command0Suppress0Kind', 'npc', $frame); + setStagedAuthoringRow($session, 'command0Suppress0Id', 'keeper', $frame); + setStagedAuthoringRow($session, 'command0Suppress0Kind', 'player', $frame); + $session->saveDatabase('cutscenes/cinematic'); + $commands = require $file; + expect(array_keys($commands[0]))->not->toContain('sprites2d', 'subject', 'suppress', 'actor.suppress') + ->and($commands[0]['actor']['suppress'])->toBe([['kind' => 'player']]) + ->and($commands[0]['actor']['subject'])->toBe(['kind' => 'npc', 'id' => 'keeper']) + ->and(file_get_contents($file))->toContain('// This command owns its actor envelope.'); + $pose = CinematicStageManager::getGraphicalSprites($commands[0]['actor']['sprites2d']); + expect($pose->loop)->toBeTrue()->and($pose->getFrame($root . '/assets', 0.25)->sourceRect->x)->toBe(0); + $session->removeDatabaseItem('cutscenes/cinematic', 0, getStagedAuthoringRow($session, 'command0Suppress0Kind', $frame)['key']); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $file)[0]['actor'])->not->toHaveKey('suppress'); +}); + +it('refuses unsupported source expressions before mutation and still edits independent literal fields', function () { + $root = makeStagedAuthoringProject(); + $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + file_put_contents($file, str_replace("'x' => 1, 'y' => 2", "'x' => 1, 'y' => 2, 'sprites2d' => ['asset' => 'Graphics/Poses/Actor.png', 'animation' => ['frames' => [0], 'fps' => 4 * 2]]", file_get_contents($file))); + $before = file_get_contents($file); + $session = EditorSession::open($root); + expect(fn() => setStagedAuthoringRow($session, 'cast0Sprites2danimationfps', '12'))->toThrow(SessionRefusal::class, 'cannot be rewritten safely') + ->and(file_get_contents($file))->toBe($before) + ->and($session->listUnsavedChanges())->toBe([]) + ->and(getStagedAuthoringRow($session, 'cast0Sprites2danimationfps')['value'])->toBe('8'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationloop', 'false'); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($file))->toContain("'fps' => 4 * 2", "'loop' => false"); +}); + +it('refuses invalid runtime animation values without changing data or history', function () { + $session = EditorSession::open(makeStagedAuthoringProject()); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + foreach (['animation.fps' => '0', 'animation.columns' => '65', 'animation.frames' => '9', 'cells' => '1.5, 1', 'sourceRect' => '0, 0, 0, 4'] as $field => $value) { + expect(fn() => setStagedAuthoringRow($session, 'cast0Sprites2d' . str_replace('.', '', $field), $value))->toThrow(SessionRefusal::class); + } + $session->undo(); + expect($session->listUnsavedChanges())->toBe([]); +}); + +it('keeps graphical data when the terminal workflow edits a terminal field', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + setStagedAuthoringRow($session, 'cast0Sprites2dasset', 'Graphics/Poses/Actor.png'); + setStagedAuthoringRow($session, 'cast0Sprites2danimationfps', '5'); + $session->saveDatabase('cutscenes/cinematic'); + $editor = cutscenesEditor($root, 160, 50); + $asset = libraryOf($editor)->find(CutsceneType::CINEMATIC, 'harbour-lanterns'); + $graphics = $asset->data()['cast'][0]['sprites2d']; + expect(cutsceneFieldIds($editor))->not->toContain('cast0Sprites2dasset', 'cast0Subjectkind'); + setCutsceneField($editor, 'cast0X', '3'); + $asset->save(); + expect($asset->data()['cast'][0]['sprites2d'])->toBe($graphics); +}); + +it('binds cast artwork to a selected NPC and preserves visibility and transforms through undo', function () { + $root = makeStagedAuthoringProject(); + $session = EditorSession::open($root); + setStagedAuthoringRow($session, 'cast0Subjectkind', 'npc'); + $session->undo(); + expect(getStagedAuthoringRow($session, 'cast0X')['value'])->toBe('1') + ->and(getStagedAuthoringRow($session, 'cast0Y')['value'])->toBe('2'); + $session->redo(); + $npc = getStagedAuthoringRow($session, 'cast0Subjectid'); + expect($npc['reference'])->toBe('map_npcs'); + setStagedAuthoringRow($session, 'cast0Subjectid', 'keeper'); + setStagedAuthoringRow($session, 'cast0Visible', 'false'); + $row = getStagedAuthoringRow($session, 'cast0Id'); + $session->addDatabaseItem('cutscenes/cinematic', 0, $row['key'], child: true); + $session->removeDatabaseItem('cutscenes/cinematic', 0, getStagedAuthoringRow($session, 'cast0Suppress0Kind')['key']); + $session->saveDatabase('cutscenes/cinematic'); + $actor = CutsceneLibrary::fromProject($root)->find(CutsceneType::CINEMATIC, 'harbour-lanterns')->data()['cast'][0]; + CinematicStageManager::validateBinding($actor); + expect($actor)->toMatchArray(['subject' => ['kind' => 'npc', 'id' => 'keeper'], 'visible' => false]) + ->not->toHaveKeys(['x', 'y', 'facing', 'collision', 'suppress']); +}); + +it('offers a map NPC look in both editors and clears the look it replaces', function (bool $graphical) { + $keys = static fn(array $entry): array => array_map(static fn($field): string => $field->key, + CutsceneSchemas::stagedActorFields($graphical, $entry)); + expect($keys(['sprite' => ['x']]))->toContain('appearance.kind', 'sprite')->not->toContain('appearance.id'); + $wearing = ['kind' => 'staged_actor', 'id' => 'gate', 'appearance' => ['kind' => 'npc', 'id' => 'route-gate'], + 'at' => ['kind' => 'npc', 'id' => 'route-gate']]; + expect($keys($wearing))->toContain('appearance.kind', 'appearance.id') + ->not->toContain('sprite', 'asset', 'sprites.north', 'sprites2d.sheet', 'subject.kind'); + // Choosing the look drops the actor's own glyphs and sheet, which the Engine refuses beside it. + $chosen = \Ichiloto\Editor\Database\StagedActorFields::prepareAppearanceEdit([...$wearing, 'sprite' => ['x'], + 'sprites2d' => ['sheet' => 'Graphics/Characters/!$Gate.png']], 'appearance.kind'); + expect($chosen)->not->toHaveKeys(['sprite', 'sprites2d']); + CinematicStageManager::validateBinding($chosen); + $cleared = \Ichiloto\Editor\Database\StagedActorFields::prepareAppearanceEdit( + array_replace($wearing, ['appearance' => ['id' => 'route-gate']]), 'appearance.kind'); + expect($cleared)->not->toHaveKey('appearance'); +})->with(['terminal' => false, 'graphical' => true]); + +it('edits a cast member that names no kind as the staged actor the Engine stages', function () { + $cast = CutsceneSchemas::castList(); + $entry = ['id' => 'threshold', 'sprite' => ['='], 'x' => 1, 'y' => 2]; + $keys = array_map(static fn($field): string => $field->key, $cast->fieldsFor($entry)); + expect($cast->getVariant($entry))->toBe('staged_actor') + ->and($keys)->toContain('kind', 'id', 'sprite', 'appearance.kind', 'x', 'y') + ->and(\Ichiloto\Editor\Database\StagedActorFields::isStagedActorEntry($entry))->toBeTrue() + ->and(\Ichiloto\Editor\Database\StagedActorFields::isStagedActorEntry(['type' => 'wait']))->toBeFalse(); +}); diff --git a/tests/Unit/Cutscenes/SummonStageAuthoringTest.php b/tests/Unit/Cutscenes/SummonStageAuthoringTest.php new file mode 100644 index 00000000..300d1a5c --- /dev/null +++ b/tests/Unit/Cutscenes/SummonStageAuthoringTest.php @@ -0,0 +1,47 @@ +find(CutsceneType::SUMMON, 'lantern-wisp'); + + expect(cutsceneField($editor, '@sequence')['value'])->toBe('Terminal') + ->and(cutsceneFieldIds($editor))->not->toContain('@stage'); + + setCutsceneField($editor, '@sequence', 'graphical'); + expect(cutsceneField($editor, '@stage')['value'])->toBe('none'); + + setCutsceneField($editor, '@stage', 'added'); + expect($asset->hasStage())->toBeTrue() + ->and(cutsceneFieldIds($editor))->toContain('stage.canvas', 'stage.startFrame', 'stage.restoreFrame', 'commandListStage.subjects', 'commandListStage.camera'); + + callEditorMethod($editor, 'performUndo'); + expect($asset->hasStage())->toBeFalse() + ->and($asset->isDirty())->toBeFalse(); +}); + +it('adds a stage subject in its frame, and a named point to the subject under the cursor', function () { + $editor = cutscenesEditor(stagedSummonProject(), 160, 50); + callEditorMethod($editor, 'switchCutsceneType', CutsceneType::SUMMON); + callEditorMethod($editor, 'selectCutsceneById', 'lantern-wisp'); + setCutsceneField($editor, '@sequence', 'graphical'); + setCutsceneField($editor, '@stage', 'added'); + + openCutsceneFrame($editor, 'commandListStage.subjects'); + pressKeys($editor, 'O'); + expect(cutsceneFieldIds($editor))->toContain('subject0Id', 'subject0Position', 'subject0Size'); + + $ids = addCutsceneAfter($editor, 'subject0Id'); + expect($ids)->toContain('subject0Attachment0Id'); +}); diff --git a/tests/Unit/DatabaseCategoryListTest.php b/tests/Unit/DatabaseCategoryListTest.php index 79f2a8b3..d00b0ade 100644 --- a/tests/Unit/DatabaseCategoryListTest.php +++ b/tests/Unit/DatabaseCategoryListTest.php @@ -55,12 +55,12 @@ function databaseListLinesFor(string $projectRoot, string $categoryKey): array it('says why a category cannot be edited rather than looking broken', function () { $root = makeTemporaryProject(); - $database = loadRecordDatabase($root, 'types'); + $category = makeUnwritableCategory($root); + $database = loadRecordDatabase($root, $category); - // Element and weapon types are PHP enum declarations, not data. expect($database->isEditable())->toBeFalse(); - $text = implode("\n", databaseListLinesFor($root, 'types')); + $text = implode("\n", databaseListLinesFor($root, $category)); expect($text)->toContain($database->getReadOnlyReason() ?? 'read only'); }); diff --git a/tests/Unit/DatabaseDeletionTest.php b/tests/Unit/DatabaseDeletionTest.php index 4a7fc880..8cfddde7 100644 --- a/tests/Unit/DatabaseDeletionTest.php +++ b/tests/Unit/DatabaseDeletionTest.php @@ -20,8 +20,8 @@ $workspace = getEditorProperty($editor, 'workspace'); expect(getEditorProperty($editor, 'isDatabaseEntryDeleteConfirmationOpen'))->toBeFalse() - ->and($workspace->classDatabase->getClasses())->toHaveCount(2) - ->and($workspace->classDatabase->isDirty())->toBeFalse() + ->and($workspace->getRecordDatabase('classes')->getRecords())->toHaveCount(2) + ->and($workspace->getRecordDatabase('classes')->isDirty())->toBeFalse() ->and(getEditorProperty($editor, 'pendingDatabaseDeletion'))->toBeNull(); } finally { removeDirectoryRecursively($root); @@ -42,7 +42,7 @@ $workspace = getEditorProperty($editor, 'workspace'); expect(getEditorProperty($editor, 'isDatabaseEntryDeleteConfirmationOpen'))->toBeFalse() - ->and($workspace->classDatabase->getClasses())->toHaveCount(2); + ->and($workspace->getRecordDatabase('classes')->getRecords())->toHaveCount(2); } } finally { removeDirectoryRecursively($root); @@ -55,21 +55,21 @@ try { $editor = deletionEditor($root); openDatabaseCategory($editor, 'classes'); - setEditorProperty($editor, 'databaseSelectedClassIndex', 1); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['classes' => 1]); callEditorMethod($editor, 'dispatchInput', "\033[3~"); callEditorMethod($editor, 'dispatchInput', 'y'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $names = array_map(static fn(object $class): string => $class->getName(), $workspace->classDatabase->getClasses()); + $names = $workspace->getRecordDatabase('classes')->getEntryLabels(); expect($names)->toBe(['Vanguard']) - ->and($workspace->classDatabase->isDirty())->toBeTrue() - ->and(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(0); + ->and($workspace->getRecordDatabase('classes')->isDirty())->toBeTrue() + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(0); callEditorMethod($editor, 'dispatchInput', "\x1a"); - $restored = array_map(static fn(object $class): string => $class->getName(), $workspace->classDatabase->getClasses()); + $restored = $workspace->getRecordDatabase('classes')->getEntryLabels(); expect($restored)->toBe(['Vanguard', 'Oracle']); } finally { @@ -110,7 +110,7 @@ try { $editor = deletionEditor($root); - // The engine has no tileset system, so the category is genuinely empty. + // The fixture has no tilesets, so the category is genuinely empty. openDatabaseCategory($editor, 'tilesets'); callEditorMethod($editor, 'dispatchInput', "\033[3~"); @@ -123,16 +123,17 @@ it('refuses deletion in a read-only category before prompting', function () { $root = makeTemporaryProject(); - $typesPath = $root . '/assets/Data/Types'; + $category = makeUnwritableCategory($root); + $typesPath = $root . '/assets/Data'; $hash = static fn(): string => md5(implode('', array_map( static fn(string $file): string => (string) file_get_contents($file), - glob($typesPath . '/*.php') ?: [] + glob($typesPath . '/system.php') ?: [] ))); $before = $hash(); try { $editor = deletionEditor($root); - openDatabaseCategory($editor, 'types'); + openDatabaseCategory($editor, $category); // The category lists real entries, so this is not an "empty" refusal. expect(callEditorMethod($editor, 'getDatabaseEntryLabels'))->not->toBeEmpty(); diff --git a/tests/Unit/DefinitionDiagnosticsTest.php b/tests/Unit/DefinitionDiagnosticsTest.php index e853810a..7fa99269 100644 --- a/tests/Unit/DefinitionDiagnosticsTest.php +++ b/tests/Unit/DefinitionDiagnosticsTest.php @@ -85,37 +85,22 @@ function writeActorFile(string $root, string $name, array $data): void ->toContain('variant awakened adjusts "luck"'); }); -it('leaves the runtime to enforce its own bounds, and says so when a project cannot open', function () { +it('leaves the runtime to enforce its own bounds, and reports the record it refuses', function () { $root = makeTemporaryProject('ichiloto-diag-'); + $before = diagnosticsFor($root, Severity::ERROR); // The engine's own constructor refuses a sell rate outside 0 through - // 10000. It does that by throwing, which takes the whole file with it, - // so the project cannot be opened at all rather than opening with a - // quietly clamped value. The editor does not re-check the bound; it - // surfaces the engine's own words. - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' - toHaveCount(1) - ->and($errors[0])->toContain('The inventory could not be read') - ->and($errors[0])->toContain('sell rate must be between 0 and 10000'); + // 10000 rather than clamping it, and the game leaves that record out. + // The editor does not re-check the bound; it surfaces the engine's own + // words against the record's own file. + file_put_contents($root . '/assets/Data/Items/0099-odd-potion.php', " \Ichiloto\Engine\Entities\Inventory\InventoryItem::class, + 'data' => ['kind' => 'item', 'id' => 'item.odd', 'name' => 'Odd Potion', 'description' => 'Priced strangely.', 'icon' => 'i', 'price' => 10, 'sellRateBasisPoints' => 25000], + ], true) . ";\n"); + + // Reported once, and the rest of the inventory still reads. + $errors = array_values(array_diff(diagnosticsFor($root, Severity::ERROR), $before)); + + expect($errors)->toBe(['assets/Data/Items/0099-odd-potion.php: Item sell rate must be between 0 and 10000 basis points.']); }); it('reports a knowledge catalogue that points at what it does not declare', function () { @@ -129,6 +114,7 @@ function writeActorFile(string $root, string $name, array $data): void ['id' => 'creature.rat', 'recordType' => 'creature', 'displayName' => 'Rat', 'quickCard' => 'A rat.'], ['id' => 'creature.rat', 'recordType' => 'creature', 'displayName' => 'Rat again', 'quickCard' => 'The same id.'], ['id' => 'person.ghost', 'recordType' => 'apparition', 'displayName' => 'Ghost', 'quickCard' => ''], + ['id' => 'creature.wisp', 'recordType' => 'creature', 'displayName' => 'Wisp', 'quickCard' => 'A wisp.', 'summon' => 'absent'], [ 'id' => 'creature.bat', 'recordType' => 'creature', @@ -150,12 +136,14 @@ function writeActorFile(string $root, string $name, array $data): void expect($errors)->toContain('Subject id "creature.rat" is declared more than once') ->toContain('record type "apparition", which the catalogue does not declare') - ->toContain('person.ghost has no quick card') ->toContain('creature.bat is related to "creature.moth"') ->toContain('The enemy "Sewer Rat" maps to the subject "creature.nothing"') ->toContain('report.one concerns the subject "creature.unknown"') ->toContain('Report id "report.one" is declared more than once') - ->and($warnings)->toContain('disagrees with "report.absent"'); + ->and($errors)->not->toContain('quick card') + // A card may wait for its writer; it is flagged, not refused. + ->and($warnings)->toContain('disagrees with "report.absent"', 'person.ghost has no quick card yet', + 'creature.wisp is a record of the summon "absent", which the project does not have'); }); it('refuses a catalogue that would ship what only the author should know', function () { @@ -178,7 +166,7 @@ function writeActorFile(string $root, string $name, array $data): void it('reports a name two definitions both claim', function () { $root = makeTemporaryProject('ichiloto-diag-'); - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' + writeItemRecords($root, ...itemsFromSource(<<<'PHP' toContain('"elixir" is claimed by'); diff --git a/tests/Unit/DialoguePortraitsTerminalTest.php b/tests/Unit/DialoguePortraitsTerminalTest.php new file mode 100644 index 00000000..b9d1fed5 --- /dev/null +++ b/tests/Unit/DialoguePortraitsTerminalTest.php @@ -0,0 +1,54 @@ + 'breakfast-banter', + 'title' => 'Breakfast Banter', + 'beats' => [ + ['actor' => 'Kaelion', 'text' => 'Again?'], + ['speaker' => 'Mom', 'text' => 'Eat first.', 'emotion' => 'Furious'], + ], +]; +PHP); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'skits'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $emotion = array_search('beat0Emotion', array_column($fields, 'field'), true); + expect($fields[$emotion]['options'])->toBe(['Neutral', 'Angry'])->and($fields[$emotion]['value'])->toBe('Neutral') + ->and(array_column($fields, 'label'))->toContain('Beat 2 Emotion Warning'); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $emotion); + callEditorMethod($editor, 'adjustDatabaseOptionField', 1); + expect(getEditorProperty($editor, 'workspace')->getRecordDatabase('skits')->getRecordByIndex(0)?->get('beats')[0]['emotion'])->toBe('Angry'); +}); + +it('adds an emotion to an identity and picks its portrait among the project images', function () { + [$root, $file] = createDialogueProject(); + $editor = deletionEditor($root); + openDatabaseCategory($editor, DialoguePortraitFields::CATEGORY); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + setEditorProperty($editor, 'databaseSelectedSettingIndex', array_search('emotionList', array_column($fields, 'field'), true)); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + $portrait = $fields[array_search('emotion0Portrait', array_column($fields, 'field'), true)]; + expect($portrait['reference'])->toBe('png_assets'); + expect(callEditorMethod($editor, 'openReferencePicker', $portrait))->toBeTrue(); + $picker = getEditorProperty($editor, 'referencePicker'); + expect($picker->isOpen())->toBeTrue(); + + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase(DialoguePortraitFields::CATEGORY); + expect(array_column($database->getRecordByIndex(0)?->getEntries($database->schema->findInlineSubList('emotions')), 'name'))->toBe(['New Emotion', 'Angry']); +}); diff --git a/tests/Unit/DurableSourceIdentityTest.php b/tests/Unit/DurableSourceIdentityTest.php deleted file mode 100644 index 9ca8dc98..00000000 --- a/tests/Unit/DurableSourceIdentityTest.php +++ /dev/null @@ -1,552 +0,0 @@ - - */ -function identityCategories(string $root): array -{ - $open = []; - - foreach (['items', 'weapons', 'armors'] as $category) { - $open[$category] = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category)); - } - - return $open; -} - -/** - * Returns the stable ids each inventory category holds, read fresh from disk. - * - * @return array - */ -function identityIds(string $root): array -{ - $ids = []; - - foreach (['items', 'weapons', 'armors'] as $category) { - $ids[$category] = array_map( - static fn($record): string => strval($record->get('id')), - ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->getRecords(), - ); - } - - return $ids; -} - -/** - * Returns the ids of a category as an open database currently lists them. - * - * @return string[] - */ -function listedIds(ProjectRecordDatabase $database): array -{ - return array_map(static fn($record): string => strval($record->get('id')), $database->getRecords()); -} - -/** - * Returns the price the file now declares for one stable id, read fresh. - */ -function priceOnDisk(string $root, string $category, string $id): mixed -{ - foreach (ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->getRecords() as $record) { - if ($record->get('id') === $id) { - return $record->get('price'); - } - } - - return null; -} - -/** - * Returns the authored bytes of the entry declaring one stable id. - * - * @return string|null The entry's source, or null when the file lacks it. - */ -function entryBytesFor(string $path, string $id): ?string -{ - $document = PhpSourceDocument::parse((string) file_get_contents($path)); - - for ($index = 0; $index < $document->entryCount(); $index++) { - $source = $document->entrySource($index); - - if ($source !== null && preg_match("/\\bid:\\s*'" . preg_quote($id, '/') . "'/", $source) === 1) { - return $source; - } - } - - return null; -} - -/** - * Returns the index of a record with one stable id in an open database. - */ -function indexOfId(ProjectRecordDatabase $database, string $id): int -{ - $index = array_search($id, listedIds($database), true); - - if ($index === false) { - throw new RuntimeException("{$id} is not listed."); - } - - return $index; -} - -/** - * The review's exact sequence, in one category, with a sibling left dirty - * throughout: delete the middle of A, B, C; save; undo; save; edit the - * restored B; save; reload. - */ -function runRestoreSequence(string $category, string $sibling): array -{ - $project = identityProject(); - - if ($project === null) { - return []; - } - - [$root, $path] = $project; - $open = identityCategories($root); - $before = identityIds($root); - [$a, $b, $c] = $before[$category]; - $bytesBefore = [ - 'a' => entryBytesFor($path, $a), - 'c' => entryBytesFor($path, $c), - ]; - - // The sibling is dirty for the whole sequence and never saved during it. - $open[$sibling]->setField(0, 'price', '999'); - - $removed = $open[$category]->removeRecord(1); - $open[$category]->save(); - $afterDelete = identityIds($root)[$category]; - - $open[$category]->insertRecord(1, $removed); - $open[$category]->save(); - $afterUndo = identityIds($root)[$category]; - - $open[$category]->setField(1, 'price', '4242'); - $open[$category]->save(); - - return [ - 'root' => $root, - 'path' => $path, - 'open' => $open, - 'ids' => [$a, $b, $c], - 'before' => $before, - 'afterDelete' => $afterDelete, - 'afterUndo' => $afterUndo, - 'bytesBefore' => $bytesBefore, - 'restored' => $removed, - ]; -} - -it('finds a restored record by its id and edits it, not its neighbour', function (string $category, string $sibling) { - $run = runRestoreSequence($category, $sibling); - - if ($run === []) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$a, $b, $c] = $run['ids']; - $before = $run['before']; - $path = $run['path']; - $root = $run['root']; - - // The delete removed exactly B; the undo put exactly B back, between A - // and C in the file as well as in the list; and the edit landed on B. - expect($run['afterDelete'])->toBe(array_values(array_diff($before[$category], [$b]))) - ->and($run['afterUndo'])->toBe($before[$category]) - ->and(priceOnDisk($root, $category, $b))->toBe(4242) - ->and(entryBytesFor($path, $a))->toBe($run['bytesBefore']['a']) - ->and(entryBytesFor($path, $c))->toBe($run['bytesBefore']['c']); - - // The reload lists every category exactly as it did, with only B changed. - $reloaded = identityIds($root); - - foreach (['items', 'weapons', 'armors'] as $each) { - expect($reloaded[$each])->toBe($before[$each], "{$each} changed."); - } - - // The sibling is still dirty with its own edit and nothing of it was - // written. - expect($run['open'][$sibling]->isDirty())->toBeTrue() - ->and(priceOnDisk($root, $sibling, $before[$sibling][0]))->not->toBe(999); -})->with([ - ['items', 'weapons'], - ['weapons', 'armors'], - ['armors', 'items'], -])->group('engine'); - -it('redoes the delete after the restored save, and undoes it again, by id', function (string $category) { - $run = runRestoreSequence($category, $category === 'items' ? 'armors' : 'items'); - - if ($run === []) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$a, $b, $c] = $run['ids']; - $root = $run['root']; - $path = $run['path']; - $database = $run['open'][$category]; - - // Redo: the delete of B again, then save. Only B leaves. - $database->removeRecord(indexOfId($database, $b)); - $database->save(); - - expect(identityIds($root)[$category])->toBe(array_values(array_diff($run['before'][$category], [$b]))) - ->and(entryBytesFor($path, $a))->toBe($run['bytesBefore']['a']) - ->and(entryBytesFor($path, $c))->toBe($run['bytesBefore']['c']); - - // Undo once more: B returns between A and C, still carrying its edit. - $database->insertRecord(1, $run['restored']); - $database->save(); - - expect(identityIds($root)[$category])->toBe($run['before'][$category]) - ->and(priceOnDisk($root, $category, $b))->toBe(4242) - ->and(entryBytesFor($path, $a))->toBe($run['bytesBefore']['a']) - ->and(entryBytesFor($path, $c))->toBe($run['bytesBefore']['c']) - ->and(ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->isDirty())->toBeFalse(); -})->with(['items', 'weapons', 'armors'])->group('engine'); - -it('composes the restore sequence in one category with structural edits in a sibling', function (string $category, string $sibling) { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $open = identityCategories($root); - $before = identityIds($root); - [$a, $b, $c] = $before[$category]; - $siblingIds = $before[$sibling]; - - // The sibling removes its first record and adds one, and saves in the - // middle of the category's sequence. - $removedSibling = strval($open[$sibling]->removeRecord(0)?->get('id')); - $addedIndex = $open[$sibling]->addRecord(); - $addedSibling = strval($open[$sibling]->getRecordByIndex($addedIndex)?->get('id')); - - $removed = $open[$category]->removeRecord(1); - $open[$category]->save(); - $open[$sibling]->save(); - $open[$category]->insertRecord(1, $removed); - $open[$category]->save(); - $open[$category]->setField(1, 'price', '4242'); - $open[$category]->save(); - - $after = identityIds($root); - - expect($after[$category])->toBe($before[$category]) - ->and($after[$sibling])->toBe([...array_values(array_diff($siblingIds, [$removedSibling])), $addedSibling]) - ->and(priceOnDisk($root, $category, $b))->toBe(4242) - ->and(priceOnDisk($root, $category, $a))->toBe(priceOnDisk($root, $category, $a)) - ->and(entryBytesFor($path, $c))->not->toBeNull(); - - // The third category was never touched. - $third = array_values(array_diff(['items', 'weapons', 'armors'], [$category, $sibling]))[0]; - expect($after[$third])->toBe($before[$third]); -})->with([ - ['items', 'weapons'], - ['items', 'armors'], - ['weapons', 'armors'], - ['weapons', 'items'], - ['armors', 'items'], - ['armors', 'weapons'], -])->group('engine'); - -it('places every kind of change by id in one Save All: edit, add, delete, and restore across siblings', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = identityIds($root); - - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $items = $workspace->getRecordDatabase('items'); - $weapons = $workspace->getRecordDatabase('weapons'); - $armors = $workspace->getRecordDatabase('armors'); - - // Round one: delete the middle item and the first weapon; save all. - $middleItem = $before['items'][1]; - $firstWeapon = $before['weapons'][0]; - $removedItem = $items->removeRecord(1); - $weapons->removeRecord(0); - callEditorMethod($editor, 'saveAllAssets'); - - expect(identityIds($root)['items'])->toBe(array_values(array_diff($before['items'], [$middleItem]))) - ->and(identityIds($root)['weapons'])->toBe(array_values(array_diff($before['weapons'], [$firstWeapon]))); - - // Round two, all at once: restore the item, add an armor, edit a weapon. - $items->insertRecord(1, $removedItem); - $addedIndex = $armors->addRecord(); - $addedArmor = strval($armors->getRecordByIndex($addedIndex)?->get('id')); - $editedWeapon = $before['weapons'][1]; - $weapons->setField(indexOfId($weapons, $editedWeapon), 'price', '5150'); - callEditorMethod($editor, 'saveAllAssets'); - - $after = identityIds($root); - - expect($after['items'])->toBe($before['items']) - ->and($after['weapons'])->toBe(array_values(array_diff($before['weapons'], [$firstWeapon]))) - ->and($after['armors'])->toBe([...$before['armors'], $addedArmor]) - ->and(priceOnDisk($root, 'weapons', $editedWeapon))->toBe(5150) - ->and($workspace->hasUnsavedChanges())->toBeFalse(); -})->group('engine'); - -it('refuses to write two entries sharing one id, and changes nothing', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $open = identityCategories($root); - $items = $open['items']; - $ids = listedIds($items); - - // Two records now declare one id: the second is a copy of the first, - // added as the runtime would refuse to load it. - $index = $items->addRecord(); - $duplicate = new ReflectionProperty($items->getRecordByIndex($index), 'payload'); - $duplicate->setValue($items->getRecordByIndex($index), $items->getRecordByIndex(0)?->toArray()); - - expect(static fn() => $items->save())->toThrow(SourceIdentityConflict::class, $ids[0]); - - // No byte written, still dirty, and the records still list what they - // listed -- the file will be addressable again once the copy is gone. - expect((string) file_get_contents($path))->toBe($before) - ->and($items->isDirty())->toBeTrue() - ->and(listedIds($items))->toBe([...$ids, $ids[0]]); - - $items->removeRecord($index); - $items->save(); - - expect((string) file_get_contents($path))->toBe($before); -})->group('engine'); - -it('refuses to address an entry the file holds twice, and leaves both alone', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $source = (string) file_get_contents($path); - $document = PhpSourceDocument::parse($source); - - // The file, as authored by hand, holds the second item twice. - $second = $document->entrySource(1); - $doubled = substr_replace($source, $second . ",\n " . $second, strpos($source, (string) $second), strlen((string) $second)); - file_put_contents($path, $doubled); - $doubledBytes = (string) file_get_contents($path); - - $items = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items')); - $ids = listedIds($items); - $twice = $ids[1]; - - expect($ids[2])->toBe($twice); - - // Editing one of the two: which entry? Refused, with the id named. - $items->setField(1, 'price', '4242'); - expect(static fn() => $items->save())->toThrow(SourceIdentityConflict::class, $twice); - expect((string) file_get_contents($path))->toBe($doubledBytes); - - // Deleting one of the two: which entry? Refused too. - $items->setField(1, 'price', strval($items->getRecordByIndex(2)?->get('price'))); - $items->removeRecord(1); - expect(static fn() => $items->save())->toThrow(SourceIdentityConflict::class, $twice); - expect((string) file_get_contents($path))->toBe($doubledBytes); - - // A record the file holds once is still edited on its own. - $fresh = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items')); - $fresh->setField(0, 'price', '4242'); - $fresh->save(); - - expect(priceOnDisk($root, 'items', $ids[0]))->toBe(4242) - ->and(substr_count((string) file_get_contents($path), "id: '{$twice}'"))->toBe(2); -})->group('engine'); - -it('refuses to address entries that declare no identity rather than guessing', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $items = RecordSchemaCatalog::forKey('items'); - - // The same category, addressed by a key its entries never declare: no - // entry can be told from another, so no entry may be written to. - $unaddressable = new \Ichiloto\Editor\Database\RecordSchema( - key: $items->key, - entryNoun: $items->entryNoun, - storage: $items->storage, - relativePath: $items->relativePath, - fields: $items->fields, - labelKey: $items->labelKey, - identityKey: 'serial', - recordFilter: $items->recordFilter, - makeBlank: $items->makeBlank, - ); - $database = ProjectRecordDatabase::fromProject($root, $unaddressable); - $database->setField(0, 'price', '4242'); - - expect(static fn() => $database->save())->toThrow(SourceIdentityConflict::class, 'serial'); - expect((string) file_get_contents($path))->toBe($before) - ->and($database->isDirty())->toBeTrue(); -})->group('engine'); - -it('changes no byte and no identity when the grouped write fails', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $open = identityCategories($root); - $ids = identityIds($root); - - $removedItem = $open['items']->removeRecord(1); - $open['weapons']->setField(0, 'price', '4242'); - $open['armors']->addRecord(); - - chmod($root . '/assets/Data', 0o555); - set_error_handler(static fn(): bool => true, E_WARNING); - - try { - expect(static fn() => SharedFileTransaction::commit(array_values($open)))->toThrow(RuntimeException::class); - } finally { - restore_error_handler(); - chmod($root . '/assets/Data', 0o755); - } - - // Nothing written, everything still dirty. - expect((string) file_get_contents($path))->toBe($before) - ->and($open['items']->isDirty())->toBeTrue() - ->and($open['weapons']->isDirty())->toBeTrue() - ->and($open['armors']->isDirty())->toBeTrue(); - - // And the identities the categories address by are exactly as they - // were: putting the item back and saving now finds its entry still in - // the file and writes only what changed since. - $open['items']->insertRecord(1, $removedItem); - SharedFileTransaction::commit(array_values($open)); - - $after = identityIds($root); - - expect($after['items'])->toBe($ids['items']) - ->and(priceOnDisk($root, 'weapons', $ids['weapons'][0]))->toBe(4242) - ->and(count($after['armors']))->toBe(count($ids['armors']) + 1) - ->and(entryBytesFor($path, $ids['items'][1]))->toBe(entryBytesFor($before === '' ? $path : $path, $ids['items'][1])); -})->group('engine'); - -it('writes nothing, and takes one backup per file, when a grouped save has nothing to do', function () { - $project = identityProject(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $open = identityCategories($root); - - // Removed and put back before any save: the file never changed and the - // record is recognised again by its id, so there is nothing to write. - $removed = $open['items']->removeRecord(1); - $open['items']->insertRecord(1, $removed); - $open['weapons']->setField(0, 'price', strval($open['weapons']->getRecordByIndex(0)?->get('price'))); - - $before = (string) file_get_contents($path); - $mtime = filemtime($path); - touch($path, $mtime - 100); - clearstatcache(); - $stamped = filemtime($path); - - expect(SharedFileTransaction::commit(array_values($open)))->toBeFalse(); - clearstatcache(); - - expect((string) file_get_contents($path))->toBe($before) - ->and(filemtime($path))->toBe($stamped); - - // Through Save All, one file, one backup, however many categories. - $config = json_decode((string) file_get_contents($root . '/ichiloto.json'), true); - $config['editor'] = ['backups' => ['enabled' => true, 'retain' => 5]]; - file_put_contents($root . '/ichiloto.json', json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $workspace->getRecordDatabase('items')?->setField(0, 'price', '4242'); - $workspace->getRecordDatabase('weapons')?->setField(0, 'price', '4243'); - $workspace->getRecordDatabase('armors')?->removeRecord(0); - callEditorMethod($editor, 'saveAllAssets'); - - $backups = glob($root . '/' . \Ichiloto\Editor\Backup\BackupSettings::DEFAULT_DIRECTORY . '/assets/Data/items.php.*.bak') ?: []; - - expect($backups)->toHaveCount(1) - ->and((string) file_get_contents($backups[0]))->toBe($before) - ->and(priceOnDisk($root, 'items', identityIds($root)['items'][0]))->toBe(4242) - ->and(priceOnDisk($root, 'weapons', identityIds($root)['weapons'][0]))->toBe(4243); -})->group('engine'); diff --git a/tests/Unit/EditableGridTest.php b/tests/Unit/EditableGridTest.php new file mode 100644 index 00000000..9ff46fe1 --- /dev/null +++ b/tests/Unit/EditableGridTest.php @@ -0,0 +1,103 @@ +000x\n\033[31m00\033[0m0\n0"; + $source = MapGridSource::buildSource($text, 'IDENTITIES'); + $grid = new EditableGrid($text, $source); + expect($grid->cells)->toBe([ + [ + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ['symbol' => 'x', 'prefix' => '', 'suffix' => ''], + ], + [ + ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '0', 'prefix' => "\033[31m", 'suffix' => "\033[0m"], + ['symbol' => '0', 'prefix' => '', 'suffix' => ''], + ], + [['symbol' => '0', 'prefix' => '', 'suffix' => '']], + ])->and($grid->getSource())->toBe($source); +}); + +it('keeps repeated cell values independently editable with exact source and snapshot restoration', function () { + $text = "xx\nxx\nx"; + $source = MapGridSource::buildSource($text, 'CELLS', "// Preserve the authored rows.\n"); + $grid = new EditableGrid($text, $source); + $before = $grid->captureSnapshot(); + $cell = &$grid->cells[0][0]; + $cell['symbol'] = 'y'; + $cell['prefix'] = ''; + unset($cell); + + expect($grid->cells[0][1])->toBe(['symbol' => 'x', 'prefix' => '', 'suffix' => '']) + ->and($grid->cells[1])->toBe($before['cells'][1]) + ->and($grid->cells[2])->toBe($before['cells'][2]) + ->and($before['cells'][0][0]['symbol'])->toBe('x') + ->and($grid->getSource())->toBe(str_replace( + "xx\nxx", + "yx\nxx", + $source, + )); + $edited = $grid->captureSnapshot(); + $restored = EditableGrid::createFromSnapshot($before); + expect($restored->getSource())->toBe($source) + ->and(array_map(count(...), $restored->cells))->toBe([2, 2, 1]); + $redone = EditableGrid::createFromSnapshot($edited); + expect($redone->getSource())->toBe($grid->getSource()); + $redone->cells[1][0]['suffix'] = ''; + expect($grid->cells[1][0]['suffix'])->toBe('') + ->and($redone->cells[1][1]['suffix'])->toBe(''); +}); + +it('holds a multi-map workload of repeated editable cells within the unchanged 128 MiB CLI budget', function () { + $probe = <<<'PHP' + require $argv[1]; + $lines = []; + for ($row = 0; $row < 96; $row++) { + $lines[] = '' . str_repeat('.', 80) . '' . str_repeat(' ', 80 - $row % 2); + } + $text = implode("\n", $lines); + $source = \Ichiloto\Engine\Field\MapGridSource::buildSource($text, 'CELLS'); + $grids = []; + $cellCount = 0; + for ($index = 0; $index < 30; $index++) { + $grid = new \Ichiloto\Editor\Maps\EditableGrid($text, $source); + $cellCount += array_sum(array_map(count(...), $grid->cells)); + $grids[] = $grid; + } + $grids[0]->cells[0][0]['symbol'] = '#'; + echo json_encode([ + 'grids' => count($grids), + 'cells' => $cellCount, + 'independent' => $grids[1]->cells[0][0]['symbol'] === '.' && $grids[0]->cells[0][1]['symbol'] === '.', + 'limit' => ini_get('memory_limit'), + 'peak' => memory_get_peak_usage(true), + ], JSON_THROW_ON_ERROR); + PHP; + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=128M', '-r', $probe, dirname(__DIR__) . '/bootstrap.php'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + expect($exitCode)->toBe(0, $error) + ->and($error)->toBe(''); + $result = json_decode($output, true, flags: JSON_THROW_ON_ERROR); + expect($result['grids'])->toBe(30) + ->and($result['cells'])->toBe(459360) + ->and($result['independent'])->toBeTrue() + ->and($result['limit'])->toBe('128M') + ->and($result['peak'])->toBeLessThan(128 * 1024 * 1024); +}); diff --git a/tests/Unit/EditorSafetyTest.php b/tests/Unit/EditorSafetyTest.php index 8cd36ff3..98f0ba44 100644 --- a/tests/Unit/EditorSafetyTest.php +++ b/tests/Unit/EditorSafetyTest.php @@ -120,19 +120,17 @@ function workspaceEditor(): Editor expect($workspace->getAssetLines(0))->toBe(['Maps', '> test-map *']); }); -it('builds the animation settings fields without a class error', function () { - // Regression: AnimationTargetPosition used to resolve to the missing class - // Ichiloto\Editor\AnimationTargetPosition and fatal at Editor.php:3457. +it('builds the animation rows from the shared schema, an older record showing its own frames read-only', function () { $editor = workspaceEditor(); setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('animations')); $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $labels = array_column($fields, 'label'); - $positionField = $fields[array_search('Position', $labels, true)]; + $rows = array_column($fields, null, 'label'); - expect($labels)->toContain('Name', 'Position', 'Max Frames') - ->and($positionField['options'])->toBe(['center', 'head', 'feet', 'screen']) - ->and($positionField['value'])->toBe('Center'); + expect(array_keys($rows))->toContain('Name', 'Caster Effect', 'Target Effect', 'Roles', 'Legacy Position', 'Legacy Frames') + ->and($rows['Legacy Position']['options'])->toBe(['center', 'head', 'feet', 'screen']) + ->and($rows['Legacy Frames']['editable'])->toBeFalse() + ->and($rows['Target Effect']['reference'])->toBe('effects'); }); it('undoes database field edits against the pinned entry', function () { @@ -140,24 +138,23 @@ function workspaceEditor(): Editor setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('animations')); $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $labels = array_column($fields, 'label'); - $nameField = $fields[array_search('Name', $labels, true)]; + $nameField = $fields[array_search('Name', array_column($fields, 'label'), true)]; callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $nameField, 'Mega Slash'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $animation = $workspace->animationDatabase->getAnimationByIndex(0); + $animation = $workspace->getRecordDatabase('animations')->getRecordByIndex(0); - expect($animation->name)->toBe('Mega Slash'); + expect($animation->get('name'))->toBe('Mega Slash'); // Move the selection elsewhere: undo must still hit animation 0. - setEditorProperty($editor, 'databaseSelectedAnimationIndex', 5); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['animations' => 5]); /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); $history->undo(); - expect($animation->name)->toBe('Slash') - ->and(getEditorProperty($editor, 'databaseSelectedAnimationIndex'))->toBe(5); + expect($animation->get('name'))->toBe('Slash') + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes'))->toBe(['animations' => 5]); }); diff --git a/tests/Unit/EditorUxTest.php b/tests/Unit/EditorUxTest.php index 5fa7b6b9..b4994b44 100644 --- a/tests/Unit/EditorUxTest.php +++ b/tests/Unit/EditorUxTest.php @@ -232,7 +232,7 @@ function uxEditor(): Editor it('steps integer inspector fields with the Left/Right idiom and records undo', function () { $editor = uxEditor(); setEditorProperty($editor, 'focusedPane', 'inspector'); - setEditorProperty($editor, 'selectedInspectorFieldIndex', 4); // Size X (width) + setEditorProperty($editor, 'selectedInspectorFieldIndex', 5); // Size X (width) /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); @@ -249,7 +249,7 @@ function uxEditor(): Editor it('builds boolean and float controls for event data values', function () { $editor = uxEditor(); - $fields = callEditorMethod($editor, 'flattenInspectorFields', ['locked' => true, 'rate' => 1.5], ['data']); + $fields = callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('A', ['data' => ['locked' => true, 'rate' => 1.5]]); $byLabel = array_column($fields, null, 'label'); expect($byLabel['Locked']['control']->type)->toBe(InputControlType::BOOLEAN) @@ -309,7 +309,7 @@ function uxEditor(): Editor $selectedHeight = $layout->spans[$last][1]; expect(count($layout->lines))->toBeGreaterThan($visibleRows) - ->and($layout->spans[2][1])->toBeGreaterThan(5) + ->and($layout->spans[3][1])->toBeGreaterThan(5) ->and($layout->rowOfField($last) + $selectedHeight - 1)->toBe($visibleRows - 1) ->and($layout->offset)->toBe($layout->spans[$last][0] + $selectedHeight - $visibleRows) ->and(callEditorMethod($editor, 'getInspectorLines')[0])->not->toContain('Name: Test Map'); diff --git a/tests/Unit/ElementalAuthoringTest.php b/tests/Unit/ElementalAuthoringTest.php index c4844f9b..2fbec37d 100644 --- a/tests/Unit/ElementalAuthoringTest.php +++ b/tests/Unit/ElementalAuthoringTest.php @@ -4,6 +4,7 @@ use Ichiloto\Editor\Database\AffinityEditor; use Ichiloto\Editor\Database\ElementAffinityCodec; +use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Editor\Database\ReferenceCatalog; @@ -11,27 +12,21 @@ use Ichiloto\Engine\Entities\Inventory\Armor; /** - * Writes an element enum into a project the way a real one declares it. + * Declares a project's elements the way a real one does, in system.php. * * @param string $root The project root. * @return void */ -function writeElementTypes(string $root): void +function writeProjectElements(string $root): void { - if (! is_dir($root . '/assets/Data/Types')) { - mkdir($root . '/assets/Data/Types', 0o777, true); - } - - // Overwrites any fixture enum so the expected cases are exactly these. - file_put_contents($root . '/assets/Data/Types/ElementType.php', <<<'PHP' + // The list system.php declares, which the game's element registry reads. + file_put_contents($root . '/assets/Data/system.php', <<<'PHP' 'Elements Test', + 'elements' => ['Fire', 'Ice', 'Water'], + ]; PHP); } @@ -55,7 +50,7 @@ enum ElementType: string it('reads the elements a project declares', function () { $root = makeTemporaryProject(); - writeElementTypes($root); + writeProjectElements($root); $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject($root)); @@ -88,9 +83,9 @@ enum ElementType: string expect($editor->encoded())->toBe('Fire: -1'); }); -it('authors an armor ward end to end, into the engine object', function () { +it('authors an armor ward end to end, into the definition the game builds', function () { $root = makeTemporaryProject(); - writeElementTypes($root); + writeProjectElements($root); $database = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('armors')); $index = $database->addRecord(); @@ -100,29 +95,30 @@ enum ElementType: string $reloaded = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('armors')); $labels = $reloaded->getEntryLabels(); - $armor = $reloaded->getRecords()[array_search('Flame Ward', $labels, true)]->toArray(); + $armor = InventoryCatalog::readDefinition($reloaded->getRecords()[array_search('Flame Ward', $labels, true)]); expect($armor)->toBeInstanceOf(Armor::class) ->and($armor->elementAffinities)->toBe(['Fire' => 0.5, 'Ice' => 2.0]) - // The engine's own lookup, on the object the editor wrote. + // The engine's own lookup, on the definition the editor wrote. ->and($armor->getElementMultiplier('Fire'))->toBe(0.5); }); it('gives a weapon an attack element and takes it away again', function () { $root = makeTemporaryProject(); - writeElementTypes($root); + writeProjectElements($root); $database = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons')); $index = $database->addRecord(); $database->setField($index, 'element', 'Fire'); - expect($database->getRecords()[$index]->toArray()->element)->toBe('Fire'); + expect(InventoryCatalog::readDefinition($database->getRecords()[$index])?->element)->toBe('Fire'); // '(None)' is what the picker's clearing entry commits: an optional // reference must have a way back to nothing. $database->setField($index, 'element', '(None)'); - expect($database->getRecords()[$index]->toArray()->element)->toBeNull(); + expect($database->getRecords()[$index]->get('element'))->toBeNull() + ->and(InventoryCatalog::readDefinition($database->getRecords()[$index]))->not->toBeNull(); }); it('declares the element fields as picked, cycled, or row-built', function () { @@ -143,3 +139,27 @@ enum ElementType: string ->and($fields['enemies']['elementAffinities']->isReadOnly)->toBeFalse() ->and($fields['enemies']['elementAffinities']->codec->value)->toBe('affinities'); }); + +it('authors elements in Types, offering them before they are saved, the Engine\'s own when none are listed', function () { + $root = makeTemporaryProject(); + writeProjectElements($root); + $workspace = ProjectWorkspace::fromProject($root); + $types = $workspace->getRecordDatabase('types'); + $authoring = new Ichiloto\Editor\Database\RecordAuthoring(); + + $added = $authoring->addItem($types, 0, [], 'elementList'); + $types->setField(0, ProjectRecordDatabase::subFieldId('element', (int) $added->index, 'name'), 'Shadow'); + + expect($types->getEntryLabels())->toBe(['Elements']) + ->and(new ReferenceCatalog($workspace)->valuesFor('elements'))->toBe(['Shadow', 'Fire', 'Ice', 'Water']); + + $types->save(); + expect((require $root . '/assets/Data/system.php')['elements'])->toBe(['Shadow', 'Fire', 'Ice', 'Water']); + + foreach ([3, 2, 1, 0] as $index) { + $authoring->removeItem($types, 0, [], ProjectRecordDatabase::subFieldId('element', $index, 'name')); + } + + expect(new ReferenceCatalog($workspace)->valuesFor('elements')) + ->toBe(array_map(static fn($element): string => $element->value, Ichiloto\Engine\Entities\Enumerations\ElementType::cases())); +}); diff --git a/tests/Unit/EncounterAreasTest.php b/tests/Unit/EncounterAreasTest.php new file mode 100644 index 00000000..8502ebf8 --- /dev/null +++ b/tests/Unit/EncounterAreasTest.php @@ -0,0 +1,94 @@ + [['id' => 'yard', 'area' => ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]]], + 'encounters' => [ + 'troops' => [ + 'Yard Rats' => ['weight' => 2, 'areas' => ['yard']], + 'Wanderer' => 1, + ], + 'rate' => 12, + 'tiles' => 'any', + ], +PHP); +} + +it('reads, writes and renames the areas a troop is met in', function () { + $encounters = MapEncounters::of(['troops' => ['Rats' => ['weight' => 2, 'areas' => ['yard'], 'note' => 'kept'], 'Bats' => 3]]); + + expect($encounters->isSupported())->toBeTrue() + ->and(array_column($encounters->rows(), 'areas'))->toBe([['yard'], []]) + ->and($encounters->troopsInArea('yard'))->toBe(['Rats']) + // A weight alone becomes a map only when it gains areas; clearing them keeps the rest. + ->and($encounters->withAreasAt(1, ['roost', 'roost'])['troops']['Bats'])->toBe(['weight' => 3, 'areas' => ['roost']]) + ->and($encounters->withAreasAt(0, [])['troops']['Rats'])->toBe(['weight' => 2, 'note' => 'kept']) + ->and($encounters->withAreaRenamed('yard', 'court')['troops']['Rats'])->toBe(['weight' => 2, 'areas' => ['court'], 'note' => 'kept']); +}); + +it('refuses areas it cannot hold, saying which troop', function (mixed $areas) { + $encounters = MapEncounters::of(['troops' => ['Rats' => ['weight' => 2, 'areas' => $areas]]]); + + expect($encounters->isSupported())->toBeFalse() + ->and($encounters->unsupportedReason())->toContain('the troop "Rats" has areas'); +})->with(['text' => ['yard'], 'keyed' => [['a' => 'yard']], 'numbers' => [[1]]]); + +it('edits a troop\'s areas through the inspector, accepting only the map\'s areas', function () { + [, $file, $session] = createEncounterAreaProject(); + $areas = static fn(int $index): array => findLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'areas', 'index' => $index]); + + expect($areas(0)['value'])->toBe('yard')->and($areas(1)['value'])->toBe('(whole map)'); + + applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'areas', 'index' => 1], 'yard'); + expect(fn() => applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'areas', 'index' => 0], 'yard, cellar')) + ->toThrow(SessionRefusal::class, 'cellar names no area of this map; its areas are yard.'); + applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'areas', 'index' => 0], ''); + $session->saveMap('test-map'); + + expect((require $file)['encounters']['troops'])->toBe(['Yard Rats' => ['weight' => 2], 'Wanderer' => ['weight' => 1, 'areas' => ['yard']]]); +}); + +it('renames an area in the troops met there and refuses to delete it while they are', function () { + [, $file, $session] = createEncounterAreaProject(); + $revision = static fn(): int => $session->readMap('test-map')['revision']; + + expect($session->readAreas('test-map')['areas'][0]['references'])->toBe(['test-map encounter Yard Rats']); + + $session->renameArea('test-map', $revision(), 'yard', 'court'); + expect(fn() => $session->deleteArea('test-map', $revision(), 'court'))->toThrow(SessionRefusal::class, 'test-map encounter Yard Rats'); + $session->saveMap('test-map'); + + expect((require $file)['encounters']['troops']['Yard Rats'])->toBe(['weight' => 2, 'areas' => ['court']]); + + // One undo step restores both the area and the troop's scope. + $session->undo(); + expect(getSessionMap($session)->getMapDataField(['encounters', 'troops', 'Yard Rats', 'areas']))->toBe(['yard']); +}); + +it('reports a troop limited to an area the map does not declare, since the engine never meets it', function () { + [$root] = createEncounterAreaProject(); + editTestMapData($root, static fn(string $source): string => str_replace("'areas' => ['yard']]", "'areas' => ['yard', 'cellar']]", $source)); + + $errors = array_map(static fn($issue): string => $issue->message, array_filter( + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn($issue): bool => $issue->severity === Severity::ERROR && $issue->where === 'test-map')); + + expect($errors)->toContain('The troop "Yard Rats" is limited to the area "cellar", which this map does not declare, so it is never met.') + ->and(implode("\n", $errors))->not->toContain('"yard"'); +}); diff --git a/tests/Unit/EncounterArenaTest.php b/tests/Unit/EncounterArenaTest.php new file mode 100644 index 00000000..5fa2d251 --- /dev/null +++ b/tests/Unit/EncounterArenaTest.php @@ -0,0 +1,182 @@ + str_replace(" 'triggers' => [],", <<<'PHP' + 'triggers' => [], + 'encounters' => [ + 'troops' => [ + // The lake's own fight. + 'Pair' => ['weight' => 2, 'battleArena' => 'arena.road', 'note' => 'kept'], + ], + 'rate' => 20, + ], +PHP, $source)); + + return $root; +} + +/** The inspector row whose key names a field (and, for a troop row, its index). */ +function encounterRow(array $read, string $field): array +{ + return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException("No inspector row {$field}."); +} + +it('reads structured entries and keeps what they carry through every edit', function () { + $encounters = MapEncounters::of([ + 'troops' => [ + 'Rats' => 5, + 'Loch Ness' => ['weight' => 1, 'battleArena' => 'arena.lake', 'note' => 'kept'], + ], + 'battleArena' => 'arena.ruins', + ]); + + expect($encounters->isSupported())->toBeTrue() + ->and($encounters->mapArena())->toBe('arena.ruins') + ->and(array_column($encounters->rows(), 'arena'))->toBe([null, 'arena.lake']) + ->and(array_column($encounters->rows(), 'weight'))->toBe([5, 1]) + // A rename and a weight keep the entry a map, with its other keys, in place. + ->and($encounters->withTroopAt(1, 'Nessie')['troops']) + ->toBe(['Rats' => 5, 'Nessie' => ['weight' => 1, 'battleArena' => 'arena.lake', 'note' => 'kept']]) + ->and($encounters->withWeightAt(1, 3)['troops']['Loch Ness'])->toBe(['weight' => 3, 'battleArena' => 'arena.lake', 'note' => 'kept']) + // A weight alone gains a map only with an arena; an arena cleared keeps the rest. + ->and($encounters->withArenaAt(0, 'arena.cave')['troops']['Rats'])->toBe(['weight' => 5, 'battleArena' => 'arena.cave']) + ->and($encounters->withArenaAt(1, null)['troops']['Loch Ness'])->toBe(['weight' => 1, 'note' => 'kept']) + ->and($encounters->withMapArena(null))->not->toHaveKey('battleArena') + ->and($encounters->withMapArena('arena.cave')['battleArena'])->toBe('arena.cave') + // The last row takes the map's arena with it, as it takes the rate. + ->and(MapEncounters::of(['troops' => ['Rats' => 5], 'battleArena' => 'arena.ruins'])->withTroopRemovedAt(0))->toBeNull(); +}); + +it('refuses an arena it cannot hold, saying where', function (array $block, string $reason) { + $encounters = MapEncounters::of($block); + + expect($encounters->isSupported())->toBeFalse() + ->and($encounters->unsupportedReason())->toContain($reason); +})->with([ + 'an entry\'s arena' => [['troops' => ['Rats' => ['weight' => 5, 'battleArena' => 7]]], 'the troop "Rats" has a battleArena that is int'], + 'the map\'s arena' => [['troops' => ['Rats' => 5], 'battleArena' => ['x']], 'the map\'s battleArena is array'], + 'an entry\'s weight' => [['troops' => ['Rats' => ['weight' => [5]]]], 'the troop "Rats" has a weight that is array'], + 'an entry\'s null arena' => [['troops' => ['Rats' => ['weight' => 5, 'battleArena' => null]]], 'the troop "Rats" has a battleArena that is null'], + 'the map\'s null arena' => [['troops' => ['Rats' => 5], 'battleArena' => null], 'the map\'s battleArena is null'], +]); + +it('offers the arenas in a graphical editor, writing only what changed', function () { + $root = encounterArenaProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $session = EditorSession::open($root); + $read = $session->readInspector('test-map'); + + expect(encounterRow($read, 'arena'))->toMatchArray(['kind' => 'reference', 'reference' => 'battle_arenas', 'noneLabel' => '(map arena)', 'value' => 'Road (arena.road)']) + ->and(encounterRow($read, 'mapArena'))->toMatchArray(['kind' => 'reference', 'noneLabel' => '(default arena)', 'value' => '(default arena)']) + ->and($session->listReferences('test-map', 'battle_arenas'))->toBe([['value' => 'arena.road', 'label' => 'Road'], ['value' => 'arena.cave', 'label' => 'Cave']]); + + // A weight edit changes the weight and nothing else of the entry. + $before = (string) file_get_contents($path); + $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'weight')['key'], '4'); + $session->saveMap('test-map'); + + expect((string) file_get_contents($path))->toBe(str_replace("['weight' => 2,", "['weight' => 4,", $before)); + + // The map's arena, then the entry's cleared to the map's. + $read = $session->readInspector('test-map'); + $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'mapArena')['key'], 'arena.cave'); + $read = $session->readInspector('test-map'); + $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'arena')['key'], ''); + $session->saveMap('test-map'); + $saved = (require $path)['encounters']; + + expect($saved['battleArena'])->toBe('arena.cave') + ->and($saved['troops'])->toBe(['Pair' => ['weight' => 4, 'note' => 'kept']]) + ->and((string) file_get_contents($path))->toContain('// The lake\'s own fight.'); + + // An arena the battle presentation does not declare is refused. + $read = $session->readInspector('test-map'); + expect(fn() => $session->applyInspector('test-map', $read['revision'], encounterRow($read, 'arena')['key'], 'arena.moon')) + ->toThrow(SessionRefusal::class, 'arena.moon'); +}); + +it('keeps arenas in the terminal editor without offering them', function () { + $root = encounterArenaProject(); + $workspace = ProjectWorkspace::fromProject($root); + $map = $workspace->maps[0]; + $terminal = new MapInspector(new ReferenceCatalog($workspace, $map)); + + $fields = $terminal->getMapFields($map); + + expect(array_filter($fields, static fn(array $field): bool => in_array($field['field'] ?? null, ['arena', 'mapArena'], true)))->toBe([]); + + $weight = array_find($fields, static fn(array $field): bool => ($field['field'] ?? null) === 'weight'); + $terminal->apply($map, $weight, '6'); + + expect($map->getMapDataField(['encounters', 'troops', 'Pair']))->toBe(['weight' => 6, 'battleArena' => 'arena.road', 'note' => 'kept']); +}); + +it('reports an arena a battle names that the battle presentation does not declare', function () { + $root = encounterArenaProject(); + editTestMapData($root, static fn(string $source): string => str_replace("'rate' => 20,", "'rate' => 20,\n 'battleArena' => 'arena.moon',", $source)); + $errors = static fn(string $root): string => implode("\n", array_map( + static fn($issue): string => $issue->where . ': ' . $issue->message, + array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), static fn($issue): bool => $issue->severity === Severity::ERROR), + )); + + expect($errors($root))->toContain('test-map: The encounters names the arena "arena.moon", which the battle presentation does not declare.') + ->not->toContain('"arena.road"'); + + // Without a battle presentation, no arena can be drawn. + unlink($root . '/assets/Data/Presentation/battle.php'); + + expect($errors($root))->toContain('The encounter with "Pair" names the arena "arena.road", but the project declares no battle presentation to draw it in.'); +}); + +it('warns when an encounter fights an enemy on an arena with nowhere for its habitat to stand', function () { + $root = encounterArenaProject(); + writeRegularBatEnemy($root); + $bat = $root . '/assets/Data/Enemies/regular-bat.php'; + $setHabitat = static fn(string $habitat) => file_put_contents($bat, + (string) preg_replace("/'habitat' => '\\w+',\\n|(?='imagePath')/", "'habitat' => '{$habitat}',\n ", (string) file_get_contents($bat), 1)); + $warnings = static fn(): string => implode("\n", array_map( + static fn($issue): string => $issue->where . ': ' . $issue->message, + array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), static fn($issue): bool => $issue->severity === Severity::WARNING), + )); + $habitatWarning = 'The encounter with "Pair" is fought on the arena "arena.road", which has no %s for Regular Bat to stand on.'; + + // An arena marking no footing is land throughout: a land or air enemy stands there, a water creature cannot. + expect($warnings())->not->toContain('to stand on'); + $setHabitat('water'); + expect($warnings())->toContain(sprintf($habitatWarning, 'water')); + + // Marking the road's water gives it somewhere, and takes its land away. + $battle = $root . '/assets/Data/Presentation/battle.php'; + file_put_contents($battle, str_replace("'arena.road' => \$arena('Road', 'Road.png')", + "'arena.road' => new BattleArenaDefinition('Road', \$arena('Road', 'Road.png')->background," + . " footing: new \\Ichiloto\\Engine\\Battle\\Presentation\\BattleFooting(water: [[0, 300], [600, 300], [600, 600]]))", + (string) file_get_contents($battle))); + expect($warnings())->not->toContain('to stand on'); + $setHabitat('land'); + expect($warnings())->toContain(sprintf($habitatWarning, 'land')); + $setHabitat('air'); + expect($warnings())->not->toContain('to stand on'); +}); diff --git a/tests/Unit/EncounterTerrainArenaTest.php b/tests/Unit/EncounterTerrainArenaTest.php new file mode 100644 index 00000000..135970e2 --- /dev/null +++ b/tests/Unit/EncounterTerrainArenaTest.php @@ -0,0 +1,181 @@ + ['Wolves' => 1], 'rate' => 12, 'battleArena' => 'arena.region', ...$encounters]; + file_put_contents($file, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'terrainKinds' => true, 'areas' => " + . var_export([['id' => 'glade', 'area' => ['x' => 2, 'y' => 0, 'width' => 2, 'height' => 2]]], true) + . ", 'encounters' => " . var_export($block, true) . ',', (string) file_get_contents($file))); + @mkdir($root . '/assets/Data/Presentation', 0o777, true); + file_put_contents($root . '/assets/Data/Presentation/battle.php', <<<'PHP' + [ + 'arena.region' => ['name' => 'Region', 'background' => ['image' => 'Graphics/Battlebacks/Region.png'], + 'variants' => ['grove' => 'arena.woods']], + 'arena.woods' => ['name' => 'Woods', 'background' => ['image' => 'Graphics/Battlebacks/Woods.png']], + 'arena.glade' => ['name' => 'Glade', 'background' => ['image' => 'Graphics/Battlebacks/Glade.png']], + ], +]; +PHP); + foreach (['Region', 'Woods', 'Glade'] as $image) { + writeTilesetTestPng($root . "/assets/Graphics/Battlebacks/{$image}.png", 8, 8); + } + + return [$root, EditorSession::open($root)]; +} + +/** @return list The validation messages of a severity for the test map. */ +function terrainArenaIssues(EditorSession $session, string $severity): array +{ + return array_values(array_column(array_filter($session->validateProject(['test-map'])['issues'], + static fn(array $issue): bool => $issue['severity'] === $severity), 'message')); +} + +it('reads and writes a map\'s arena rules, keeping what each carries and following an area\'s rename', function () { + $encounters = MapEncounters::of(['troops' => ['Wolves' => 1], 'battleArenas' => [ + ['area' => 'glade', 'battleArena' => 'arena.glade', 'note' => 'kept'], + ['terrain' => 'grove', 'battleArena' => 'arena.woods'], + ]]); + + expect($encounters->arenaRules())->toHaveCount(2) + ->and($encounters->arenaRulesInArea('glade'))->toBe([0]) + ->and($encounters->withArenaRuleAt(0, 'terrain', 'meadow')['battleArenas'][0]) + ->toBe(['area' => 'glade', 'battleArena' => 'arena.glade', 'note' => 'kept', 'terrain' => 'meadow']) + ->and($encounters->withAreaRenamed('glade', 'clearing')['battleArenas'][0]['area'])->toBe('clearing') + ->and($encounters->withArenaRuleAdded(null, 'meadow', 'arena.region', 0)['battleArenas'][1]) + ->toBe(['terrain' => 'meadow', 'battleArena' => 'arena.region']) + ->and(MapEncounters::of(['troops' => ['Wolves' => 1], 'battleArenas' => [['battleArena' => 'arena.glade']]]) + ->withArenaRuleRemovedAt(0))->not->toHaveKey('battleArenas') + ->and(MapEncounters::of(['troops' => ['Wolves' => 1], 'battleArenas' => ['terrain' => 'grove']])->unsupportedReason()) + ->toContain('battleArenas are not a list'); +}); + +it('authors arena rules in the graphical inspector from the map\'s own areas and terrains', function () { + [$root, $session] = createTerrainArenaProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $heading = findLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'arenaRules']); + $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']); + + expect(findLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'ruleTerrain', 'index' => 0])) + ->toMatchArray(['value' => 'meadow', 'options' => ['(any terrain)', 'meadow', 'grove']]) + ->and(findLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'ruleArea', 'index' => 0])) + ->toMatchArray(['value' => '(whole map)', 'options' => ['(whole map)', 'glade']]); + + applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'ruleArea', 'index' => 0], 'glade'); + applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'ruleTerrain', 'index' => 0], '(any terrain)'); + applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'ruleArena', 'index' => 0], 'arena.glade'); + + expect(fn() => applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'ruleArea', 'index' => 0], '(whole map)')) + ->toThrow(SessionRefusal::class, 'an area, a terrain or both') + ->and(fn() => applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'ruleTerrain', 'index' => 0], 'swamp')) + ->toThrow(SessionRefusal::class, 'swamp'); + $session->saveMap('test-map'); + + expect((require $file)['encounters']['battleArenas'])->toEqual([['area' => 'glade', 'battleArena' => 'arena.glade']]) + ->and(fn() => new MapAreaAuthoring(ProjectWorkspace::fromProject($root)->maps[0])->deleteArea('glade')) + ->toThrow(\Ichiloto\Editor\Field\MapAreaRefusal::class, 'test-map battle arena rule 1'); +}); + +it('keeps arena rules in the terminal editor without offering them', function () { + [$root] = createTerrainArenaProject(['battleArenas' => [['terrain' => 'grove', 'battleArena' => 'arena.woods']]]); + $workspace = ProjectWorkspace::fromProject($root); + $map = $workspace->maps[0]; + $terminal = new MapInspector(new ReferenceCatalog($workspace, $map)); + $fields = $terminal->getMapFields($map); + + expect(array_filter($fields, static fn(array $field): bool => str_starts_with((string) ($field['field'] ?? ''), 'rule')))->toBe([]); + $terminal->apply($map, array_find($fields, static fn(array $field): bool => ($field['field'] ?? null) === 'weight'), '3'); + + expect($map->getMapDataField(['encounters', 'battleArenas']))->toBe([['terrain' => 'grove', 'battleArena' => 'arena.woods']]); +}); + +it('authors an arena\'s terrain variants from the project\'s encounter terrains and arenas', function () { + [$root, $session] = createTerrainArenaProject(); + $path = $root . '/assets/Data/Presentation/arenas.php'; + $row = static fn(string $field): array => array_find($session->readDatabaseRecord(BattleArenaFields::CATEGORY, 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) ?? throw new RuntimeException("No row {$field}."); + + expect($session->listReferences('test-map', ReferenceCatalog::ENCOUNTER_TERRAINS)) + ->toBe([['value' => 'meadow', 'label' => 'meadow'], ['value' => 'grove', 'label' => 'grove']]) + ->and($row('terrainVariant0Terrain')['value'])->toBe('grove'); + + $session->addDatabaseItem(BattleArenaFields::CATEGORY, 0, $row('terrainVariant0Terrain')['key']); + $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, $row('terrainVariant1Terrain')['key'], 'meadow'); + $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, $row('terrainVariant1Arena')['key'], 'arena.region'); + $session->saveDatabase(BattleArenaFields::CATEGORY); + + expect((require $path)['arenas']['arena.region']['variants'])->toBe(['grove' => 'arena.woods', 'meadow' => 'arena.region']) + ->and((string) file_get_contents($path))->toContain('// Each region and what it draws on each terrain.'); + + $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, $row('terrainVariant1Arena')['key'], 'arena.gone'); + expect(fn() => $session->saveDatabase(BattleArenaFields::CATEGORY))->toThrow(SessionRefusal::class); +}); + +it('lists every arena and terrain a map\'s battles meet without a variant, and stops once art covers it', function () { + [$root] = createTerrainArenaProject(['battleArenas' => [['area' => 'glade', 'battleArena' => 'arena.glade']]]); + $warnings = static fn(): string => implode("\n", terrainArenaIssues(EditorSession::open($root), 'warning')); + + // The fixture's buildings wall off two cells; the region draws its grove on the woods. + expect($warnings())->toContain('Battles on the arena "arena.region" start on meadow (map, 1 cell)') + ->toContain('Battles on the arena "arena.glade" start on meadow (area glade, 2 cells)') + ->not->toContain('start on grove'); + + // The region's own picture is its meadow, so it names itself. + $path = $root . '/assets/Data/Presentation/arenas.php'; + file_put_contents($path, str_replace("'variants' => ['grove' => 'arena.woods']", "'variants' => ['grove' => 'arena.woods', 'meadow' => 'arena.region']", + (string) file_get_contents($path))); + + expect($warnings())->not->toContain('"arena.region" start on') + ->toContain('Battles on the arena "arena.glade" start on meadow'); +}); + +it('reports arena rules the Engine leaves out', function () { + [, $session] = createTerrainArenaProject(['battleArenas' => [ + ['area' => 'nowhere', 'battleArena' => 'arena.glade'], + ['terrain' => 'grove', 'battleArena' => 'arena.moon'], + ]]); + $errors = implode("\n", terrainArenaIssues($session, 'error')); + + expect($errors)->toContain('Battle arena rule 1 names map area "nowhere", which the map does not declare') + ->toContain('Battle arena rule 2 names the arena "arena.moon", which the battle presentation does not declare.'); +}); diff --git a/tests/Unit/EncounterTerrainsTest.php b/tests/Unit/EncounterTerrainsTest.php new file mode 100644 index 00000000..5edac9b0 --- /dev/null +++ b/tests/Unit/EncounterTerrainsTest.php @@ -0,0 +1,58 @@ + ['weight' => 2, 'terrains' => ['grove']], 'Wolves' => 1]): array +{ + $root = terrainKindProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($file, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'terrainKinds' => true, 'encounters' => " + . var_export(['troops' => $troops, 'rate' => 12], true) . ',', (string) file_get_contents($file))); + + return [$file, EditorSession::open($root)]; +} + +it('reads and writes the terrains a troop is met on, keeping the rest of its entry', function () { + $encounters = MapEncounters::of(['troops' => ['Sprites' => ['weight' => 2, 'terrains' => ['grove'], 'note' => 'kept'], 'Wolves' => 3]]); + + expect(array_column($encounters->rows(), 'terrains'))->toBe([['grove'], []]) + ->and($encounters->withTerrainsAt(1, ['meadow', 'meadow'])['troops']['Wolves'])->toBe(['weight' => 3, 'terrains' => ['meadow']]) + ->and($encounters->withTerrainsAt(0, [])['troops']['Sprites'])->toBe(['weight' => 2, 'note' => 'kept']) + ->and(MapEncounters::of(['troops' => ['Sprites' => ['weight' => 2, 'terrains' => 'grove']]])->unsupportedReason()) + ->toContain('the troop "Sprites" has terrains'); +}); + +it('edits a troop\'s terrains through the inspector, accepting only those the map\'s kinds meet', function () { + [$file, $session] = createEncounterTerrainProject(); + $terrains = static fn(int $index): array => findLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'terrains', 'index' => $index]); + + expect($terrains(0)['value'])->toBe('grove')->and($terrains(1)['value'])->toBe('(any terrain)'); + + applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'terrains', 'index' => 1], 'meadow'); + expect(fn() => applyLandmarkRow($session, ['target' => 'map-encounters', 'field' => 'terrains', 'index' => 0], 'grove, swamp')) + ->toThrow(SessionRefusal::class, "swamp names no encounter terrain of this map's kinds; they are meadow, grove."); + $session->saveMap('test-map'); + + expect((require $file)['encounters']['troops'])->toBe(['Sprites' => ['weight' => 2, 'terrains' => ['grove']], + 'Wolves' => ['weight' => 1, 'terrains' => ['meadow']]]); +}); + +it('reports a troop limited to a terrain no kind of the map meets, which is never met', function () { + [, $session] = createEncounterTerrainProject(['Ghosts' => ['weight' => 1, 'terrains' => ['swamp']]]); + + $messages = array_column(array_filter($session->validateProject(['test-map'])['issues'], + static fn(array $issue): bool => $issue['severity'] === 'error'), 'message'); + + expect(implode("\n", $messages))->toContain('The troop "Ghosts" is limited to the encounter terrain "swamp", which no terrain kind of this map meets'); +}); diff --git a/tests/Unit/EnemyRecordFilesTest.php b/tests/Unit/EnemyRecordFilesTest.php new file mode 100644 index 00000000..7425bb01 --- /dev/null +++ b/tests/Unit/EnemyRecordFilesTest.php @@ -0,0 +1,211 @@ + $records File contents keyed by file name. + */ +function enemyRecordProject(array $records = []): string +{ + $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-enemy-records-' . bin2hex(random_bytes(4))); + mkdir($root . '/assets/Data/Enemies', 0777, true); + mkdir($root . '/assets/Graphics/Enemies', 0777, true); + file_put_contents($root . '/assets/Graphics/Enemies/blob.txt', "(o)\n"); + file_put_contents($root . '/assets/Graphics/Enemies/wisp.txt', "~*~\n"); + writeSkillRecords($root, new Ichiloto\Engine\Entities\Skills\BasicSkill('Nip', 'A small bite.', '', 0, 0), new Ichiloto\Engine\Entities\Skills\BasicSkill('Brace', 'Holds firm.', '', 0, 0)); + + foreach ($records as $file => $contents) { + file_put_contents($root . '/assets/Data/Enemies/' . $file, $contents); + } + + return $root; +} + +/** A record file as the conversion and an author write it: the class named through its import. */ +function blobRecordFile(): string +{ + return <<<'PHP' + Enemy::class, + 'data' => [ + 'name' => 'Blob', + 'level' => 2, + 'imagePath' => 'blob', + 'stats' => [ + 'maxHp' => 40, + 'maxMp' => 0, + 'attack' => 9, + 'defence' => 4, + 'magicAttack' => 3, + 'magicDefence' => 5, + 'speed' => 7, + 'grace' => 2, + 'evasion' => 1, + ], + 'rewards' => [ + 'experience' => 12, + 'gold' => 30, + ], + 'actionPatterns' => [ + [ + 'skill' => 'Nip', + 'rating' => 5, + ], + ], + 'elementAffinities' => [ + 'Fire' => 2.0, + ], + ], + ]; + + PHP; +} + +/** Reads the project's enemies the way the game does, from its own directory. */ +function loadEnemyCatalog(string $root): EnemyCatalog +{ + $previous = (string) getcwd(); + chdir($root); + + try { + return EnemyCatalog::load($root . '/assets'); + } finally { + chdir($previous); + } +} + +function enemiesDatabase(string $root): ProjectRecordDatabase +{ + return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('enemies')); +} + +it('lists each enemy record file as an editable record of its data', function () { + $database = enemiesDatabase(enemyRecordProject(['blob.php' => blobRecordFile()])); + $record = $database->getRecords()[0]; + + expect($database->getEntryLabels())->toBe(['Blob']) + ->and($database->isEditable())->toBeTrue() + ->and($record->isEditable())->toBeTrue() + ->and($record->get('stats.maxHp'))->toBe(40) + ->and($record->get('actionPatterns'))->toBe([['skill' => 'Nip', 'rating' => 5]]); +}); + +it('edits a value in its own source, keeping the envelope, import and comment', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $database->setField(0, 'stats.maxHp', '55'); + $database->setField(0, 'stats.speed', '9'); + $database->save(); + + expect(file_get_contents($root . '/assets/Data/Enemies/blob.php'))->toBe(str_replace( + ["'maxHp' => 40,", "'speed' => 7,"], + ["'maxHp' => 55,", "'speed' => 9,"], + blobRecordFile(), + )) + ->and(loadEnemyCatalog($root)->findEnemy('Blob')?->stats->totalHp)->toBe(55); +}); + +it('authors action patterns by naming catalogue skills and conditions as data', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $entry = $database->addSubItem(0); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'skill'), 'Brace'); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'rating'), '7'); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'condition.type'), 'HP'); + $database->setField(0, ProjectRecordDatabase::subFieldId('pattern', $entry, 'condition.range'), '0, 50'); + $database->save(); + + $pattern = loadEnemyCatalog($root)->findEnemy('Blob')?->actionPatterns[1]; + + expect($pattern?->skill->name)->toBe('Brace') + ->and($pattern?->rating)->toBe(7) + ->and($pattern?->condition->type)->toBe(ActionConditionType::HP) + ->and([$pattern?->condition->range->min, $pattern?->condition->range->max])->toBe([0, 50]); +}); + +it('creates a new enemy in a file of its own that the game loads', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $index = $database->addRecord(); + $database->save(); + $catalog = loadEnemyCatalog($root); + + expect($index)->toBe(1) + ->and(is_file($root . '/assets/Data/Enemies/new-enemy.php'))->toBeTrue() + ->and($catalog->getProblems())->toBe([]) + ->and($catalog->getSourceFile('New Enemy'))->toBe('Enemies/new-enemy.php') + ->and($catalog->findEnemy('New Enemy')?->imagePath)->toBe('blob'); +}); + +it('refuses to create an enemy while the project has no enemy sprite to give it', function () { + $root = enemyRecordProject(); + array_map(unlink(...), glob($root . '/assets/Graphics/Enemies/*') ?: []); + + expect(enemiesDatabase($root)->addRecord())->toBeNull(); +}); + +it('duplicates an enemy into its own file under a name of its own', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + + $copy = $database->duplicateRecord(0); + $database->save(); + $catalog = loadEnemyCatalog($root); + + expect($copy)->toBe(1) + ->and($catalog->getProblems())->toBe([]) + ->and($catalog->getSourceFile('Blob-2'))->toBe('Enemies/blob-2.php') + ->and($catalog->findEnemy('Blob-2')?->stats->totalHp)->toBe(40) + ->and(file_get_contents($root . '/assets/Data/Enemies/blob.php'))->toBe(blobRecordFile()); +}); + +it('keeps a file that is not an enemy record listed, read-only with the reason', function () { + $database = enemiesDatabase(enemyRecordProject([ + 'blob.php' => blobRecordFile(), + 'stray.php' => " 'Stray'];\n", + ])); + [$blob, $stray] = $database->getRecords(); + + expect($database->getEntryLabels())->toBe(['Blob', 'stray']) + ->and($blob->isEditable())->toBeTrue() + ->and($stray->isEditable())->toBeFalse() + ->and($stray->getReadOnlyReason())->toBe("stray.php does not return ['class' => Enemy::class, 'data' => [...]]") + ->and($database->duplicateRecord(1))->toBeNull(); +}); + +it('authors drops as item ids with their chance, and drops the list with its last entry', function () { + $root = enemyRecordProject(['blob.php' => blobRecordFile()]); + $database = enemiesDatabase($root); + $authoring = new RecordAuthoring(); + $rewards = static fn(): array => (require $root . '/assets/Data/Enemies/blob.php')['data']['rewards']; + + $added = $authoring->addItem($database, 0, [], 'dropList'); + $database->setField(0, ProjectRecordDatabase::subFieldId('drop', (int) $added->index, 'item'), 'item.tonic'); + $database->setField(0, ProjectRecordDatabase::subFieldId('drop', (int) $added->index, 'rate'), '0.25'); + $database->save(); + + expect($rewards())->toBe(['experience' => 12, 'gold' => 30, 'items' => [['item' => 'item.tonic', 'rate' => 0.25]]]); + + $authoring->removeItem($database, 0, [], ProjectRecordDatabase::subFieldId('drop', 0, 'item')); + $database->save(); + + expect($rewards())->toBe(['experience' => 12, 'gold' => 30]); +}); diff --git a/tests/Unit/EngineMessageTermsTest.php b/tests/Unit/EngineMessageTermsTest.php new file mode 100644 index 00000000..50493365 --- /dev/null +++ b/tests/Unit/EngineMessageTermsTest.php @@ -0,0 +1,91 @@ + ['title' => 'Hello'],\n 'messages' => [/* kept */ 'file' => 'Slot', 'computed' => strtoupper('keep')],\n];\n"); + + return $root; +} + +/** @return array{0: Ichiloto\Editor\Database\ProjectRecordDatabase, 1: int} The Terms category and a term's index. */ +function findTerm(ProjectWorkspace $workspace, string $path): array +{ + $terms = $workspace->getRecordDatabase('terms'); + $index = array_search($path, $terms->getEntryLabels(), true); + + return [$terms, $index === false ? throw new RuntimeException("No term {$path}.") : $index]; +} + +it('offers every Engine message, with the project\'s own wording where it has one', function () { + $workspace = ProjectWorkspace::fromProject(createTermsProject()); + $labels = $workspace->getRecordDatabase('terms')->getEntryLabels(); + + foreach (array_keys(EngineMessageTerms::listTerms()) as $path) { + expect($labels)->toContain('messages.' . $path); + } + expect(array_count_values($labels)['messages.file'])->toBe(1); + [$terms, $index] = findTerm($workspace, 'messages.file'); + expect($terms->getRecordByIndex($index)?->get('value'))->toBe('Slot'); +}); + +it('writes a reworded Engine message into config.php, keeps the source, writes nothing for its default, and keeps blank', function () { + $root = createTermsProject(); + $original = file_get_contents($root . '/config.php'); + $workspace = ProjectWorkspace::fromProject($root); + [$terms, $index] = findTerm($workspace, 'messages.battle.announcements.attack'); + + $terms->setField($index, 'value', '%2'); + expect($workspace->hasUnsavedChanges())->toBeFalse(); + $terms->setField($index, 'value', '%1 attacks!'); + $terms->save(); + $saved = file_get_contents($root . '/config.php'); + expect((require $root . '/config.php')['messages']['battle']['announcements']['attack'])->toBe('%1 attacks!') + ->and($saved)->toContain('// Keep header.', '/* kept */', "strtoupper('keep')"); + + [$terms, $guard] = findTerm(ProjectWorkspace::fromProject($root), 'messages.battle.announcements.guard'); + $terms->setField($guard, 'value', ''); + $terms->save(); + expect((require $root . '/config.php')['messages']['battle']['announcements']['guard'])->toBe('') + ->and($original)->not->toBe(file_get_contents($root . '/config.php')); +}); + +it('describes a term as the Engine does, and shows what a battle callout will display', function () { + $root = createTermsProject(); + $workspace = ProjectWorkspace::fromProject($root); + [$terms, $index] = findTerm($workspace, 'messages.battle.announcements.item'); + $terms->setField($index, 'value', '%1 uses %2!'); + $lines = implode("\n", RecordPanes::describe($workspace, 'terms', $index)['preview']['lines']); + $term = EngineMessageTerms::listTerms()['battle.announcements.item']; + + expect($lines)->toContain($term['description'], 'Default: %2', '%1 = Actor name', 'Shows: Actor uses Action!'); + [, $file] = findTerm($workspace, 'messages.file'); + expect(implode("\n", RecordPanes::describe($workspace, 'terms', $file)['preview']['lines']))->not->toContain('Shows:'); +}); + +it('shows a message that inherits the project\'s vocabulary wording with that project\'s own wording as its default', function () { + $inheriting = makeTemporaryProject('editor-terms-'); + file_put_contents($inheriting . '/config.php', " ['shop' => ['exp_total' => 'EXP So Far']],\n 'messages' => [],\n];\n"); + $plain = createTermsProject(); + + [$terms, $index] = findTerm(ProjectWorkspace::fromProject($inheriting), 'messages.exp_total'); + expect($terms->getRecordByIndex($index)?->get('default'))->toBe('EXP So Far'); + // Another project opened afterwards reads the Engine's own default, not the first project's wording. + [$terms, $index] = findTerm(ProjectWorkspace::fromProject($plain), 'messages.exp_total'); + expect($terms->getRecordByIndex($index)?->get('default'))->toBe(EngineMessageTerms::listTerms()['exp_total']['default']) + ->and($terms->getRecordByIndex($index)?->get('default'))->not->toBe('EXP So Far'); +}); diff --git a/tests/Unit/EquipmentOptimizationTest.php b/tests/Unit/EquipmentOptimizationTest.php index 91ceb649..87dd8e59 100644 --- a/tests/Unit/EquipmentOptimizationTest.php +++ b/tests/Unit/EquipmentOptimizationTest.php @@ -31,7 +31,7 @@ function writeOptimizePolicy(string $root, string $body): string */ function writeOptimizeInventory(string $root): void { - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' + writeItemRecords($root, ...itemsFromSource(<<<'PHP' 'lifesteal', 'amount' => 10], ), ]; - PHP); + PHP)); } /** @@ -308,15 +308,15 @@ function optimizeIssues(string $root, ?Severity $severity = null): array $root = makeTemporaryProject('ichiloto-optimize-'); writeOptimizeInventory($root); + $workspace = ProjectWorkspace::fromProject($root); - expect(EquipmentOptimizationPolicy::isDeclaredIn($root))->toBeFalse() - ->and(EquipmentOptimizationPolicy::describeSource($root)) + expect(EquipmentOptimizationPolicy::isDeclaredIn($workspace))->toBeFalse() + ->and(EquipmentOptimizationPolicy::describeSource($workspace)) ->toContain('legacy equal weight') ->toContain('compatibility fallback') - ->and(EquipmentOptimizationPolicy::enginePolicyFor($root)) + ->and(EquipmentOptimizationPolicy::enginePolicyFor($workspace)) ->toBeInstanceOf(\Ichiloto\Engine\Entities\EquipmentOptimization\LegacyEqualWeightEquipmentOptimizationPolicy::class); - $workspace = ProjectWorkspace::fromProject($root); $ranked = EquipmentOptimizationPolicy::rank($workspace, $workspace->actorDatabase->getActors()[0], 'weapon'); // Without a declared policy every score is the engine's compatibility @@ -325,8 +325,8 @@ function optimizeIssues(string $root, ?Severity $severity = null): array writeOptimizePolicy($root, " 'statWeights' => ['attack' => 1],"); - expect(EquipmentOptimizationPolicy::describeSource($root))->toContain('declared by this project') - ->and(EquipmentOptimizationPolicy::enginePolicyFor($root)) + expect(EquipmentOptimizationPolicy::describeSource($workspace))->toContain('declared by this project') + ->and(EquipmentOptimizationPolicy::enginePolicyFor($workspace)) ->toBeInstanceOf(\Ichiloto\Engine\Entities\EquipmentOptimization\DeclaredEquipmentOptimizationPolicy::class); }); @@ -344,7 +344,7 @@ function optimizeIssues(string $root, ?Severity $severity = null): array setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); - $rows = static fn(Editor $editor): array => callEditorMethod($editor, 'getDatabaseActorSettingsFields'); + $rows = static fn(Editor $editor): array => callEditorMethod($editor, 'getDatabaseSettingsFields'); $valueOf = static function (Editor $editor, string $label) use ($rows): string { foreach ($rows($editor) as $field) { if (($field['label'] ?? null) === $label) { @@ -362,7 +362,7 @@ function optimizeIssues(string $root, ?Severity $severity = null): array // Nothing this project has is worn on the head, and the preview says so // rather than showing an empty list. - callEditorMethod($editor, 'applyDatabaseFieldValue', '__actor_optimize_slot', 'head'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', array_values(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $row): bool => ($row['field'] ?? null) === '__actor_optimize_slot'))[0], 'head'); expect($valueOf($editor, ' (nothing)'))->toContain('no equipment this project has fits that slot') ->and(sourceHashTree($root))->toBe($before); diff --git a/tests/Unit/EquipmentTypeIconTest.php b/tests/Unit/EquipmentTypeIconTest.php new file mode 100644 index 00000000..aa8cd717 --- /dev/null +++ b/tests/Unit/EquipmentTypeIconTest.php @@ -0,0 +1,53 @@ +getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'icon'); +} + +it('shows an armor or accessory\'s own icon as read-only legacy data', function () { + $database = ProjectRecordDatabase::fromProject(makeTemporaryProject(), RecordSchemaCatalog::forKey('armors')); + $row = iconRow($database, $database->addRecord()); + + expect($row)->not->toBeNull() + ->and($row['editable'] ?? true)->toBeFalse() + ->and($row['label'])->toContain('Legacy Icon'); +}); + +it('shows a weapon\'s own icon as read-only legacy data and keeps it through an edit', function () { + $root = makeTemporaryProject(); + $database = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons')); + $before = $database->getRecords()[0]->get('icon'); + $row = iconRow($database, 0); + + expect($row)->not->toBeNull() + ->and($row['editable'] ?? true)->toBeFalse() + ->and($row['label'])->toContain('Legacy Icon'); + + $database->setField(0, 'description', 'Edited beside a legacy icon.'); + $database->save(); + $saved = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('weapons'))->getRecords()[0]; + + expect($before)->not->toBeNull() + ->and($saved->get('description'))->toBe('Edited beside a legacy icon.') + ->and($saved->get('icon'))->toBe($before); +}); + +it('keeps a consumable\'s own icon editable', function () { + $database = ProjectRecordDatabase::fromProject(makeTemporaryProject(), RecordSchemaCatalog::forKey('items')); + $row = iconRow($database, 0); + + expect($row)->not->toBeNull() + ->and($row['editable'] ?? true)->toBeTrue() + ->and($row['label'])->toBe('Icon'); +}); diff --git a/tests/Unit/EventAuthoringTerminalTest.php b/tests/Unit/EventAuthoringTerminalTest.php new file mode 100644 index 00000000..27180020 --- /dev/null +++ b/tests/Unit/EventAuthoringTerminalTest.php @@ -0,0 +1,106 @@ +maps[0]]; +} + +/** Puts the cursor on an event's first cell and returns the event's inspector rows by label. */ +function terminalEventRows(Editor $editor, ProjectMap $map, string $marker): array +{ + [$x, $y] = $map->getEventArea($marker)->cells[0]; + setEditorProperty($editor, 'cursorX', $x); + setEditorProperty($editor, 'cursorY', $y); + $fields = callEditorMethod($editor, 'getInspectorFields'); + $fields = array_slice($fields, (int) array_search('Event', array_column($fields, 'label'), true)); + $rows = []; + $section = ''; + foreach ($fields as $field) { + if (! str_starts_with((string) $field['label'], ' ')) { + $section = (string) $field['label']; + } + $rows[$section . '/' . trim((string) $field['label'])] = $field; + } + + return $rows; +} + +it('refuses an inspector move or resize onto another event instead of overwriting its cells', function () { + [$editor, $map] = terminalEventEditor(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $bounds = $map->getEventBounds($marker); + $neighbour = EventAuthoring::createEvent($map, [[$bounds['x'] + 1, $bounds['y']]], EventTypeCatalog::at(0))['marker']; + $rows = terminalEventRows($editor, $map, $marker); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Size/X'], '2'); + expect(getEditorProperty($editor, 'statusMessage'))->toContain("would cover marker {$neighbour}") + ->and($map->getEventArea($neighbour)?->cells)->toBe([[$bounds['x'] + 1, $bounds['y']]]); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Position/X'], (string) ($bounds['x'] + 1)); + expect(getEditorProperty($editor, 'statusMessage'))->toContain("would cover marker {$neighbour}") + ->and($map->getEventBounds($marker))->toBe($bounds); +}); + +it('refuses an inspector position off the map rather than clamping it onto the edge', function () { + [$editor, $map] = terminalEventEditor(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $bounds = $map->getEventBounds($marker); + + callEditorMethod($editor, 'applyInspectorFieldValue', terminalEventRows($editor, $map, $marker)['Position/X'], '-1'); + + expect(getEditorProperty($editor, 'statusMessage'))->toContain('would leave the map') + ->and($map->getEventBounds($marker))->toBe($bounds); +}); + +it('changes an event\'s type from the type dialog as one undo step', function () { + [$editor, $map] = terminalEventEditor(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $original = $map->getEventDefinition($marker); + + callEditorMethod($editor, 'openEventTypeDialog', $marker); + setEditorProperty($editor, 'selectedEventTypeIndex', array_search('Dialogue', array_map(static fn($type): string => $type->label, EventTypeCatalog::all()), true)); + callEditorMethod($editor, 'applySelectedEventType'); + + expect($map->getEventDefinition($marker))->toBe(EventTypeCatalog::findByLabel('Dialogue')->buildDefinition(null)) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe("{$marker} is now a Dialogue event."); + + callEditorMethod($editor, 'performUndo'); + expect($map->getEventDefinition($marker))->toBe($original); +}); + +it('sets a transfer\'s destination and spawn point from the picker as one undo step', function () { + [$editor, $map] = terminalEventEditor(); + $marker = EventAuthoring::createEvent($map, [[1, $map->getHeight() - 1]], EventTypeCatalog::findByLabel('Transfer Player'))['marker']; + $before = $map->getEventDefinition($marker); + terminalEventRows($editor, $map, $marker); + + callEditorMethod($editor, 'openDestinationDialog', $marker, ['data', 'destinationMap'], ''); + callEditorMethod($editor, 'applySelectedDestination'); + setEditorProperty($editor, 'cursorX', 2); + setEditorProperty($editor, 'cursorY', 1); + callEditorMethod($editor, 'commitDestinationSpawnSelection'); + + expect($map->getEventField($marker, ['data', 'destinationMap']))->toBe($map->mapId) + ->and($map->getEventField($marker, ['data', 'spawnPoint']))->toBe(['x' => 2, 'y' => 1]) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe("{$marker} destination set to {$map->mapId} (2, 1)."); + + callEditorMethod($editor, 'performUndo'); + expect($map->getEventDefinition($marker))->toBe($before); +}); diff --git a/tests/Unit/EventAuthoringTest.php b/tests/Unit/EventAuthoringTest.php new file mode 100644 index 00000000..1c080c75 --- /dev/null +++ b/tests/Unit/EventAuthoringTest.php @@ -0,0 +1,196 @@ +maps[array_search('test-map', $workspace->mapIds, true)]; +} + +/** Every cell of the event layer, so a refusal can be shown to change nothing. */ +function eventLayerOf(ProjectMap $map): array +{ + $rows = []; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + $rows[$y][$x] = $map->getEventSymbol($x, $y); + } + } + + return [$rows, $map->getEventDefinitions()]; +} + +/** A free 2x1 run of event cells, found rather than assumed. */ +function freeEventCells(ProjectMap $map, int $count = 2): array +{ + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x + $count <= $map->getWidth(); $x++) { + $cells = array_map(static fn(int $offset): array => [$x + $offset, $y], range(0, $count - 1)); + if (array_all($cells, static fn(array $cell): bool => $map->getEventMarkerAt($cell[0], $cell[1]) === null)) { + return $cells; + } + } + } + + throw new RuntimeException('The fixture map has no free event cells.'); +} + +it('creates an event at cells with a type as one undo step, under a marker no event claims', function () { + $map = authoringMap(); + $before = eventLayerOf($map); + $cells = freeEventCells($map); + $dialogue = EventTypeCatalog::findByLabel('Dialogue'); + $expected = EventMarkers::findFreeMarker(EventMarkers::findUsedMarkers($map)); + + $created = EventAuthoring::createEvent($map, $cells, $dialogue); + $marker = $created['marker']; + + expect($marker)->toBe($expected) + ->and($map->getEventArea($marker)?->cells)->toBe($cells) + ->and($map->getEventDefinition($marker))->toBe($dialogue->buildDefinition(null)) + ->and($created['command']->label)->toBe('Event create'); + + $created['command']->undo(); + expect(eventLayerOf($map))->toBe($before) + ->and($map->isDirty())->toBeFalse(); + + $created['command']->execute(); + expect($map->getEventArea($marker)?->cells)->toBe($cells) + ->and($map->getEventDefinition($marker)['class'] ?? null)->toBe(DialogueEventTrigger::class); +}); + +it('refuses an event on cells another event holds, off the map, or under a marker that cannot be used, changing nothing', function () { + $map = authoringMap(); + [$placed] = array_keys($map->getEventDefinitions()); + [$x, $y] = $map->getEventArea((string) $placed)->cells[0]; + $type = EventTypeCatalog::at(0); + $before = eventLayerOf($map); + $revision = $map->stateVersion(); + + expect(fn() => EventAuthoring::createEvent($map, [[$x, $y]], $type))->toThrow(EventRefusal::class, "Marker {$placed} already holds") + ->and(fn() => EventAuthoring::createEvent($map, [[$map->getWidth(), 0]], $type))->toThrow(EventRefusal::class, 'is outside') + ->and(fn() => EventAuthoring::createEvent($map, [], $type))->toThrow(EventRefusal::class, 'at least one cell') + ->and(fn() => EventAuthoring::createEvent($map, freeEventCells($map), $type, (string) $placed))->toThrow(EventRefusal::class, 'already an event') + ->and(fn() => EventAuthoring::createEvent($map, freeEventCells($map), $type, '7'))->toThrow(EventRefusal::class, 'is a digit') + ->and(fn() => EventAuthoring::createEvent($map, freeEventCells($map), $type, 'AB'))->toThrow(EventRefusal::class, 'one terminal cell') + ->and(eventLayerOf($map))->toBe($before) + ->and($map->stateVersion())->toBe($revision); +}); + +it('treats a painted marker without a definition, and a definition without cells, as taken', function () { + $map = authoringMap(); + [$x, $y] = freeEventCells($map, 1)[0]; + $map->setEventSymbol($x, $y, '@'); + + expect(EventMarkers::findUsedMarkers($map))->toContain('@', ...array_map(strval(...), array_keys($map->getEventDefinitions()))) + ->and(EventMarkers::findFreeMarker(EventMarkers::findUsedMarkers($map)))->toBe('!') + ->and(EventMarkers::findFreeMarker(str_split(EventMarkers::CANDIDATES)))->toBeNull(); +}); + +it('retypes an event, keeping what it holds when the type is unchanged, and undoes to the old definition', function () { + $map = authoringMap(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $original = $map->getEventDefinition($marker); + $current = EventTypeCatalog::at(EventTypeCatalog::indexOfClass($original['class'])); + + $same = EventAuthoring::setEventType($map, $marker, $current); + // Defaults follow the type, while authored keys retain their values and relative order. + expect($map->getEventDefinition($marker))->toEqual($current->buildDefinition($original)) + ->and(array_intersect_key($map->getEventDefinition($marker)['data'], $original['data']))->toBe($original['data']); + $same?->undo(); + expect($map->getEventDefinition($marker))->toBe($original); + + $dialogue = EventTypeCatalog::findByLabel('Dialogue'); + $command = EventAuthoring::setEventType($map, $marker, $dialogue); + expect($map->getEventDefinition($marker))->toBe($dialogue->buildDefinition(null)) + ->and(EventAuthoring::setEventType($map, $marker, $dialogue))->toBeNull(); + $command->undo(); + expect($map->getEventDefinition($marker))->toBe($original) + ->and(fn() => EventAuthoring::setEventType($map, 'Q', $dialogue))->toThrow(EventRefusal::class, 'has no event Q'); +}); + +it('deletes an event\'s cells and definition together and undoes both, back to the authored order', function () { + $map = authoringMap(); + $created = EventAuthoring::createEvent($map, freeEventCells($map), EventTypeCatalog::at(0)); + [$first] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $before = eventLayerOf($map); + + $command = EventAuthoring::deleteEvent($map, $first); + expect($map->getEventDefinition($first))->toBeNull() + ->and($map->getEventArea($first))->toBeNull() + ->and($map->getEventDefinition($created['marker']))->not->toBeNull(); + + $command->undo(); + // The deleted event comes back where it was, ahead of the newer one. + expect(eventLayerOf($map))->toBe($before) + ->and(array_map(strval(...), array_keys($map->getEventDefinitions())))->toBe([$first, $created['marker']]); + + // A marker painted without a definition is deleted the same way. + [$x, $y] = freeEventCells($map, 1)[0]; + $map->setEventSymbol($x, $y, 'Q'); + EventAuthoring::deleteEvent($map, 'Q'); + expect($map->getEventMarkerAt($x, $y))->toBeNull() + ->and(fn() => EventAuthoring::deleteEvent($map, 'Q'))->toThrow(EventRefusal::class, 'has no event Q'); +}); + +it('moves and reshapes an event, refusing to leave the map or cover another event instead of clamping or overwriting', function () { + $map = authoringMap(); + [$marker] = array_map(strval(...), array_keys($map->getEventDefinitions())); + $bounds = $map->getEventBounds($marker); + // A second event directly to the right of the first. + $neighbour = EventAuthoring::createEvent($map, [[$bounds['x'] + $bounds['width'], $bounds['y']]], EventTypeCatalog::at(0))['marker']; + $before = eventLayerOf($map); + + expect(fn() => EventAuthoring::moveEvent($map, $marker, 1, 0))->toThrow(EventRefusal::class, "would cover marker {$neighbour}") + ->and(fn() => EventAuthoring::moveEvent($map, $marker, -($bounds['x'] + 1), 0))->toThrow(EventRefusal::class, 'would leave the map') + ->and(fn() => EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], $bounds['width'] + 1, 1)) + ->toThrow(EventRefusal::class, "would cover marker {$neighbour}") + ->and(fn() => EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], 1, $map->getHeight())) + ->toThrow(EventRefusal::class, 'would leave the map') + ->and(fn() => EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], 0, 1))->toThrow(EventRefusal::class, 'at least 1x1') + ->and(eventLayerOf($map))->toBe($before) + ->and(EventAuthoring::moveEvent($map, $marker, 0, 0))->toBeNull(); + + $move = EventAuthoring::moveEvent($map, $marker, 0, 1); + expect($map->getEventBounds($marker))->toBe([...$bounds, 'y' => $bounds['y'] + 1]) + ->and($move->label)->toBe('Event move'); + $move->undo(); + expect(eventLayerOf($map))->toBe($before); + + $grow = EventAuthoring::setEventBounds($map, $marker, $bounds['x'], $bounds['y'], 1, 2); + expect($map->getEventBounds($marker))->toBe([...$bounds, 'width' => 1, 'height' => 2]) + ->and($map->getEventArea($neighbour))->not->toBeNull(); + $grow->undo(); + expect(eventLayerOf($map))->toBe($before); +}); + +it('refuses every event edit on a read-only map before anything changes', function () { + $root = makeTemporaryProject('ichiloto-event-readonly-'); + file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', "maps[0]; + $marker = '@'; + + expect($map->getGridSourceIssue())->not->toBeNull() + ->and(fn() => EventAuthoring::createEvent($map, [[0, 0]], EventTypeCatalog::at(0)))->toThrow(MapSourceRefusal::class, 'read-only') + ->and(fn() => EventAuthoring::deleteEvent($map, $marker))->toThrow(MapSourceRefusal::class, 'read-only') + ->and(fn() => EventAuthoring::moveEvent($map, $marker, 0, 1))->toThrow(MapSourceRefusal::class, 'read-only') + ->and($map->isDirty())->toBeFalse(); +}); diff --git a/tests/Unit/EventCueFrontendTest.php b/tests/Unit/EventCueFrontendTest.php new file mode 100644 index 00000000..d811ec9f --- /dev/null +++ b/tests/Unit/EventCueFrontendTest.php @@ -0,0 +1,111 @@ + 'Cue frontend fixture', + 'description' => strtoupper('kept'), + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger', + 'data' => ['lootType' => 'gold', 'loot' => 2], + 'cue' => ['symbol' => '!', 'color' => 'bright-blue', 'conditions' => []], + ], + ], + ]; + PHP); + + return $root; +} + +it('cycles and clears cue kind through the real Terminal inspector without typed or graphical controls', function () { + $root = createCueFrontendProject(); + $editor = deletionEditor($root); + $map = callEditorMethod($editor, 'getSelectedMap'); + $bounds = $map->getEventBounds('E'); + setEditorProperty($editor, 'cursorX', $bounds['x']); + setEditorProperty($editor, 'cursorY', $bounds['y']); + setEditorProperty($editor, 'editingMode', 'event'); + setEditorProperty($editor, 'focusedPane', 'inspector'); + $fields = callEditorMethod($editor, 'getInspectorFields'); + $index = array_find_key($fields, static fn(array $field): bool => ($field['path'] ?? null) === ['cue', 'kind']); + expect($index)->not->toBeNull(); + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + + expect(implode("\n", callEditorMethod($editor, 'inspectorPaneLayout')->lines))->toContain('Unclassified') + ->and($fields[$index])->not->toHaveKey('control'); + callEditorMethod($editor, 'activateInspectorField'); + expect($map->getEventField('E', ['cue', 'kind']))->toBe('story') + ->and(getEditorProperty($editor, 'isInspectorEditing'))->toBeFalse(); + callEditorMethod($editor, 'adjustInspectorOptionField', 1); + expect($map->getEventField('E', ['cue', 'kind']))->toBe('route'); + callEditorMethod($editor, 'adjustInspectorOptionField', -1); + callEditorMethod($editor, 'adjustInspectorOptionField', -1); + expect($map->getEventDefinition('E'))->toBe($before) + ->and(sourceHashTree($root))->toBe($hashes); + callEditorMethod($editor, 'performUndo'); + expect($map->getEventField('E', ['cue', 'kind']))->toBe('story'); + $map->save(); + expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getEventField('E', ['cue', 'kind']))->toBe('story') + ->and(file_get_contents($map->dataPath))->toContain("strtoupper('kept')"); +}); + +it('transports cue choices and clear through the GUI session protocol with undo save and reload', function () { + $root = createCueFrontendProject(); + $hashes = sourceHashTree($root); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $read = static fn(): array => $request('inspector.read', ['map' => 'test-map', 'event' => 'E'])['result']; + $findRow = static fn(array $inspector): array => array_find($inspector['rows'], + static fn(array $row): bool => ($row['key']['path'] ?? null) === ['cue', 'kind']); + + try { + expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + $inspector = $read(); + $row = $findRow($inspector); + expect($row)->toMatchArray(['kind' => 'options', 'value' => '', 'options' => ['', 'story', 'route']]) + ->and($row['optionLabels'])->toBe(['Unclassified (clear kind)', 'Story', 'Route']) + ->and(sourceHashTree($root))->toBe($hashes); + + $params = ['map' => 'test-map', 'revision' => $inspector['revision'], 'key' => $row['key']]; + expect($request('inspector.apply', $params + ['value' => 'unknown'])['error']['kind'])->toBe('refusal') + ->and($findRow($read())['value'])->toBe('') + ->and(sourceHashTree($root))->toBe($hashes); + expect($request('inspector.apply', $params + ['value' => 'route'])['result']['changed'])->toBeTrue() + ->and($findRow($read())['value'])->toBe('route'); + expect($request('project.saveAll')['result']['failures'])->toBe([]) + ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getEventField('E', ['cue', 'kind']))->toBe('route'); + $inspector = $read(); + expect($request('inspector.apply', ['map' => 'test-map', 'revision' => $inspector['revision'], + 'key' => $findRow($inspector)['key'], 'value' => ''])['result']['changed'])->toBeTrue() + ->and($findRow($read())['value'])->toBe(''); + expect($request('project.saveAll')['result']['failures'])->toBe([]) + ->and(sourceHashTree($root))->toBe($hashes); + expect($request('history.undo')['result']['label'])->toBe('Cue kind edit') + ->and($findRow($read())['value'])->toBe('route'); + expect($request('history.redo')['result']['label'])->toBe('Cue kind edit') + ->and($findRow($read())['value'])->toBe(''); + rewind($diagnostics); + expect(stream_get_contents($diagnostics))->toBe(''); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); diff --git a/tests/Unit/EventCueKindAuthoringTest.php b/tests/Unit/EventCueKindAuthoringTest.php new file mode 100644 index 00000000..470ff6f3 --- /dev/null +++ b/tests/Unit/EventCueKindAuthoringTest.php @@ -0,0 +1,267 @@ + $cueSource,"; + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', << 'Cue fixture', + 'region' => '', + 'description' => strtoupper('kept'), + 'triggers' => [], + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', + 'data' => ['lootType' => 'gold', 'loot' => 2], + 'conditions' => [], + $cueField + ], + ], + ]; + PHP); + + return $root; +} + +function getCueKindFixtureSource(array $extra = []): string +{ + return "[\n // Independently authored cue presentation.\n 'symbol' => '!',\n 'color' => 'bright-blue',\n 'conditions' => [['type' => 'event', 'name' => 'cue_ready']],\n" + . implode('', array_map(static fn(string $key, mixed $value): string => ' ' . var_export($key, true) . ' => ' . var_export($value, true) . ",\n", array_keys($extra), array_values($extra))) + . ' ]'; +} + +function findCueKindMap(ProjectWorkspace $workspace): ProjectMap +{ + return array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map') + ?? throw new RuntimeException('The synthetic cue map is missing.'); +} + +function getCueKindRow(MapInspector $inspector, ProjectMap $map): array +{ + return array_find($inspector->getEventFields($map, 'E'), static fn(array $row): bool => ($row['path'] ?? null) === ['cue', 'kind']) + ?? throw new RuntimeException('The cue kind picker is missing.'); +} + +it('describes an optional constrained cue kind without classifying legacy cues or writing defaults', function (bool $graphical, ?string $kind, bool $hasCue) { + $root = createCueKindProject($hasCue ? getCueKindFixtureSource($kind === null ? [] : ['kind' => $kind]) : null); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + $row = getCueKindRow(new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical), $map); + + expect(array_column($row['choices'], 'value'))->toBe(['', EventCueKind::STORY->value, EventCueKind::ROUTE->value]) + ->and($row['options'])->toBe(['', 'story', 'route']) + ->and($row['optionLabels'])->toBe(['' => 'Unclassified (clear kind)', 'story' => 'Story', 'route' => 'Route']) + ->and($row['selectedValue'])->toBe($kind ?? '') + ->and($row['optional'])->toBeTrue() + ->and($row)->not->toHaveKey('control') + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true])->with([ + 'no cue' => [null, false], + 'legacy blue cue' => [null, true], + 'story' => ['story', true], + 'route' => ['route', true], +]); + +it('authors each engine cue kind with source-preserving save undo redo and reload', function (bool $graphical, string $kind) { + $root = createCueKindProject(getCueKindFixtureSource()); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + $change = $inspector->apply($map, getCueKindRow($inspector, $map), $kind); + $expected = $before; + $expected['cue']['kind'] = $kind; + + expect($change)->not->toBeNull() + ->and($map->getEventDefinition('E'))->toBe($expected) + ->and(sourceHashTree($root))->toBe($hashes); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected) + ->and(file_get_contents($map->dataPath))->toContain('// Independently authored cue presentation.', "strtoupper('kept')") + ->and(array_keys(array_diff_assoc(sourceHashTree($root), $hashes)))->toBe(['assets/Maps/test-map/test-map.data.php']); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes) + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($before); + $change->execute(); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); +})->with([false, true])->with(['story', 'route']); + +it('clears only cue kind while retaining symbol color conditions and unrelated source', function (bool $graphical, string $kind) { + $root = createCueKindProject(getCueKindFixtureSource(['kind' => $kind])); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + $expected = $before; + unset($expected['cue']['kind']); + $change = $inspector->apply($map, getCueKindRow($inspector, $map), ''); + + expect($change)->not->toBeNull() + ->and($map->getEventDefinition('E'))->toBe($expected) + ->and(EventCue::fromArray($expected['cue'])->kind)->toBeNull(); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected) + ->and(file_get_contents($map->dataPath))->toContain('// Independently authored cue presentation.', "strtoupper('kept')"); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); + $change->execute(); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); +})->with([false, true])->with(['story', 'route']); + +it('keeps clearing omitted kind a no-op and does not materialize cue defaults on authoring', function (bool $graphical) { + $root = createCueKindProject(); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $hashes = sourceHashTree($root); + $row = getCueKindRow($inspector, $map); + + expect($inspector->apply($map, $row, ''))->toBeNull() + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); + $change = $inspector->apply($map, $row, 'route'); + expect($map->getEventField('E', ['cue']))->toBe(['kind' => 'route']); + $map->save(); + expect(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventField('E', ['cue']))->toBe(['kind' => 'route']); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('refuses unknown picker values before changing memory source or dirty state', function (bool $graphical, string $kind) { + $root = createCueKindProject(getCueKindFixtureSource()); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + + expect(fn() => $inspector->apply($map, getCueKindRow($inspector, $map), $kind))->toThrow(InspectorRefusal::class) + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); +})->with([false, true])->with(['unknown', 'STORY', ' route ', 'null', '[]']); + +it('refuses changing expression-authored cue kind before mutation', function (bool $graphical) { + $root = createCueKindProject("['symbol' => '!', 'kind' => strtolower('STORY')]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $hashes = sourceHashTree($root); + + expect(fn() => $inspector->apply($map, getCueKindRow($inspector, $map), 'route'))->toThrow(MapSourceRefusal::class) + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('clears expression-authored cue kind with byte-restoring undo after save reload and redo', function (bool $graphical) { + $root = createCueKindProject("['symbol' => '!', 'kind' => strtolower('STORY')]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + $source = file_get_contents($map->dataPath); + $hashes = sourceHashTree($root); + $expected = $before; + unset($expected['cue']['kind']); + $change = $inspector->apply($map, getCueKindRow($inspector, $map), ''); + + expect($change)->not->toBeNull() + ->and($map->getEventDefinition('E'))->toBe($expected) + ->and(sourceHashTree($root))->toBe($hashes); + $map->save(); + $clearedSource = file_get_contents($map->dataPath); + expect($clearedSource)->not->toContain("strtolower('STORY')") + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); + $change->undo(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($source) + ->and(sourceHashTree($root))->toBe($hashes) + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($before); + $change->execute(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($clearedSource) + ->and(findCueKindMap(ProjectWorkspace::fromProject($root))->getEventDefinition('E'))->toBe($expected); +})->with([false, true]); + +it('offers one constrained repair row for malformed authored kinds without flattening them into writable subfields', function (bool $graphical, mixed $kind) { + $root = createCueKindProject(getCueKindFixtureSource(['kind' => $kind])); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = findCueKindMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $hashes = sourceHashTree($root); + $before = $map->getEventDefinition('E'); + $rows = array_values(array_filter($inspector->getEventFields($map, 'E'), static fn(array $row): bool => array_slice($row['path'] ?? [], 0, 2) === ['cue', 'kind'])); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['path'])->toBe(['cue', 'kind']) + ->and($rows[0]['value'])->not->toBe('') + ->and($rows[0])->not->toHaveKey('control') + ->and($map->getEventDefinition('E'))->toBe($before) + ->and($map->isDirty())->toBeFalse() + ->and(sourceHashTree($root))->toBe($hashes); + $clear = $inspector->apply($map, $rows[0], ''); + $expected = $before; + unset($expected['cue']['kind']); + expect($map->getEventDefinition('E'))->toBe($expected); + $clear->undo(); + expect($map->getEventDefinition('E'))->toBe($before); +})->with([false, true])->with([ + 'null' => [null], 'array' => [['story']], 'boolean' => [false], 'integer' => [1], +]); + +it('accepts omitted story and route kinds through global project validation', function (?string $kind, string $symbol) { + $cue = ['symbol' => $symbol, 'color' => 'bright-blue']; + if ($kind !== null) { $cue['kind'] = $kind; } + $root = createCueKindProject(var_export($cue, true)); + $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)); + + expect(issuesMentioning($issues, 'event cue'))->toBe([]); + if ($symbol !== '') { + expect(EventCue::fromArray($cue)->kind?->value)->toBe($kind); + } +})->with([null, 'story', 'route'])->with(['!', '']); + +it('rejects every invalid authored kind globally even when the cue symbol is blank', function (mixed $kind, string $symbol) { + $cue = ['symbol' => $symbol, 'kind' => $kind]; + $root = createCueKindProject(var_export($cue, true)); + $hashes = sourceHashTree($root); + $issues = issuesMentioning(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), 'event cue kind'); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->where)->toBe('test-map event E') + ->and(fn() => EventCue::fromArray($cue))->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'unknown' => ['unknown'], 'empty' => [''], 'whitespace' => [' story '], + 'null' => [null], 'integer' => [1], 'float' => [1.5], 'boolean' => [false], + 'array' => [['story']], 'object' => [(object) ['kind' => 'story']], +])->with(['!', '']); diff --git a/tests/Unit/EventEngagementAuthoringTest.php b/tests/Unit/EventEngagementAuthoringTest.php new file mode 100644 index 00000000..31801879 --- /dev/null +++ b/tests/Unit/EventEngagementAuthoringTest.php @@ -0,0 +1,141 @@ + $engagementSource,"; + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', << 'Engagement fixture', + 'region' => '', + 'description' => strtoupper('kept'), + 'triggers' => [], + 'events' => [ + 'R' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'synthetic-records'], + // The event's own comment. + 'facing' => ['North'], + $field + ], + ], + ]; + PHP); + // The event layer places R, the event this fixture defines. + $events = $root . '/assets/Maps/test-map/test-map.event.php'; + file_put_contents($events, str_replace(' E ', ' R ', (string) file_get_contents($events))); + + return $root; +} + +function findEngagementMap(string $root): ProjectMap +{ + return array_find(ProjectWorkspace::fromProject($root)->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map') + ?? throw new RuntimeException('The synthetic engagement map is missing.'); +} + +function getEngagementRow(MapInspector $inspector, ProjectMap $map): array +{ + return array_find($inspector->getEventFields($map, 'R'), static fn(array $row): bool => ($row['path'] ?? null) === ['engagement']) + ?? throw new RuntimeException('The engagement choice is missing.'); +} + +it('offers standing on the event or facing it, showing the default without writing it', function (bool $graphical) { + $root = createEngagementProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $row = getEngagementRow(new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical), $map); + + expect($row['options'])->toBe(['inside', 'facing']) + ->and($row['selectedValue'])->toBe('inside') + ->and($row)->not->toHaveKey('control'); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('writes facing to the source, keeps the event\'s headings, and undoes to the event as it was', function (bool $graphical) { + $root = createEngagementProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('R'); + + $change = $inspector->apply($map, getEngagementRow($inspector, $map), 'facing'); + $map->save(); + + expect(findEngagementMap($root)->getEventDefinition('R'))->toBe([...$before, 'engagement' => 'facing']) + ->and(file_get_contents($map->dataPath))->toContain('// The event\'s own comment.', "strtoupper('kept')"); + $change->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); + $change->execute(); + $map->save(); + expect(findEngagementMap($root)->getEventDefinition('R')['engagement'])->toBe('facing'); +})->with([false, true]); + +it('drops the key when the author goes back to standing on the event', function () { + $root = createEngagementProject("'facing'"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace)); + + $change = $inspector->apply($map, getEngagementRow($inspector, $map), 'inside'); + $map->save(); + + expect(findEngagementMap($root)->getEventDefinition('R'))->not->toHaveKey('engagement'); + $change->undo(); + $map->save(); + expect(findEngagementMap($root)->getEventDefinition('R')['engagement'])->toBe('facing'); +}); + +it('names an engagement the Engine would refuse, and the validator reports it', function () { + $root = createEngagementProject("'beside'"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $row = getEngagementRow(new MapInspector(new ReferenceCatalog($workspace)), $map); + $messages = array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate($workspace)); + + expect($row['editable'])->toBeFalse() + ->and(implode("\n", $messages))->toContain('Event engagement must be inside or facing.') + ->and(array_filter($messages, static fn(string $message): bool => str_contains($message, 'unsupported root field')))->toBe([]); +}); + +it('serves the choice to a graphical editor as options it edits and saves through the session', function () { + $root = createEngagementProject(); + $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; + $original = file_get_contents($mapPath); + $session = EditorSession::open($root); + $view = $session->readInspector('test-map', 'R'); + $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['path'] ?? null) === ['engagement']); + + expect($row['kind'])->toBe('options') + ->and($row['options'])->toBe(['inside', 'facing']) + ->and($row['optionLabels'])->toBe(['Stand on it', 'Face it from next to it']); + $session->applyInspector('test-map', $view['revision'], $row['key'], 'facing'); + $session->saveMap('test-map'); + expect((require $mapPath)['events']['R']['engagement'])->toBe('facing'); + $session->undo(); + $session->saveMap('test-map'); + expect(file_get_contents($mapPath))->toBe($original); +}); diff --git a/tests/Unit/EventFacingAuthoringTest.php b/tests/Unit/EventFacingAuthoringTest.php new file mode 100644 index 00000000..a4eea10e --- /dev/null +++ b/tests/Unit/EventFacingAuthoringTest.php @@ -0,0 +1,151 @@ + $facingSource,"; + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', << 'Facing fixture', + 'region' => '', + 'description' => strtoupper('kept'), + 'triggers' => [], + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\\\$class', + 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'synthetic-note'], + // The event's own comment. + 'conditions' => [], + $facingField + ], + ], + ]; + PHP); + + return $root; +} + +function findFacingMap(string $root): ProjectMap +{ + return array_find(ProjectWorkspace::fromProject($root)->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map') + ?? throw new RuntimeException('The synthetic facing map is missing.'); +} + +/** @return array> The facing rows by heading. */ +function listFacingRows(MapInspector $inspector, ProjectMap $map): array +{ + $rows = array_filter($inspector->getEventFields($map, 'E'), static fn(array $row): bool => is_string($row['facingHeading'] ?? null)); + + return array_column($rows, null, 'facingHeading'); +} + +it('offers one choice per heading, none chosen, without writing anything for an event that faces any way', function (bool $graphical) { + $root = createFacingProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $rows = listFacingRows(new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical), $map); + + expect(array_keys($rows))->toBe(['North', 'East', 'South', 'West']) + ->and(array_column($rows, 'value'))->toBe(['false', 'false', 'false', 'false']) + ->and(array_column($rows, 'label'))->toBe(['Facing North', 'Facing East', 'Facing South', 'Facing West']) + ->and($map->isDirty())->toBeFalse(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes); +})->with([false, true]); + +it('writes chosen headings in compass order, keeps the source, and undoes to the event as it was', function (bool $graphical) { + $root = createFacingProject(); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: $graphical); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: $graphical); + $before = $map->getEventDefinition('E'); + + $east = $inspector->apply($map, listFacingRows($inspector, $map)['East'], 'true'); + $north = $inspector->apply($map, listFacingRows($inspector, $map)['North'], 'true'); + $map->save(); + + expect($map->getEventDefinition('E')['facing'])->toBe(['North', 'East']) + ->and(findFacingMap($root)->getEventDefinition('E')['facing'])->toBe(['North', 'East']) + ->and(file_get_contents($map->dataPath))->toContain('// The event\'s own comment.', "strtoupper('kept')") + ->and(array_column(listFacingRows($inspector, $map), 'value'))->toBe(['true', 'true', 'false', 'false']); + $north->undo(); + $east->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes) + ->and(findFacingMap($root)->getEventDefinition('E'))->toBe($before); + $east->execute(); + $map->save(); + expect(findFacingMap($root)->getEventDefinition('E')['facing'])->toBe(['East']); +})->with([false, true]); + +it('removes the facing once no heading is left, so the event starts whichever way the player faces', function () { + $root = createFacingProject("['West']"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $inspector = new MapInspector(new ReferenceCatalog($workspace)); + + $change = $inspector->apply($map, listFacingRows($inspector, $map)['West'], 'false'); + $map->save(); + + expect(findFacingMap($root)->getEventDefinition('E'))->not->toHaveKey('facing'); + $change->undo(); + $map->save(); + expect(findFacingMap($root)->getEventDefinition('E')['facing'])->toBe(['West']); +}); + +it('names a facing the Engine would refuse instead of offering headings that would rewrite it', function () { + $root = createFacingProject("['Up']"); + $workspace = ProjectWorkspace::fromProject($root); + $map = array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); + $rows = array_values(array_filter((new MapInspector(new ReferenceCatalog($workspace)))->getEventFields($map, 'E'), + static fn(array $row): bool => ($row['path'] ?? null) === ['facing'] || is_string($row['facingHeading'] ?? null))); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['editable'])->toBeFalse() + ->and(implode("\n", array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate(ProjectWorkspace::fromProject($root))))) + ->toContain('Event facing accepts North, East, South and West only.'); +}); + +it('accepts a facing on any trigger kind without calling it an unsupported field', function (string $class) { + $root = createFacingProject("['North']", $class); + $messages = array_map(static fn($issue): string => $issue->message, new ProjectValidator()->validate(ProjectWorkspace::fromProject($root))); + + expect(array_filter($messages, static fn(string $message): bool => str_contains($message, 'facing')))->toBe([]); +})->with(['ScriptEventTrigger', 'SleepEventTrigger']); + +it('serves the headings to a graphical editor as checkboxes it edits, saves and undoes through the session', function () { + $root = createFacingProject(); + $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; + $original = file_get_contents($mapPath); + $session = Ichiloto\Editor\Session\EditorSession::open($root); + $view = $session->readInspector('test-map', 'E'); + $rows = array_values(array_filter($view['rows'], static fn(array $row): bool => ($row['key']['path'][0] ?? null) === 'facing')); + + expect(array_column($rows, 'label'))->toBe(['Facing North', 'Facing East', 'Facing South', 'Facing West']) + ->and(array_unique(array_column($rows, 'kind')))->toBe(['boolean']); + $session->applyInspector('test-map', $view['revision'], $rows[1]['key'], 'true'); + $session->saveMap('test-map'); + expect((require $mapPath)['events']['E']['facing'])->toBe(['East']); + $session->undo(); + $session->saveMap('test-map'); + expect(file_get_contents($mapPath))->toBe($original); +}); diff --git a/tests/Unit/EventMarkerCellsTest.php b/tests/Unit/EventMarkerCellsTest.php new file mode 100644 index 00000000..344f9deb --- /dev/null +++ b/tests/Unit/EventMarkerCellsTest.php @@ -0,0 +1,81 @@ + 120, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + callEditorMethod($editor, 'setEditingMode', 'event'); + setEditorProperty($editor, 'cursorX', 1); + setEditorProperty($editor, 'cursorY', 1); + + return [$editor, getEditorProperty($editor, 'workspace')->maps[0]]; +} + +/** @return array> The event's inspector rows keyed "Section/label". */ +function scatteredMarkerRows(Ichiloto\Editor\Editor $editor): array +{ + $rows = []; + $section = ''; + $fields = callEditorMethod($editor, 'getInspectorFields'); + // The map's own rows come first; the event's begin at its Event row. + $fields = array_slice($fields, (int) array_search('Event', array_column($fields, 'label'), true)); + + foreach ($fields as $field) { + if (! str_starts_with((string) $field['label'], ' ')) { + $section = (string) $field['label']; + } + + $rows[$section . '/' . trim((string) $field['label'])] = $field; + } + + return $rows; +} + +it('shows the cells of a marker painted in separate places instead of a rectangle to resize', function () { + [$editor] = scatteredMarkerEditor(); + $rows = scatteredMarkerRows($editor); + + expect($rows['Cells/Cells']['value'] ?? null)->toBe('2 in 2 places') + ->and($rows)->toHaveKeys(['Position/X', 'Position/Y']) + ->and($rows)->not->toHaveKey('Size/X'); +}); + +it('moves every cell of a scattered marker from the inspector, and undoes it whole', function () { + /** @var ProjectMap $map */ + [$editor, $map] = scatteredMarkerEditor(); + $rows = scatteredMarkerRows($editor); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Position/X'], '2'); + + expect($map->getEventArea('E')?->cells)->toBe([[2, 1], [9, 3]]); + + callEditorMethod($editor, 'performUndo'); + + expect($map->getEventArea('E')?->cells)->toBe([[1, 1], [8, 3]]); +}); + +it('refuses to move a scattered marker onto another marker and leaves it where it was', function () { + /** @var ProjectMap $map */ + [$editor, $map] = scatteredMarkerEditor(); + $map->setEventSymbol(3, 1, 'F'); + $rows = scatteredMarkerRows($editor); + + callEditorMethod($editor, 'applyInspectorFieldValue', $rows['Position/X'], '3'); + + expect($map->getEventArea('E')?->cells)->toBe([[1, 1], [8, 3]]) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('would cover marker F at (3, 1)'); +}); diff --git a/tests/Unit/EventRecordTerminalTest.php b/tests/Unit/EventRecordTerminalTest.php new file mode 100644 index 00000000..8d6960d1 --- /dev/null +++ b/tests/Unit/EventRecordTerminalTest.php @@ -0,0 +1,88 @@ + $row) { + if (($row['field'] ?? null) === $field) { + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + + return $row; + } + } + + throw new RuntimeException("No inspector row {$field}."); +} + +it('opens a Story Script\'s inline commands as a frame, edits them there, and leaves with Esc', function () { + $editor = eventRecordTerminal(10); + $map = getEditorProperty($editor, 'workspace')->maps[0]; + + selectEventRecordField($editor, 'commandListScript'); + callEditorMethod($editor, 'dispatchInput', "\r"); + $rows = callEditorMethod($editor, 'getInspectorFields'); + + expect($rows[0]['label'])->toBe('Script') + ->and(array_column($rows, 'field'))->toContain('command0Text', 'command1Seconds') + ->and(array_column($rows, 'field'))->not->toContain('conditions'); + + callEditorMethod($editor, 'applyInspectorFieldValue', selectEventRecordField($editor, 'command0Text'), 'Wait for me.'); + selectEventRecordField($editor, 'command0Text'); + callEditorMethod($editor, 'dispatchInput', 'O'); + expect($map->getEventDefinition('S')['data']['script'][0]['text'])->toBe('Wait for me.') + ->and($map->getEventDefinition('S')['data']['script'])->toHaveCount(3); + + callEditorMethod($editor, 'dispatchInput', "\033"); + $field = callEditorMethod($editor, 'getInspectorFields')[getEditorProperty($editor, 'selectedInspectorFieldIndex')]; + expect($field['field'])->toBe('commandListScript') + ->and($field['value'])->toBe('3'); +}); + +it('builds an event\'s completion writes in the shared write editor hosted in the Inspector', function () { + $editor = eventRecordTerminal(1); + $map = getEditorProperty($editor, 'workspace')->maps[0]; + + selectEventRecordField($editor, 'sets'); + callEditorMethod($editor, 'dispatchInput', "\r"); + $writes = getEditorProperty($editor, 'worldWriteEditor'); + expect($writes->isOpen())->toBeTrue(); + + callEditorMethod($editor, 'dispatchInput', 'a'); + callEditorMethod($editor, 'dispatchInput', 't'); + $writes->setName('door_seen'); + callEditorMethod($editor, 'dispatchInput', "\r"); + + expect($map->getEventDefinition('T')['sets'])->toBe([['type' => 'event', 'name' => 'door_seen']]) + ->and(getEditorProperty($editor, 'mapWriteField'))->toBeNull(); + + // Esc leaves the writes alone and releases the hosting. + selectEventRecordField($editor, 'sets'); + callEditorMethod($editor, 'dispatchInput', "\r"); + callEditorMethod($editor, 'dispatchInput', 'a'); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect($map->getEventDefinition('T')['sets'])->toBe([['type' => 'event', 'name' => 'door_seen']]) + ->and(getEditorProperty($editor, 'mapWriteField'))->toBeNull(); +}); diff --git a/tests/Unit/EventReferenceFieldTest.php b/tests/Unit/EventReferenceFieldTest.php index 35cca4e8..7d1de54a 100644 --- a/tests/Unit/EventReferenceFieldTest.php +++ b/tests/Unit/EventReferenceFieldTest.php @@ -15,9 +15,11 @@ */ function eventDataFieldFor(array $data, array $path): array { - $editor = createEditorForTesting(makeTemporaryProject()); + $root = makeTemporaryProject(); + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - foreach (callEditorMethod($editor, 'buildEventDataFields', 'A', ['data' => $data]) as $field) { + foreach (callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('A', ['data' => $data]) as $field) { if (($field['path'] ?? null) === $path) { return $field; } @@ -63,6 +65,28 @@ function eventDataFieldFor(array $data, array $path): array ->and($field)->toHaveKey('control'); }); +it('picks a chest\'s loot from the database its loot type names', function (array $data, ?string $category) { + $field = eventDataFieldFor($data, ['data', 'loot']); + + expect($field['reference'] ?? null)->toBe($category); + if ($category !== null) { + expect($field)->not->toHaveKey('control'); + } +})->with([ + 'an item' => [['loot' => '', 'lootType' => 'item'], 'items'], + // The runtime reads a chest with no loot type as holding an item. + 'no loot type' => [['loot' => ''], 'items'], + 'a weapon' => [['loot' => '', 'lootType' => 'weapon'], 'weapons'], + 'an armor' => [['loot' => '', 'lootType' => 'armor'], 'armors'], + 'an accessory' => [['loot' => '', 'lootType' => 'accessory'], 'inventory'], + // Gold is an amount, typed. + 'gold' => [['loot' => '50', 'lootType' => 'gold'], null], +]); + +it('leaves a chest\'s loot to the terminal editor\'s own dialog when no loot type is given', function () { + expect(Ichiloto\Editor\Inspector\MapInspector::findEventReference(['target' => 'event', 'path' => ['data', 'loot']]))->toBeNull(); +}); + it('offers a shop every kind of thing an item store holds', function () { $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject(makeTemporaryProject())); $stock = $catalog->valuesFor('inventory'); diff --git a/tests/Unit/FieldCueAuthoringTest.php b/tests/Unit/FieldCueAuthoringTest.php new file mode 100644 index 00000000..5ba906e2 --- /dev/null +++ b/tests/Unit/FieldCueAuthoringTest.php @@ -0,0 +1,169 @@ + 5, + 'lengthFrames' => 1, 'playback' => 'loop', 'restFrame' => 0, 'tracks' => [['id' => 'mark', 'type' => 'image', + 'asset' => 'Graphics/System/CuePlate.png', 'sheet' => ['columns' => 1, 'rows' => 1], 'cells' => ['width' => 1, 'height' => 1], + 'depth' => 'front', 'keyframes' => [['frame' => 0, 'sourceFrame' => 0]]]]], true) . ';'); + $turns = ['east' => 0, 'southeast' => 1, 'south' => 1, 'southwest' => 2, 'west' => 2, 'northwest' => 3, 'north' => 3, 'northeast' => 0]; + $lines = []; + foreach ($turns as $direction => $turn) { + $asset = str_contains(substr($direction, 1), 'east') || str_contains(substr($direction, 1), 'west') ? 'CueEdgeDiagonal' : 'CueEdge'; + $lines[] = " '{$direction}' => ['asset' => 'Graphics/System/{$asset}.png', 'quarterTurns' => {$turn}],"; + } + $edgesSource = $edges ? " 'edges' => [\n" . implode("\n", $lines) . "\n ],\n" : ''; + @mkdir($root . '/assets/Data/Presentation', 0777, true); + file_put_contents($root . '/assets/Data/Presentation/field.php', " [\n 'bright-yellow' => [\n 'effect' => 'cue-mark',\n{$edgesSource} ],\n ],\n];\n"); + + return $root; +} + +function cueRow(EditorSession $session, string $path): array +{ + $records = $session->listDatabaseRecords('field_cues')['records']; + $index = array_search($path, $records, true); + expect($index)->not->toBeFalse("No {$path} setting."); + $value = array_find($session->readDatabaseRecord('field_cues', $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); + + return ['index' => $index, 'row' => $value]; +} + +it('splits baked edge images into pointer images in place, keeping turns and comments', function () { + $root = makeCueProject(); + $settings = new FieldPresentationSettings($root); + + expect(fn() => FieldPresentationCatalog::load($root . '/assets'))->toThrow(InvalidArgumentException::class) + ->and($settings->replaceEdgesWithPointers(['Graphics/System/CueEdge.png' => 'Graphics/System/CuePointer.png', + 'Graphics/System/CueEdgeDiagonal.png' => 'Graphics/System/CuePointerDiagonal.png']))->toBeTrue(); + $source = file_get_contents($root . '/assets/Data/Presentation/field.php'); + $catalog = FieldPresentationCatalog::load($root . '/assets'); + + expect($source)->toContain('// Colour selects replaceable artwork.')->not->toContain("'edges'") + ->and($catalog->cues['bright-yellow']['pointer']['south'])->toBe(['asset' => 'Graphics/System/CuePointer.png', 'quarterTurns' => 1]) + ->and($catalog->cues['bright-yellow']['pointer']['northwest'])->toBe(['asset' => 'Graphics/System/CuePointerDiagonal.png', 'quarterTurns' => 3]) + ->and((new FieldPresentationSettings($root))->replaceEdgesWithPointers([]))->toBeFalse(); +}); + +it('refuses to split edges without a pointer image for each, writing nothing', function () { + $root = makeCueProject(); + $before = file_get_contents($root . '/assets/Data/Presentation/field.php'); + + expect(fn() => (new FieldPresentationSettings($root))->replaceEdgesWithPointers(['Graphics/System/CueEdge.png' => 'Graphics/System/CuePointer.png'])) + ->toThrow(InvalidArgumentException::class) + ->and(file_get_contents($root . '/assets/Data/Presentation/field.php'))->toBe($before); +}); + +it('authors the cue treatment and pointer images as rows with the Engine defaults and refusals', function () { + $root = makeCueProject(edges: false); + $session = EditorSession::open($root); + $records = $session->listDatabaseRecords('field_cues')['records']; + + expect($records)->toContain('cues.bright-yellow.plate', 'cueStyle.bob.height', 'cueStyle.bob.seconds', 'cueStyle.pointerSpacing.x', 'cueStyle.pointerSpacing.y', + 'cueStyle.terminal.background', 'cueStyle.terminal.bold', 'cues.bright-yellow.effect', + 'cues.bright-yellow.pointer.east.asset', 'cues.bright-yellow.pointer.east.quarterTurns', 'waymarks.color') + ->and(cueRow($session, 'cues.bright-yellow.plate')['row'])->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets']) + ->and($records)->not->toContain('cueStyle.plate') + ->and(cueRow($session, 'cueStyle.terminal.background')['row']['value'])->toBe('black') + ->and(cueRow($session, 'cues.bright-yellow.effect')['row'])->toMatchArray(['kind' => 'reference', 'reference' => 'effects']); + + $height = cueRow($session, 'cueStyle.bob.height'); + expect(fn() => $session->applyDatabaseRecord('field_cues', $height['index'], $height['row']['key'], '13')) + ->toThrow('A cue bob height must be 0..12 logical pixels.'); + $session->applyDatabaseRecord('field_cues', $height['index'], $height['row']['key'], '3'); + $plate = cueRow($session, 'cues.bright-yellow.plate'); + $session->applyDatabaseRecord('field_cues', $plate['index'], $plate['row']['key'], 'Graphics/System/CuePlate.png'); + foreach (FieldPresentationCatalog::DIRECTIONS as $direction) { + $asset = cueRow($session, "cues.bright-yellow.pointer.{$direction}.asset"); + $session->applyDatabaseRecord('field_cues', $asset['index'], $asset['row']['key'], 'Graphics/System/CuePointer.png'); + } + $turns = cueRow($session, 'cues.bright-yellow.pointer.south.quarterTurns'); + $session->applyDatabaseRecord('field_cues', $turns['index'], $turns['row']['key'], '1'); + $session->saveDatabase('field_cues'); + $catalog = FieldPresentationCatalog::load($root . '/assets'); + + expect(file_get_contents($root . '/assets/Data/Presentation/field.php'))->toContain('// Colour selects replaceable artwork.') + ->and($catalog->cueStyle->bobHeight)->toBe(3)->and($catalog->cues['bright-yellow']['plate'])->toBe('Graphics/System/CuePlate.png') + ->and($catalog->cues['bright-yellow']['pointer']['south'])->toBe(['asset' => 'Graphics/System/CuePointer.png', 'quarterTurns' => 1]) + ->and(count($catalog->cues['bright-yellow']['pointer']))->toBe(8); +}); + +it('authors how see-through a mark turns under the player, with the Engine default and refusals', function () { + $root = makeCueProject(edges: false); + $session = EditorSession::open($root); + $opacity = cueRow($session, 'cueStyle.overlap.opacity'); + $seconds = cueRow($session, 'cueStyle.overlap.seconds'); + + expect($opacity['row']['value'])->toBe('0.4')->and($seconds['row']['value'])->toBe('0.2') + ->and(fn() => $session->applyDatabaseRecord('field_cues', $opacity['index'], $opacity['row']['key'], '1.5')) + ->toThrow('A cue overlap opacity must be 0..1.') + ->and(fn() => $session->applyDatabaseRecord('field_cues', $seconds['index'], $seconds['row']['key'], '3')) + ->toThrow('A cue overlap fade must last 0..2 seconds.'); + $session->applyDatabaseRecord('field_cues', $opacity['index'], $opacity['row']['key'], '0.25'); + $seconds = cueRow($session, 'cueStyle.overlap.seconds'); + $session->applyDatabaseRecord('field_cues', $seconds['index'], $seconds['row']['key'], '0.5'); + $session->saveDatabase('field_cues'); + $catalog = FieldPresentationCatalog::load($root . '/assets'); + + expect($catalog->cueStyle->overlapOpacity)->toBe(0.25)->and($catalog->cueStyle->overlapSeconds)->toBe(0.5); +}); + +it('authors an NPC cue on the page talking runs, stored on the NPC while its lines are its only page', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + $index = $session->createNpc('test-map', $session->readMap('test-map')['revision'], 2, 2, 'Halen', 'halen')['index']; + $rows = $session->readNpc('test-map', $index)['rows']; + $row = static fn(string $field): array => array_find($session->readNpc('test-map', $index)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); + + // One place to author it: the page's own rows, never a second NPC-level Cue group. + expect(array_column($rows, 'label'))->toContain('Page 1')->not->toContain('Cue') + ->and($row('variant0Cuecolor')['kind'])->toBe('options'); + $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $row('variant0Cuesymbol')['key'], '!'); + $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $row('variant0Cuecolor')['key'], 'bright-cyan'); + $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $row('variant0Cueconditions')['key'], '!event:halen_rescued'); + $session->saveMap('test-map'); + $npc = (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs'][$index]; + $cue = Ichiloto\Engine\Events\Triggers\EventCue::fromArray($npc['cue']); + + expect($cue->symbol)->toBe('!')->and($cue->color)->toBe('bright-cyan') + ->and($cue->conditions)->toBe([['type' => 'event', 'name' => 'halen_rescued', 'negate' => true]]) + ->and($npc['dialogue'])->toBe([['text' => 'Hello.']]); + + // A condition makes the lines a page list: the cue moves onto that page, the one the Engine reads. + $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $row('variant0Conditions')['key'], 'event:halen_found'); + $session->saveMap('test-map'); + $npc = (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs'][$index]; + + expect($npc)->not->toHaveKey('cue') + ->and($npc['dialogue'][0]['cue']['symbol'])->toBe('!') + ->and(Ichiloto\Engine\Field\NpcPages::fromEntry($npc)->pages[0]->cue?->color)->toBe('bright-cyan'); +}); + +it('reports an NPC cue the Engine would refuse, as it does an event cue', function () { + $root = makeTemporaryProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $file; + $data['npcs'] = [['id' => 'halen', 'name' => 'Halen', 'x' => 1, 'y' => 1, 'sprite' => '@', 'cue' => ['symbol' => '!!', 'color' => 'bright-yellow']]]; + file_put_contents($file, ' $issue->message, + (new Ichiloto\Editor\Validation\ProjectValidator())->validate(Ichiloto\Editor\ProjectWorkspace::fromProject($root))); + + expect(implode("\n", $messages))->toContain('does not occupy exactly one terminal cell'); +}); diff --git a/tests/Unit/FileSetTransactionTest.php b/tests/Unit/FileSetTransactionTest.php index 1fe93dfb..c3cd9602 100644 --- a/tests/Unit/FileSetTransactionTest.php +++ b/tests/Unit/FileSetTransactionTest.php @@ -59,6 +59,69 @@ function folderState(string $folder): array return $state; } +it('refuses a changed read dependency before creating or writing the destination', function () { + [$source, $dataPath] = transactionFolder(); + $destination = dirname($source) . '/copy'; + $loaded = (string) file_get_contents($dataPath); + $transaction = new FileSetTransaction($destination); + $transaction->expectSource($dataPath, $loaded); + $transaction->write($destination . '/copy.data.php', $loaded); + file_put_contents($dataPath, $loaded . "\n// Another author changed the source.\n"); + $before = folderState($source); + + expect(fn() => $transaction->commit())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(is_dir($destination))->toBeFalse() + ->and(folderState($source))->toBe($before); +}); + +it('only recovers a missing source when the caller explicitly permits it', function (bool $allowMissing) { + [$folder, $dataPath] = transactionFolder(); + $loaded = (string) file_get_contents($dataPath); + unlink($dataPath); + $transaction = new FileSetTransaction($folder); + $transaction->expectSource($dataPath, $loaded, $allowMissing); + $transaction->write($dataPath, $loaded); + if ($allowMissing) { + $transaction->commit(); + expect(file_get_contents($dataPath))->toBe($loaded); + } else { + expect(fn() => $transaction->commit())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(is_file($dataPath))->toBeFalse(); + } +})->with([false, true]); + +it('refuses source changes made after staging without leaving temporary files', function () { + [$source, $dataPath] = transactionFolder(); + $destination = dirname($source) . '/copy'; + $loaded = (string) file_get_contents($dataPath); + $transaction = new FileSetTransaction($destination); + $transaction->expectSource($dataPath, $loaded); + $transaction->write($destination . '/copy.data.php', $loaded); + $transaction->stage(); + file_put_contents($dataPath, $loaded . '// Concurrent external edit'); + $before = folderState($source); + expect(fn() => $transaction->commit())->toThrow(FileSetTransactionFailure::class, 'changed after opening') + ->and(is_dir($destination))->toBeFalse() + ->and(folderState($source))->toBe($before); +}); + +it('reserves read dependencies until the proposal has finished', function () { + [$source, $dataPath] = transactionFolder(); + $destination = dirname($source) . '/copy'; + $loaded = (string) file_get_contents($dataPath); + $transaction = new FileSetTransaction($destination); + $transaction->expectSource($dataPath, $loaded); + $transaction->write($destination . '/copy.data.php', $loaded); + $transaction->stage(); + $competing = new FileSetTransaction($source); + $competing->write($dataPath, $loaded . '// Concurrent proposal'); + expect(fn() => $competing->commit())->toThrow(FileSetTransactionFailure::class, 'Another file transaction'); + $transaction->rollBack(); + $competing->commit(); + expect(file_get_contents($dataPath))->toBe($loaded . '// Concurrent proposal') + ->and(is_dir($destination))->toBeFalse(); +}); + it('installs a new pair only when both files land, and leaves no folder when the second cannot', function () { [$folder, $dataPath, $partnerPath] = transactionFolder(withPair: false); $files = new FailingFileSetOperations(failures: ['move' => [$partnerPath]]); diff --git a/tests/Unit/IdentityFailClosedTest.php b/tests/Unit/IdentityFailClosedTest.php index 99d92510..de55732c 100644 --- a/tests/Unit/IdentityFailClosedTest.php +++ b/tests/Unit/IdentityFailClosedTest.php @@ -6,6 +6,11 @@ use Ichiloto\Editor\ProjectActorDatabase; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\Severity; +use Ichiloto\Engine\Entities\Enumerations\WeaponType; +use Ichiloto\Engine\Entities\Inventory\Armor; +use Ichiloto\Engine\Entities\Inventory\EquipmentSlotType; +use Ichiloto\Engine\Entities\Inventory\Items\Item; +use Ichiloto\Engine\Entities\Inventory\Weapons\Weapon; /** * Identity, when a project has got it wrong. @@ -30,20 +35,13 @@ function contestedInventoryProject(): string { $root = makeTemporaryProject('ichiloto-identity-'); - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' - new Item(id: 'gear.shared', name: 'Shared Item', description: 'x', icon: 'i', price: 1, aliases: ['Old Item Alias']), + 'weapons' => new Weapon(id: 'gear.shared', name: 'Shared Weapon', description: 'x', icon: '/', price: 1, equipmentType: WeaponType::SWORD, aliases: ['Old Weapon Alias']), + 'armors' => new Armor(id: 'gear.shared', name: 'Shared Armor', semanticSlot: EquipmentSlotType::BODY, description: 'x', icon: '[', price: 1, aliases: ['Old Armor Alias']), ]; - $sources = [ - 'items' => "new Item(id: 'gear.shared', name: 'Shared Item', description: 'x', icon: 'i', price: 1, aliases: ['Old Item Alias']),", - 'weapons' => "new Weapon(id: 'gear.shared', name: 'Shared Weapon', description: 'x', icon: '/', price: 1, equipmentType: WeaponType::SWORD, aliases: ['Old Weapon Alias']),", - 'armors' => "new Armor(id: 'gear.shared', name: 'Shared Armor', semanticSlot: EquipmentSlotType::BODY, description: 'x', icon: '[', price: 1, aliases: ['Old Armor Alias']),", - ]; - - foreach ($categories as $category) { - $entries[] = $sources[$category]; - } - - file_put_contents($root . '/assets/Data/items.php', sprintf(<<<'PHP' - $definitions[$category], $categories), + ); return $root; } +/** Where a claimant of the multi-claimant project is authored. */ +function multiClaimantSource(string $category): string +{ + return match ($category) { + 'items' => 'assets/Data/Items/0002-shared-item.php', + 'weapons' => 'assets/Data/Weapons/0001-shared-weapon.php', + default => 'assets/Data/Armors/0001-shared-armor.php', + }; +} + it('retains every claimant of a contested id with its category, aliases and source, and resolves none of them', function (array $categories) { $root = multiClaimantProject($categories); $workspace = ProjectWorkspace::fromProject($root); @@ -237,7 +225,7 @@ function multiClaimantProject(array $categories): string expect($claimant->name)->toBe('Shared ' . $noun) ->and($claimant->aliases)->toBe(['Old ' . $noun . ' Alias']) - ->and($claimant->source)->toBe(sprintf('assets/Data/items.php entry %d', $position + 2)); + ->and($claimant->source)->toBe(multiClaimantSource($claimant->category)); } // Nothing of any claimant resolves or is offered. @@ -300,7 +288,7 @@ function multiClaimantProject(array $categories): string foreach ($categories as $position => $category) { $noun = rtrim($category, 's'); - expect($message)->toContain(sprintf('Shared %s (%s; aliases: Old %s Alias; assets/Data/items.php entry %d)', ucfirst($noun), $noun, ucfirst($noun), $position + 2)); + expect($message)->toContain(sprintf('Shared %s (%s; aliases: Old %s Alias; %s)', ucfirst($noun), $noun, ucfirst($noun), multiClaimantSource($category))); } expect(implode("\n", $errors)) diff --git a/tests/Unit/IncomeSchedulesTest.php b/tests/Unit/IncomeSchedulesTest.php new file mode 100644 index 00000000..f1fe55af --- /dev/null +++ b/tests/Unit/IncomeSchedulesTest.php @@ -0,0 +1,217 @@ + The validator's findings about income, in report order. */ +function incomeIssueMessages(string $root): array +{ + $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)); + + return array_values(array_map( + static fn(Issue $issue): string => $issue->message, + array_filter($issues, static fn(Issue $issue): bool => $issue->where === 'assets/' . IncomeCatalog::FILE + || str_contains($issue->message, 'income schedule')), + )); +} + +function writeIncomeScript(string $root, string $scriptId, array $commands): void +{ + file_put_contents($root . '/assets/Events/' . $scriptId . '.php', 'toBe([]) + ->and(is_file(incomeFilePath($root)))->toBeFalse() + ->and(sourceHashTree($root . '/assets/Data'))->toBe($before); +}); + +it('creates the file the Engine reads on the first saved schedule, its id following its name', function (): void { + $root = makeTemporaryProject(); + + $database = loadRecordDatabase($root, 'income_schedules'); + $index = $database->addRecord(); + $database->setField($index, 'name', 'Guild Stipend'); + $database->setField($index, 'conditions', 'event:joined_guild'); + $database->setField($index, 'cadence', 'steps'); + $database->setField($index, 'interval', '50'); + $database->setField($index, 'amount', '30'); + $database->setField($index, 'delivery', 'hold'); + $database->save(); + + $catalog = IncomeCatalog::fromProject(incomeFilePath($root)); + $schedule = $catalog->findSchedule('guild-stipend'); + + expect(array_keys($catalog->getSchedules()))->toBe(['guild-stipend']) + ->and($schedule?->name)->toBe('Guild Stipend') + ->and($schedule?->cadence->value)->toBe('steps') + ->and($schedule?->interval)->toBe(50) + ->and($schedule?->amount)->toBe(30) + ->and($schedule?->delivery->value)->toBe('hold') + ->and($schedule?->conditions)->toBe([['type' => 'event', 'name' => 'joined_guild']]) + ->and(incomeIssueMessages($root))->toBe([]); +}); + +it('authors an ordered pay table the Engine applies first match first', function (): void { + $root = makeTemporaryProject(); + + $database = loadRecordDatabase($root, 'income_schedules'); + $index = $database->addRecord(); + $database->setField($index, 'name', 'Wage'); + $database->setField($index, 'amount', '100'); + foreach ([['variable:rank:==:A', '900'], ['variable:rank:==:B', '400']] as [$when, $pay]) { + $rate = $database->addSubItem($index); + $database->setField($index, sprintf('rate%dConditions', $rate), $when); + $database->setField($index, sprintf('rate%dAmount', $rate), $pay); + } + $database->save(); + + $rates = IncomeCatalog::fromProject(incomeFilePath($root))->findSchedule('wage')?->rates ?? []; + + expect(array_map(static fn($rate): int => $rate->amount, $rates))->toBe([900, 400]) + ->and($rates[0]->conditions)->toBe([['type' => 'variable', 'name' => 'rank', 'op' => '==', 'value' => 'A']]); +}); + +it('reads absent optional fields as the Engine defaults without writing them', function (): void { + $root = makeTemporaryProject(); + + $database = loadRecordDatabase($root, 'income_schedules'); + $index = $database->addRecord(); + $database->setField($index, 'name', 'Wage'); + $database->save(); + + $payload = require incomeFilePath($root); + $byField = array_column($database->getSettingsFields(0), 'value', 'field'); + + expect(array_key_exists('delivery', $payload['schedules'][0]))->toBeFalse() + ->and(array_key_exists('idleTimeout', $payload['schedules'][0]))->toBeFalse() + ->and($byField['delivery'])->toBe('deposit') + ->and($byField['idleTimeout'])->toBe('120'); +}); + +it('keeps a hand-authored file and its comments when one value changes', function (): void { + $root = makeTemporaryProject(); + file_put_contents(incomeFilePath($root), <<<'PHP' + [ + [ + 'id' => 'stipend', + 'name' => 'Stipend', + 'cadence' => 'active_time', + 'interval' => 3600, // one hour of play + 'amount' => 250, + ], + ], +]; +PHP); + + $database = loadRecordDatabase($root, 'income_schedules'); + $database->setField(0, 'amount', '300'); + $database->save(); + $source = (string) file_get_contents(incomeFilePath($root)); + + expect($source)->toContain('// The guild pays its members by the hour.') + ->and($source)->toContain('// one hour of play') + ->and(IncomeCatalog::fromProject(incomeFilePath($root))->findSchedule('stipend')?->amount)->toBe(300); +}); + +it('reports schedules the game would refuse, each one by name', function (): void { + $root = makeTemporaryProject(); + file_put_contents(incomeFilePath($root), ' [ + ['id' => 'walk', 'name' => 'Walk Pay', 'cadence' => 'steps', 'amount' => 5], + ['id' => 'calendar', 'name' => 'Calendar', 'cadence' => 'event', 'interval' => 7, 'amount' => 5], + ['id' => 'fine', 'name' => 'Fine', 'cadence' => 'battles', 'interval' => 2, 'amount' => 5], + ]], true) . ';'); + + expect(incomeIssueMessages($root))->toBe([ + 'The game cannot read an income schedule: Schedule [0] (walk) interval must be a positive whole number of steps.', + 'The game cannot read an income schedule: Schedule [1] (calendar) interval does not apply to the event cadence; payday pays it.', + ]); +}); + +it('offers schedules to the income commands by name and refuses one that does not exist', function (): void { + $root = makeTemporaryProject(); + file_put_contents(incomeFilePath($root), ' [ + ['id' => 'stipend', 'name' => 'Guild Stipend', 'cadence' => 'event', 'amount' => 5], + ]], true) . ';'); + writeIncomeScript($root, 'pay-desk', [ + ['type' => 'collect_income', 'schedule' => 'stipend', 'resultVariable' => 'collected'], + ['type' => 'payday', 'schedule' => 'bonus'], + ]); + + $workspace = ProjectWorkspace::fromProject($root); + $references = new ReferenceCatalog($workspace); + $variants = RecordSchemaCatalog::getRegisteredCommandVariants(); + $schedule = array_find($variants['payday'] ?? [], static fn($field): bool => $field->key === 'schedule'); + $messages = array_map(static fn(Issue $issue): string => $issue->message, new ProjectValidator()->validate($workspace)); + + expect($references->valuesFor('income_schedules'))->toBe(['stipend']) + ->and($references->labelsFor('income_schedules'))->toBe(['stipend' => 'Guild Stipend (stipend)']) + ->and($schedule?->reference)->toBe('income_schedules') + ->and(array_filter($messages, static fn(string $message): bool => str_contains($message, '"bonus"')))->not->toBe([]) + ->and(array_filter($messages, static fn(string $message): bool => str_contains($message, '"stipend"')))->toBe([]); +}); + +it('keeps an id a script names, refusing to rename it with the name or delete it', function (): void { + $root = makeTemporaryProject(); + file_put_contents(incomeFilePath($root), ' [ + ['id' => 'stipend', 'name' => 'Stipend', 'cadence' => 'event', 'amount' => 5], + ['id' => 'unused', 'name' => 'Unused', 'cadence' => 'event', 'amount' => 5], + ]], true) . ';'); + writeIncomeScript($root, 'pay-desk', [['type' => 'adjust_income', 'schedule' => 'stipend', 'amount' => -5]]); + + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('income_schedules'); + $database?->setField(0, 'name', 'Guild Stipend'); + $database?->setField(1, 'name', 'Spare Pay'); + + expect($database?->getRecordByIndex(0)?->get('id'))->toBe('stipend') + ->and($database?->getRecordByIndex(1)?->get('id'))->toBe('spare-pay') + ->and($database?->describeDeletionReferences(0))->toBe(['event script pay-desk']) + ->and($database?->describeDeletionReferences(1))->toBe([]); +}); + +it('offers only the fields a cadence uses and drops the rest when the cadence changes', function (): void { + $root = makeTemporaryProject(); + + $database = loadRecordDatabase($root, 'income_schedules'); + $index = $database->addRecord(); + $database->setField($index, 'name', 'Wage'); + $database->setField($index, 'idleTimeout', '45'); + $offered = static fn(): array => array_column($database->getSettingsFields($index), 'field'); + + expect($offered())->toContain('interval', 'idleTimeout'); + + $database->setField($index, 'cadence', 'steps'); + expect($offered())->toContain('interval')->not->toContain('idleTimeout') + ->and($database->getRecordByIndex($index)?->get('idleTimeout'))->toBeNull(); + + $database->setField($index, 'cadence', 'event'); + $database->save(); + + expect($offered())->not->toContain('interval') + ->and(IncomeCatalog::fromProject(incomeFilePath($root))->findSchedule('wage')?->interval)->toBeNull(); +}); diff --git a/tests/Unit/InnDescriptorAuthoringTest.php b/tests/Unit/InnDescriptorAuthoringTest.php new file mode 100644 index 00000000..a5808283 --- /dev/null +++ b/tests/Unit/InnDescriptorAuthoringTest.php @@ -0,0 +1,515 @@ + 10, 'lengthFrames' => 4, + 'stage' => ['canvas' => ['width' => 320, 'height' => 180], 'startFrame' => 0, 'restoreFrame' => 3, + 'camera' => [['id' => 'hold', 'frame' => 0, 'focus' => ['x' => 160, 'y' => 90], 'zoom' => 1, 'easing' => 'hold']], + 'subjects' => [['id' => 'guest', 'position' => ['x' => 160, 'y' => 150], 'size' => ['width' => 32, 'height' => 32]]]], + 'tracks' => [['id' => 'guest', 'type' => 'image', 'asset' => 'Graphics/Rest/guest.png', 'anchor' => 'stage', + 'placement' => ['subject' => 'guest'], 'pivot' => ['x' => .5, 'y' => 1], + 'keyframes' => [['frame' => 0, 'duration' => 4]]]], + ], true) . ';'); + } + $actor = (string) file_get_contents($root . '/assets/Data/Actors/Kaelion.php'); + file_put_contents($root . '/assets/Data/Actors/Guest.php', str_replace("'Kaelion'", "'guest-b'", $actor)); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace(" 'events' => [\n", " 'events' => [\n 'S' => [\n 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger',\n 'data' => ['cost' => 5, 'confirmDialogue' => ['text' => 'Rest?']],\n ],\n", (string) file_get_contents($data))); + return $root; +} + +function innDescriptorConfig(string $root, string $descriptor): string +{ + $source = " ['title' => 'Original'],\n 'untouched' => strtoupper('kept'),\n 'opaque' => new stdClass(),\n 'graphics' => ['inn' => ['presentation' => $descriptor]],\n];\n"; + file_put_contents($root . '/config.php', $source); + return $source; +} + +function innDescriptorMap(ProjectWorkspace $workspace): ProjectMap +{ + return array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); +} + +function innDescriptorRow(MapInspector $inspector, ProjectMap $map, callable $matches): array +{ + return array_find($inspector->buildEventDataFields('S', $map->getEventDefinition('S')), $matches) ?? []; +} + +function innDescriptorSessionRow(EditorSession $session, string $category, int $index, string $field, array $frame = []): array +{ + return array_find($session->readDatabaseRecord($category, $index, $frame)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException('Missing Inn row ' . $field); +} + +function innDescriptorCinematic(string $root, string $presentation = ''): string +{ + $folder = $root . '/assets/Cutscenes/Cinematics/rest-scene'; + mkdir($folder, 0777, true); + file_put_contents($folder . '/rest-scene.data.php', " 'rest-scene', 'name' => 'Rest', 'startMap' => 'test-map', 'skip' => ['policy' => 'forbidden']];\n"); + $file = $folder . '/rest-scene.script.php'; + file_put_contents($file, " 'sequence', 'commands' => [['type' => 'inn', 'confirmDialogue' => ['text' => 'Rest?'], 'cost' => 5$presentation]]]];\n"); + return $file; +} + +it('keeps each descriptor one config record and preserves source through edit save reopen and byte-restoring undo', function (string $descriptor, string $fieldId, array $expected) { + $root = innDescriptorProject(); + $source = innDescriptorConfig($root, $descriptor); + $hashes = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + expect(array_filter($db->getEntryLabels(), static fn(string $path): bool => str_starts_with($path, ProjectConfig::INN_PRESENTATION)))->toHaveCount(1); + $row = array_find($db->getSettingsFields($index), static fn(array $row): bool => ($row['field'] ?? null) === $fieldId); + expect($row['reference'])->toBe('stage_timelines')->and($row)->not->toHaveKey('control'); + $change = new RecordAuthoring()->applyField($db, $index, [], $fieldId, 'rest-b', 'Stage'); + $change->command->undo(); + expect($workspace->config->getSource())->toBe($source)->and(sourceHashTree($root))->toBe($hashes); + $change->command->execute(); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toContain('// selected shot', "strtoupper('kept')", 'new stdClass()'); + $reopened = ProjectWorkspace::fromProject($root, graphical: true); + expect($reopened->config->getRecord(ProjectConfig::INN_PRESENTATION)->get('value'))->toBe($expected); + $change->command->undo(); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toBe($source)->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'leader' => ["['treatment' => 'leader', 'leaders' => [\n // selected shot\n 'Kaelion' => 'rest-a',\n ]]", 'innLeader0Timeline', ['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-b']]], + 'party' => ["['treatment' => 'party', 'parties' => [\n // selected shot\n ['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-a'],\n ]]", 'innParty0Timeline', ['treatment' => 'party', 'parties' => [['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-b']]]], +]); + +it('creates bindings with stable constrained pickers not invented ids and refuses incomplete saves or mixed treatment before mutation', function (string $treatment) { + $root = innDescriptorProject(); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $before = sourceHashTree($root); + $author = new RecordAuthoring(); + $author->applyField($db, $index, [], 'value.treatment', $treatment, 'Treatment'); + $prefix = $treatment === 'leader' ? 'innLeader' : 'innParty'; + $add = $author->addItem($db, $index, [], $prefix . 'List'); + expect($add->command)->not->toBeNull() + ->and(fn() => $workspace->config->save())->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($before); + $actorField = $treatment === 'leader' ? $prefix . '0Actor' : $prefix . '0Actors'; + $actorRow = array_find($db->getSettingsFields($index), static fn(array $row): bool => ($row['field'] ?? null) === $actorField); + expect($actorRow['reference'])->toBe('actor_ids')->and($actorRow)->not->toHaveKey('control'); + $snapshot = $db->getRecordByIndex($index)->toArray(); + expect(fn() => $author->applyField($db, $index, [], $actorField, 'missing-actor', 'Guest'))->toThrow(RecordRefusal::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($snapshot); + $author->applyField($db, $index, [], $actorField, $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b', 'Guests'); + $stage = $author->applyField($db, $index, [], $prefix . '0Timeline', 'rest-a', 'Stage'); + $snapshot = $db->getRecordByIndex($index)->toArray(); + expect(fn() => $author->applyField($db, $index, [], 'value.treatment', $treatment === 'leader' ? 'party' : 'leader', 'Treatment'))->toThrow(RecordRefusal::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($snapshot); + $workspace->config->save(); + expect(ProjectWorkspace::fromProject($root)->config->getRecord(ProjectConfig::INN_PRESENTATION)->get('value')['treatment'])->toBe($treatment); + $stage->command->undo(); + expect(fn() => $workspace->config->save())->toThrow(InvalidArgumentException::class); + $stage->command->execute(); + expect($db->getRecordByIndex($index)->toArray())->toBe($snapshot); +})->with(['leader', 'party']); + +it('refuses expression edits and unknown stages before record mutation or history creation', function () { + $root = innDescriptorProject(); + $source = innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => ['Kaelion' => strtolower('REST-A')]]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $before = $db->getRecordByIndex($index)->toArray(); + $version = $db->getContentVersion(); + expect(fn() => new RecordAuthoring()->applyField($db, $index, [], 'innLeader0Timeline', 'rest-b', 'Stage'))->toThrow(RecordRefusal::class) + ->and(fn() => $db->setField($index, 'innLeader0Timeline', 'missing-stage'))->toThrow(InvalidArgumentException::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($before) + ->and($db->getContentVersion())->toBe($version) + ->and($workspace->config->isDirty())->toBeFalse() + ->and(file_get_contents($root . '/config.php'))->toBe($source); +}); + +it('preserves graphical descriptors in Terminal while retaining scalar config and Sleep workflow', function () { + $root = innDescriptorProject(); + innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a']]"); + $workspace = ProjectWorkspace::fromProject($root); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $fields = $db->getSettingsFields($index); + expect($fields)->toHaveCount(2)->and($fields[1]['editable'])->toBeFalse(); + $old = $db->getRecordByIndex($index)->toArray(); + $db->setField($index, 'value', 'rest-b'); + expect($db->getRecordByIndex($index)->toArray())->toBe($old); + $map = innDescriptorMap($workspace); + $map->setEventField('S', ['data', 'presentation'], $old['value']); + $tui = new MapInspector(new ReferenceCatalog($workspace)); + $row = innDescriptorRow($tui, $map, static fn(array $row): bool => ($row['innPresentation'] ?? false) === true); + expect($row['editable'])->toBeFalse() + ->and(fn() => $tui->apply($map, $row, 'rest-b'))->toThrow(InspectorRefusal::class); + $map->setEventField('S', ['data', 'cost'], 7); + $map->save(); + expect(ProjectWorkspace::fromProject($root)->maps[0]->data)->not->toBeNull(); +}); + +it('authors Sleep leader and exact-party bindings with undo redo save reopen and refusal without mutation', function (string $treatment) { + $root = innDescriptorProject(); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $map = innDescriptorMap($workspace); + $before = sourceHashTree($root); + $inspector = new MapInspector(new ReferenceCatalog($workspace), graphical: true); + $treatmentRow = innDescriptorRow($inspector, $map, static fn(array $row): bool => ($row['path'] ?? null) === ['data', 'presentation', 'treatment']); + $mode = $inspector->apply($map, $treatmentRow, $treatment); + $heading = innDescriptorRow($inspector, $map, static fn(array $row): bool => isset($row['innPresentationList']) && ($row['innPresentationList']['index'] ?? null) === -1); + $added = $inspector->addListEntry($map, $heading); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class)->and(sourceHashTree($root))->toBe($before); + $actorRow = innDescriptorRow($inspector, $map, static fn(array $row): bool => ($row['reference'] ?? null) === 'actor_ids'); + $inspector->apply($map, $actorRow, $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b'); + $stageRow = innDescriptorRow($inspector, $map, static fn(array $row): bool => isset($row['innEntryField']) && ($row['reference'] ?? null) === 'stage_timelines'); + $stage = $inspector->apply($map, $stageRow, 'rest-a'); + $snapshot = $map->getEventDefinition('S'); + expect(fn() => $inspector->apply($map, $stageRow, 'absent-stage'))->toThrow(InspectorRefusal::class) + ->and($map->getEventDefinition('S'))->toBe($snapshot); + $stage->undo(); + $stage->execute(); + $map->save(); + expect(innDescriptorMap(ProjectWorkspace::fromProject($root))->getEventDefinition('S'))->toBe($snapshot); + $removed = $inspector->removeListEntry($map, $stageRow); + $removed->command->undo(); + expect($map->getEventDefinition('S'))->toBe($snapshot); + $removed->command->execute(); + $added->command->undo(); + $mode->undo(); + $map->save(); + expect(sourceHashTree($root))->toBe($before); +})->with(['leader', 'party']); + +it('authors nested registered inn descriptors and retains Terminal command scalar compatibility', function (string $treatment) { + $root = innDescriptorProject(); + file_put_contents($root . '/assets/Events/rest.php', " 'if', 'condition' => ['switch' => 'late'], 'then' => [['type' => 'inn', 'cost' => 5]]]];\n"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('common_events'); + $index = array_search('rest', $db->getEntryLabels(), true); + $author = new RecordAuthoring(); + $frame = [0, 'then']; + $author->applyField($db, $index, $frame, 'command0Presentationtreatment', $treatment, 'Treatment'); + $beforeAdd = $db->getFrameCommands($index, $frame); + $added = $author->addNestedItem($db, $index, $frame, 0); + expect($added->command)->not->toBeNull()->and(fn() => $db->save())->toThrow(InvalidArgumentException::class); + $draft = $db->getFrameCommands($index, $frame); + $added->command->undo(); + expect($db->getFrameCommands($index, $frame))->toBe($beforeAdd); + $added->command->execute(); + expect($db->getFrameCommands($index, $frame))->toBe($draft); + $prefix = $treatment === 'leader' ? 'command0InnLeader0' : 'command0InnParty0'; + $author->applyField($db, $index, $frame, $prefix . ($treatment === 'leader' ? 'Actor' : 'Actors'), $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b', 'Guests'); + $change = $author->applyField($db, $index, $frame, $prefix . 'Timeline', 'rest-a', 'Stage'); + $expected = $db->getFrameCommands($index, $frame)[0]['presentation']; + $db->save(); + $change->command->undo(); + $change->command->execute(); + expect(ProjectWorkspace::fromProject($root)->getRecordDatabase('common_events')->getFrameCommands($index, $frame)[0]['presentation'])->toBe($expected) + ->and(file_get_contents($root . '/assets/Events/rest.php'))->toContain('// keep script annotation'); + $tui = ProjectWorkspace::fromProject($root)->getRecordDatabase('common_events'); + $row = array_find($tui->getFrameSettingsFields($index, $frame), static fn(array $row): bool => ($row['field'] ?? null) === 'command0Presentation'); + expect($row['editable'])->toBeFalse(); +})->with(['leader', 'party']); + +it('traverses every bound stage independent of the live party and reports malformed descriptors without guessing fallback', function () { + $root = innDescriptorProject(); + innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a', 'guest-b' => 'missing-rest']]"); + file_put_contents($root . '/assets/Events/rest.php', ' 'if', 'then' => [['type' => 'inn', 'presentation' => ['treatment' => 'party', 'parties' => [ + ['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-b'], + ]]]]], + ], true) . ';'); + $workspace = ProjectWorkspace::fromProject($root); + $issues = []; + $uses = EffectValidator::findUses($workspace, $issues); + expect($uses['rest-a']['stage'])->toBe(['config.php: ' . ProjectConfig::INN_PRESENTATION]) + ->and($uses['missing-rest']['stage'])->toBe(['config.php: ' . ProjectConfig::INN_PRESENTATION]) + ->and($uses['rest-b']['stage'])->toBe(['event script rest']) + ->and(array_filter(new EffectValidator()->validate($workspace), static fn($issue): bool => str_contains($issue->message, 'missing-rest')))->toHaveCount(1); + $workspace->config->getRecord(ProjectConfig::INN_PRESENTATION)->set('value', ['leaders' => ['Kaelion' => 'rest-a']]); + $issues = []; + $uses = EffectValidator::findUses($workspace, $issues); + expect($uses)->not->toHaveKey('rest-a') + ->and(array_filter($issues, static fn($issue): bool => str_contains($issue->message, 'rest presentation is invalid')))->toHaveCount(1); +}); + +it('rejects mixed bindings duplicate parties and unknown keys through the authoritative descriptor', function (array $value) { + expect(fn() => InnPresentationFields::assertValid($value, complete: false))->toThrow(InvalidArgumentException::class); +})->with([ + [['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a'], 'parties' => [['actors' => ['guest-b'], 'timeline' => 'rest-b']]]], + [['treatment' => 'party', 'parties' => [['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-a'], ['actors' => ['guest-b', 'Kaelion'], 'timeline' => 'rest-b']]]], + [['treatment' => 'leader', 'leaders' => [], 'fallback' => 'rest-a']], +]); + +it('renames a selected leader identity in place without dropping its source annotation or expression value', function () { + $root = innDescriptorProject(); + $source = innDescriptorConfig($root, "['treatment' => 'leader', 'leaders' => [\n // selected shot\n 'Kaelion' => strtolower('REST-A'),\n ]]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $db->getEntryLabels(), true); + $change = new RecordAuthoring()->applyField($db, $index, [], 'innLeader0Actor', 'guest-b', 'Leader'); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toContain('// selected shot', "'guest-b' => strtolower('REST-A')") + ->and(ProjectWorkspace::fromProject($root)->config->getRecord(ProjectConfig::INN_PRESENTATION)->get('value')['leaders'])->toBe(['guest-b' => 'rest-a']); + $change->command->undo(); + $workspace->config->save(); + expect(file_get_contents($root . '/config.php'))->toBe($source); +}); + +it('exposes constrained Inn controls through the graphical session with history and disk round trips', function () { + $root = innDescriptorProject(); + $session = EditorSession::open($root); + $index = array_search(ProjectConfig::INN_PRESENTATION, ProjectWorkspace::fromProject($root)->getRecordDatabase('configuration')->getEntryLabels(), true); + $row = innDescriptorSessionRow($session, 'configuration', $index, 'value.treatment'); + expect($row['kind'])->toBe('options'); + $session->applyDatabaseRecord('configuration', $index, $row['key'], 'party'); + $session->addDatabaseItem('configuration', $index, innDescriptorSessionRow($session, 'configuration', $index, 'innPartyList')['key']); + $actors = innDescriptorSessionRow($session, 'configuration', $index, 'innParty0Actors'); + expect($actors)->toMatchArray(['kind' => 'reference', 'reference' => 'actor_ids', 'multi' => true]) + ->and(array_column($session->listReferences(null, 'actor_ids'), 'value'))->toContain('Kaelion', 'guest-b'); + $session->applyDatabaseRecord('configuration', $index, $actors['key'], 'Kaelion, guest-b'); + $stage = innDescriptorSessionRow($session, 'configuration', $index, 'innParty0Timeline'); + expect($stage)->toMatchArray(['kind' => 'reference', 'reference' => 'stage_timelines']); + $session->applyDatabaseRecord('configuration', $index, $stage['key'], 'rest-b'); + $session->undo(); + expect(innDescriptorSessionRow($session, 'configuration', $index, 'innParty0Timeline')['value'])->toBe(''); + $session->redo(); + expect(fn() => $session->applyDatabaseRecord('configuration', $index, $stage['key'], 'missing-stage'))->toThrow(SessionRefusal::class); + $session->saveDatabase('configuration'); + $reopenedIndex = array_search(ProjectConfig::INN_PRESENTATION, ProjectWorkspace::fromProject($root)->getRecordDatabase('configuration')->getEntryLabels(), true); + expect(innDescriptorSessionRow(EditorSession::open($root), 'configuration', $reopenedIndex, 'innParty0Timeline')['value'])->toBe('rest-b'); +}); + +it('keeps cinematic owned-list drafts editable and reference-constrained across refresh then saves reopens and undoes', function () { + $root = innDescriptorProject(); + $file = innDescriptorCinematic($root); + $before = file_get_contents($file); + $session = EditorSession::open($root); + $frame = [CutsceneAsset::COMMANDS_KEY, 0, 'commands']; + $row = static fn(string $field): array => innDescriptorSessionRow($session, 'cutscenes/cinematic', 0, $field, $frame); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0Presentationtreatment')['key'], 'party'); + expect($session->addDatabaseItem('cutscenes/cinematic', 0, $row('command0Cost')['key'], child: true)['changed'])->toBeTrue(); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class) + ->and(file_get_contents($file))->toBe($before); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0InnParty0Actors')['key'], 'Kaelion, guest-b'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0InnParty0Timeline')['key'], 'rest-a'); + $snapshot = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row('command0InnParty0Actors')['key'], 'unknown-actor'))->toThrow(SessionRefusal::class) + ->and($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame))->toBe($snapshot); + $session->saveDatabase('cutscenes/cinematic'); + expect(innDescriptorSessionRow(EditorSession::open($root), 'cutscenes/cinematic', 0, 'command0InnParty0Timeline', $frame)['value'])->toBe('rest-a') + ->and(file_get_contents($file))->toContain('// preserve cinematic annotation'); + $session->undo(); + $session->redo(); + expect($row('command0InnParty0Timeline')['value'])->toBe('rest-a'); +}); + +it('keeps NPC commands graphical only while their sole map owner supplies save and history', function () { + $root = innDescriptorProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $file; + $data['npcs'] = [['id' => 'keeper', 'name' => 'Keeper', 'x' => 1, 'y' => 1, 'sprite' => 'K', + 'script' => [['type' => 'inn', 'cost' => 5, 'presentation' => ['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a']]]]]]; + file_put_contents($file, 'getFields(0, $frame), static fn(array $row): bool => ($row['field'] ?? '') === 'command0Presentation'); + expect($tuiRow['editable'])->toBeFalse(); + $session = EditorSession::open($root); + $read = $session->readNpc('test-map', 0, $frame); + $row = array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === 'command0InnLeader0Timeline'); + expect($row['reference'])->toBe('stage_timelines'); + $session->applyNpc('test-map', $read['revision'], 0, $row['key'], 'rest-b'); + $session->undo(); + $session->redo(); + $session->saveMap('test-map'); + $reopened = $session->readNpc('test-map', 0, $frame); + expect(array_find(EditorSession::open($root)->readNpc('test-map', 0, $frame)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === 'command0InnLeader0Timeline')['value'])->toBe('rest-b'); + expect(fn() => $session->applyNpc('test-map', $reopened['revision'], 0, $row['key'], 'missing-stage'))->toThrow(SessionRefusal::class); +}); + +it('preserves untouched graphical references while a Terminal cinematic edits gameplay cost', function (bool $saveAll) { + $root = innDescriptorProject(); + $file = innDescriptorCinematic($root, ", 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'rest-a']]"); + $before = sourceHashTree($root); + $workspace = ProjectWorkspace::fromProject($root); + $library = $workspace->cutscenes; + $frame = [CutsceneAsset::COMMANDS_KEY, 0, 'commands']; + $change = $library->changeAsset(CutsceneType::CINEMATIC, 0, 'Cost', static fn($db, $index) => new RecordAuthoring()->applyField($db, $index, $frame, 'command0Cost', '7', 'Cost')); + if ($saveAll) { + expect($library->saveAll())->toBe(['saved' => ['cinematic rest-scene'], 'failed' => []]); + } else { + expect($library->save(CutsceneType::CINEMATIC, 'rest-scene'))->toBeTrue(); + } + expect((require $file)[0]['commands'][0])->toMatchArray(['cost' => 7, 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'rest-a']]]) + ->and(ProjectWorkspace::fromProject($root)->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene')->payload()['commands'][0]['commands'][0]['cost'])->toBe(7) + ->and(file_get_contents($file))->toContain('// preserve cinematic annotation'); + $change['command']->undo(); + $library->save(CutsceneType::CINEMATIC, 'rest-scene'); + expect(sourceHashTree($root))->toBe($before); +})->with(['single save' => [false], 'save all' => [true]]); + +it('refuses incomplete or invalid newly authored bindings at the direct cutscene owner before staging or backup', function (array $presentation, bool $withReferences) { + $root = innDescriptorProject(); + innDescriptorCinematic($root); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $asset = $workspace->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene'); + $before = sourceHashTree($root); + $state = $asset->captureEditState(); + $payload = $asset->payload(); + $payload['commands'][0]['commands'][0]['presentation'] = $presentation; + $asset->apply($payload); + $edited = $asset->captureEditState(); + $backedUp = false; + $backup = static function () use (&$backedUp): void { $backedUp = true; }; + $references = $withReferences ? new ReferenceCatalog($workspace) : null; + expect(fn() => $asset->save($backup, $references))->toThrow(InvalidArgumentException::class) + ->and($backedUp)->toBeFalse() + ->and($asset->captureEditState())->toBe($edited) + ->and($asset->isDirty())->toBeTrue() + ->and(sourceHashTree($root))->toBe($before); + $asset->restoreEditState($state); + expect($asset->save($backup, $references))->toBeFalse()->and(sourceHashTree($root))->toBe($before); +})->with([ + 'missing treatment without catalog' => [['leaders' => ['Kaelion' => 'rest-a']], false], + 'blank leader without catalog' => [['treatment' => 'leader', 'leaders' => ['' => '']], false], + 'blank party without catalog' => [['treatment' => 'party', 'parties' => [['actors' => [], 'timeline' => '']]], false], + 'unknown actor with live catalog' => [['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'rest-a']], true], + 'unknown stage with live catalog' => [['treatment' => 'party', 'parties' => [['actors' => ['Kaelion'], 'timeline' => 'unknown-stage']]], true], +]); + +it('composes route draft refusal with unchanged Inn references at the paired owner and restores authored source after save', function (array $draft) { + $root = innDescriptorProject(); + $file = innDescriptorCinematic($root, ", 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => strtolower('REST-A')]]"); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $asset = $workspace->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene'); + $state = $asset->captureEditState(); + $before = sourceHashTree($root); + $payload = $asset->payload(); + $route = ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.3]; + $payload['commands'][0]['commands'][] = [...$route, ...$draft]; + $asset->apply($payload); + $pending = $asset->captureEditState(); + $backedUp = false; + $backup = static function () use (&$backedUp): void { $backedUp = true; }; + $references = new ReferenceCatalog($workspace); + expect(fn() => $asset->save($backup, $references))->toThrow(InvalidArgumentException::class) + ->and($backedUp)->toBeFalse() + ->and($asset->captureEditState())->toBe($pending) + ->and(sourceHashTree($root))->toBe($before); + $complete = [...$route, 'waypoints' => [['x' => 0], ['y' => 0]]]; + $payload['commands'][0]['commands'][1] = $complete; + $asset->apply($payload); + expect($asset->save(references: $references))->toBeTrue() + ->and((require $file)[0]['commands'][1])->toBe($complete) + ->and(file_get_contents($file))->toContain('// preserve cinematic annotation', "strtolower('REST-A')") + ->and(ProjectWorkspace::fromProject($root)->cutscenes->find(CutsceneType::CINEMATIC, 'rest-scene')->payload()['commands'][0]['commands'][1])->toBe($complete); + $asset->restoreEditState($state); + $asset->save(references: $references); + expect(sourceHashTree($root))->toBe($before); +})->with([ + 'no waypoints' => [['waypoints' => []]], + 'no authored axes' => [['waypoints' => [[]]]], + 'no retrace reference' => [['retrace' => '']], +]); + +it('refuses unsupported nested binding source changes before mutation while preserving independent authored expressions', function () { + $root = innDescriptorProject(); + $file = $root . '/assets/Events/rest.php'; + $source = " 'inn', 'confirmDialogue' => ['text' => 'Rest?'], 'cost' => 5, 'presentation' => ['treatment' => 'leader', 'leaders' => ['Kaelion' => strtolower('REST-A')]]]];\n"; + file_put_contents($file, $source); + $workspace = ProjectWorkspace::fromProject($root, graphical: true); + $db = $workspace->getRecordDatabase('common_events'); + $index = array_search('rest', $db->getEntryLabels(), true); + $before = $db->getRecordByIndex($index)->toArray(); + $version = $db->getContentVersion(); + expect(fn() => new RecordAuthoring()->applyField($db, $index, [], 'command0InnLeader0Timeline', 'rest-b', 'Stage'))->toThrow(RecordRefusal::class) + ->and($db->getRecordByIndex($index)->toArray())->toBe($before) + ->and($db->getContentVersion())->toBe($version) + ->and(file_get_contents($file))->toBe($source); + new RecordAuthoring()->applyField($db, $index, [], 'command0Cost', '7', 'Cost'); + $db->save(); + expect(file_get_contents($file))->toContain('// binding comment', "strtolower('REST-A')") + ->and((require $file)[0]['cost'])->toBe(7); +}); + +it('validates Sleep cinematic and NPC bindings including actors and all timelines without party-dependent selection', function () { + $root = innDescriptorProject(); + innDescriptorCinematic($root, ", 'presentation' => ['treatment' => 'party', 'parties' => [['actors' => ['Kaelion', 'guest-b'], 'timeline' => 'rest-b']]]"); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $file; + $data['events']['S']['data']['presentation'] = ['treatment' => 'leader', 'leaders' => ['Kaelion' => 'rest-a']]; + $data['npcs'] = [['id' => 'keeper', 'name' => 'Keeper', 'x' => 1, 'y' => 1, 'sprite' => 'K', + 'script' => [['type' => 'if', 'then' => [['type' => 'inn', 'presentation' => ['treatment' => 'leader', 'leaders' => ['unknown-actor' => 'missing-rest']]]]]]]]; + file_put_contents($file, 'toBe(['map test-map event S']) + ->and($uses['rest-b']['stage'])->toBe(['cinematic rest-scene']) + ->and($uses['missing-rest']['stage'])->toBe(['map test-map NPC keeper']) + ->and(array_filter($issues, static fn($issue): bool => str_contains($issue->message, 'unknown actor identity unknown-actor')))->toHaveCount(1); +}); + +it('authors inline map command bindings through the same controls while Terminal preserves them read-only', function (string $treatment) { + $root = innDescriptorProject(); + $file = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $file; + $data['events']['I'] = ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptedEventTrigger', 'data' => [ + 'commands' => [['type' => 'if', 'then' => [['type' => 'inn', 'confirmDialogue' => ['text' => 'Rest?'], 'cost' => 5]]]], + ]]; + file_put_contents($file, " $inspector->buildEventDataFields('I', $map->getEventDefinition('I')); + $path = ['data', 'commands', '0', 'then', '0', 'presentation']; + $mode = $inspector->apply($map, array_find($rows(), static fn(array $row): bool => ($row['path'] ?? []) === [...$path, 'treatment']), $treatment); + $heading = array_find($rows(), static fn(array $row): bool => ($row['innPresentationList']['index'] ?? null) === -1); + $added = $inspector->addListEntry($map, $heading); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class)->and(file_get_contents($file))->toBe($before); + $actors = array_find($rows(), static fn(array $row): bool => ($row['reference'] ?? null) === 'actor_ids'); + $inspector->apply($map, $actors, $treatment === 'leader' ? 'Kaelion' : 'Kaelion, guest-b'); + $stageRow = array_find($rows(), static fn(array $row): bool => isset($row['innEntryField']) && ($row['reference'] ?? null) === 'stage_timelines'); + $stage = $inspector->apply($map, $stageRow, 'rest-a'); + $snapshot = $map->getEventDefinition('I'); + expect(fn() => $inspector->apply($map, $stageRow, 'missing-stage'))->toThrow(InspectorRefusal::class) + ->and($map->getEventDefinition('I'))->toBe($snapshot); + $stage->undo(); + $stage->execute(); + $map->save(); + $reopened = ProjectWorkspace::fromProject($root); + $tui = new MapInspector(new ReferenceCatalog($reopened)); + $reopenedMap = innDescriptorMap($reopened); + $terminalRows = $tui->buildEventDataFields('I', $reopenedMap->getEventDefinition('I')); + $descriptorRow = array_find($terminalRows, static fn(array $row): bool => ($row['path'] ?? []) === $path); + expect($descriptorRow['editable'])->toBeFalse() + ->and(array_any($terminalRows, static fn(array $row): bool => ($row['reference'] ?? '') === 'actor_ids'))->toBeFalse() + ->and($reopenedMap->getEventDefinition('I'))->toBe($snapshot) + ->and(file_get_contents($file))->toContain('// preserve inline event'); +})->with(['leader', 'party']); diff --git a/tests/Unit/InnStagePresentationTest.php b/tests/Unit/InnStagePresentationTest.php new file mode 100644 index 00000000..18a2b7fe --- /dev/null +++ b/tests/Unit/InnStagePresentationTest.php @@ -0,0 +1,141 @@ + 12, 'lengthFrames' => 6, 'restFrame' => 2, + 'stage' => ['canvas' => ['width' => 320, 'height' => 180], 'startFrame' => 0, 'restoreFrame' => 5, + 'camera' => [['id' => 'hold', 'frame' => 0, 'focus' => ['x' => 160, 'y' => 90], 'zoom' => 1, 'easing' => 'hold']], + 'subjects' => [['id' => 'sleeper', 'position' => ['x' => 160, 'y' => 150], 'size' => ['width' => 64, 'height' => 96]]]], + 'tracks' => [['id' => 'sleeper', 'type' => 'image', 'asset' => 'Graphics/Rest/sleeper.png', 'sheet' => ['columns' => 2, 'rows' => 1], + 'anchor' => 'stage', 'placement' => ['subject' => 'sleeper'], 'pivot' => ['x' => .5, 'y' => 1], + 'keyframes' => [['frame' => 0, 'duration' => 3], ['frame' => 3, 'duration' => 2, 'sourceFrame' => 1]]]], + ], true) . ';'); + @mkdir($root . '/assets/Animations/flicker', 0o777, true); + file_put_contents($root . '/assets/Animations/flicker/flicker.timeline.php', ' 10, 'lengthFrames' => 4, + 'tracks' => [['id' => 'glyph', 'type' => 'glyph', 'keyframes' => [['frame' => 0, 'duration' => 4, 'content' => '*']]]], + ], true) . ';'); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace(" 'events' => [\n", " 'events' => [\n 'S' => [\n 'class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger',\n 'data' => ['confirmDialogue' => ['name' => '', 'text' => 'Rest here?'], 'cost' => 5],\n ],\n", + (string) file_get_contents($data))); + + return $root; +} + +function innBedMap(ProjectWorkspace $workspace): ProjectMap +{ + return array_find($workspace->maps, static fn(ProjectMap $map): bool => $map->mapId === 'test-map'); +} + +/** @return array The bed's data row at a path. */ +function innBedField(MapInspector $inspector, ProjectMap $map, array $path): array +{ + return array_find($inspector->buildEventDataFields('S', $map->getEventDefinition('S') ?? []), + static fn(array $field): bool => ($field['path'] ?? null) === $path) ?? []; +} + +it('offers only the timelines the Engine admits as a stage', function () { + $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject(innStageProject())); + + expect($catalog->valuesFor('stage_timelines'))->toBe(['quiet-night']) + ->and($catalog->valuesFor('effects'))->toBe(['flicker', 'quiet-night']); +}); + +it('offers a Sleep event\'s rest music and stage unset, writes one only when chosen, and removes it when cleared', function () { + $root = innStageProject(); + $workspace = ProjectWorkspace::fromProject($root); + $map = innBedMap($workspace); + $inspector = new MapInspector(new ReferenceCatalog($workspace)); + + $stage = innBedField($inspector, $map, ['data', 'presentation']); + expect($stage)->toMatchArray(['reference' => 'stage_timelines', 'optional' => true, 'value' => '']) + ->and(innBedField($inspector, $map, ['data', 'bgm']))->toMatchArray(['reference' => 'bgm', 'optional' => true]) + // Offered, not stored: the event holds only what was authored. + ->and(array_keys($map->getEventDefinition('S')['data']))->toBe(['confirmDialogue', 'cost']); + + $chosen = $inspector->apply($map, $stage, 'quiet-night'); + expect($map->getEventDefinition('S')['data']['presentation'])->toBe('quiet-night'); + $chosen->undo(); + expect($map->getEventDefinition('S')['data'])->not->toHaveKey('presentation'); + $chosen->execute(); + + $cleared = $inspector->apply($map, innBedField($inspector, $map, ['data', 'presentation']), ''); + expect($map->getEventDefinition('S')['data'])->not->toHaveKey('presentation'); + $cleared->undo(); + expect($map->getEventDefinition('S')['data']['presentation'])->toBe('quiet-night'); + $cleared->execute(); + // Clearing what is already unset changes nothing. + expect($inspector->apply($map, innBedField($inspector, $map, ['data', 'presentation']), ''))->toBeNull(); + + $map->save(); + expect((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['S']['data']) + ->toBe(['confirmDialogue' => ['name' => '', 'text' => 'Rest here?'], 'cost' => 5]); +}); + +it('chooses the project\'s default rest stage in Configuration, writing nothing when left unset', function () { + $root = innStageProject(); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search(ProjectConfig::INN_PRESENTATION, $database->getEntryLabels(), true); + $value = array_find($database->getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'value'); + $before = (string) file_get_contents($root . '/config.php'); + + expect($value['reference'] ?? null)->toBe('stage_timelines'); + $database->setField($index, 'value', 'None'); + expect($workspace->config->isDirty())->toBeFalse(); + + $database->setField($index, 'value', 'quiet-night'); + $workspace->config->save(); + expect((require $root . '/config.php')['graphics']['inn']['presentation'])->toBe('quiet-night') + ->and(fn() => $database->setField($index, 'value', 'Not An Id'))->toThrow(InvalidArgumentException::class); + expect(str_starts_with((string) file_get_contents($root . '/config.php'), substr($before, 0, 20)))->toBeTrue(); +}); + +it('reports a rest stage the Engine would not admit, wherever it is named', function () { + $root = innStageProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'cost' => 5]", "'cost' => 5, 'presentation' => 'flicker']", (string) file_get_contents($data))); + file_put_contents($root . '/assets/Events/inn-keeper.php', " 'if', 'condition' => ['switch' => 'late'], 'then' => [ + ['type' => 'inn', 'confirmDialogue' => ['text' => 'A bed?'], 'presentation' => 'flicker'], + ]], + ], true) . ";\n"); + $stages = static fn(): array => array_values(array_map(static fn(Issue $issue): string => $issue->where . ': ' . $issue->message . ' ' . $issue->hint, + array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => str_contains($issue->message, 'as a stage')))); + + // One report per timeline, naming its first use and counting the nested inn command's. + expect($stages())->toHaveCount(1) + ->and($stages()[0])->toStartWith('map test-map event S: Effect flicker cannot be played as a stage') + ->toContain('Also used by 1 more.'); + + // An admitted stage named anywhere is not reported. + file_put_contents($data, str_replace("'presentation' => 'flicker'", "'presentation' => 'quiet-night'", (string) file_get_contents($data))); + file_put_contents($root . '/assets/Events/inn-keeper.php', str_replace("'flicker'", "'quiet-night'", (string) file_get_contents($root . '/assets/Events/inn-keeper.php'))); + expect($stages())->toBe([]); +}); diff --git a/tests/Unit/IntegerFieldAbsenceTest.php b/tests/Unit/IntegerFieldAbsenceTest.php new file mode 100644 index 00000000..aae4a459 --- /dev/null +++ b/tests/Unit/IntegerFieldAbsenceTest.php @@ -0,0 +1,46 @@ + 'one']], static function (): void { + })); +} + +function readIntegerAbsence(RecordCategory $records): array +{ + return array_column($records->getRecordRows(0, []), 'value', 'label'); +} + +it('keeps zero where absence means something else, and clears a field given its shown default', function () { + $records = integerAbsenceRecords(); + foreach (['count' => '0', 'chance' => '0', 'rest' => '0'] as $field => $value) { + $records->applyField(0, [], $field, $value, 'Set'); + } + + expect(readIntegerAbsence($records))->toMatchArray(['Count' => '', 'Chance %' => '0', 'Rest Frame' => '0']); + + $records->applyField(0, [], 'chance', '100', 'Set'); + $records->applyField(0, [], 'rest', 'last frame', 'Set'); + expect(readIntegerAbsence($records))->toMatchArray(['Chance %' => '100', 'Rest Frame' => 'Last frame']); + + $records->applyField(0, [], 'rest', '', 'Set'); + expect(readIntegerAbsence($records)['Rest Frame'])->toBe('Last frame'); +}); diff --git a/tests/Unit/InventoryIdentityTest.php b/tests/Unit/InventoryIdentityTest.php index 90dcd0de..fb78b1a8 100644 --- a/tests/Unit/InventoryIdentityTest.php +++ b/tests/Unit/InventoryIdentityTest.php @@ -8,38 +8,6 @@ use Ichiloto\Editor\Validation\SaveCompatibilityValidator; use Ichiloto\Editor\Validation\Severity; -/** - * Writes an items.php whose entries are the given PHP constructor snippets. - * - * @param string $root The project root. - * @param string[] $entries The constructor expressions. - * @return void - */ -function writeInventorySource(string $root, array $entries): void -{ - file_put_contents( - $root . '/assets/Data/items.php', - " ' ' . $e, $entries)) . ",\n];\n", - ); -} - -/** - * An Item constructor expression with named arguments. - * - * @param array $arguments The named arguments, already as PHP. - * @return string The expression. - */ -function itemExpression(array $arguments): string -{ - $parts = []; - - foreach ($arguments as $name => $value) { - $parts[] = sprintf('%s: %s', $name, $value); - } - - return 'new \Ichiloto\Engine\Entities\Inventory\Items\Item(' . implode(', ', $parts) . ')'; -} - /** * Returns the real game project root, with its own class autoloader * registered, or null when this checkout has no game beside it. @@ -130,16 +98,16 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('resolves a stable id, a display name, and a declared alias to one definition', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression([ - 'id' => "'item.s-potion'", - 'name' => "'S-Potion'", - 'description' => "'Restores 50 HP.'", - 'icon' => "'🧪'", - 'price' => '50', - 'aliases' => "['Old Potion Name']", - ]), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item( + id: 'item.s-potion', + name: 'S-Potion', + description: 'Restores 50 HP.', + icon: '🧪', + price: 50, + aliases: ['Old Potion Name'], + ), + ); $catalog = inventoryCatalog($root); @@ -186,10 +154,10 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('fails closed on an ambiguous reference instead of guessing a definition', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.one'", 'name' => "'First'", 'description' => "''", 'icon' => "'a'", 'price' => '1', 'aliases' => "['Shared Name']"]), - itemExpression(['id' => "'item.two'", 'name' => "'Second'", 'description' => "''", 'icon' => "'b'", 'price' => '2', 'aliases' => "['shared name']"]), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.one', name: 'First', description: '', icon: 'a', price: 1, aliases: ['Shared Name']), + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.two', name: 'Second', description: '', icon: 'b', price: 2, aliases: ['shared name']), + ); $catalog = inventoryCatalog($root); @@ -209,9 +177,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('derives the engine legacy id for a definition authored without one', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['name' => "'Old Charm'", 'description' => "''", 'icon' => "'c'", 'price' => '3']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(name: 'Old Charm', description: '', icon: 'c', price: 3), + ); $catalog = inventoryCatalog($root); @@ -228,9 +196,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('accepts an alias target that is a stable definition id', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); writeCompatibilityManifest($root, [ 'contentVersion' => 0, 'migrations' => [], @@ -245,9 +213,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('still fails an alias target that names nothing in the catalogue', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); writeCompatibilityManifest($root, [ 'contentVersion' => 0, 'migrations' => [], @@ -304,9 +272,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('offers inventory references as stable ids labelled with their names', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); $catalog = new \Ichiloto\Editor\Database\ReferenceCatalog(ProjectWorkspace::fromProject($root)); @@ -352,11 +320,11 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('accepts an id, a name or an alias wherever content names an item, and refuses the rest', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50', 'aliases' => "['Potion']"]), - itemExpression(['id' => "'item.twin'", 'name' => "'First Twin'", 'description' => "''", 'icon' => "'a'", 'price' => '1', 'aliases' => "['Shared']"]), - itemExpression(['id' => "'item.twin-two'", 'name' => "'Second Twin'", 'description' => "''", 'icon' => "'b'", 'price' => '1', 'aliases' => "['shared']"]), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50, aliases: ['Potion']), + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.twin', name: 'First Twin', description: '', icon: 'a', price: 1, aliases: ['Shared']), + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.twin-two', name: 'Second Twin', description: '', icon: 'b', price: 1, aliases: ['shared']), + ); $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; $map = require $mapPath; @@ -392,9 +360,9 @@ function writeCompatibilityManifest(string $root, array $manifest): void it('validates what a chest gives out', function () { $root = makeTemporaryProject('ichiloto-inventory-'); - writeInventorySource($root, [ - itemExpression(['id' => "'item.s-potion'", 'name' => "'S-Potion'", 'description' => "''", 'icon' => "'🧪'", 'price' => '50']), - ]); + writeItemRecords($root, + new \Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.s-potion', name: 'S-Potion', description: '', icon: '🧪', price: 50), + ); $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; $map = require $mapPath; $map['events']['E']['data'] = ['lootType' => 'item', 'loot' => 'item.absent']; @@ -405,12 +373,18 @@ function writeCompatibilityManifest(string $root, array $manifest): void $lines = implode("\n", issueLines($root)); - expect($lines)->toContain('It names the item "item.absent", which does not exist.') - ->and($lines)->toContain('It gives gold "lots", which is not an amount.') - ->and($lines)->toContain('Its loot type "treasure" is not one the game knows.') - // An unconfigured chest is unfinished authoring, not a broken - // reference. - ->and($lines)->not->toContain('event I'); + // The Engine's chest rule reports each of them on its own chest, under its rule identity; a chest + // with no item chosen fails when opened, so it is reported too and still saves as authored. + $chests = array_values(array_unique(array_map( + static fn($issue): string => $issue->where, + array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn($issue): bool => $issue->code === \Ichiloto\Editor\Validation\ChestRewardValidator::CODE + && $issue->severity === Severity::ERROR), + ))); + sort($chests); + + expect($lines)->not->toBe('') + ->and($chests)->toBe(['test-map event E', 'test-map event G', 'test-map event H', 'test-map event I']); }); it('keeps a key item\'s quantity through the condition line, both ways', function () { diff --git a/tests/Unit/InventorySourcePreservationTest.php b/tests/Unit/InventorySourcePreservationTest.php index d065aba0..68e0a647 100644 --- a/tests/Unit/InventorySourcePreservationTest.php +++ b/tests/Unit/InventorySourcePreservationTest.php @@ -7,25 +7,52 @@ use Ichiloto\Editor\Database\RecordSchemaCatalog; /** - * Copies the real project's authored items.php into a throwaway project. - * - * The point of these tests is the actual authored source -- named arguments, - * fully qualified classes, nested constructors, emoji icons -- not a - * simplified stand-in, so the file is copied rather than written. - * - * @return array{0: string, 1: string}|null The project root and items path. + * Editing an inventory record changes the value asked for in that record's + * own file and nothing else: not the record's other lines, not its comments + * or imports, and not any other file in the project. */ -function projectWithAuthoredInventory(): ?array -{ - $game = gameSourceRoot(); - if ($game === null) { - return null; - } +/** + * The authored weapon record these tests edit: an import, comments, an emoji + * icon and a nested map, written the way an author writes one. + */ +const AUTHORED_WEAPON_RECORD = <<<'PHP' + InventoryItem::class, + 'data' => [ + 'kind' => 'weapon', + 'id' => 'equipment.ember-blade', + 'name' => 'Ember Blade', + 'description' => 'Warm to the touch.', // Not hot. + 'icon' => '🗡️', + 'price' => 300, + 'equipmentType' => 'Sword', + /* Balanced for the first town. */ + 'parameterChanges' => [ + 'attack' => 6, + 'speed' => 1, + ], + 'element' => 'Fire', + ], +]; +PHP; + +/** + * Returns a project whose weapons are the fixture's sword and the authored + * Ember Blade, and the blade's record file. + * + * @return array{0: string, 1: string} The project root and the record file. + */ +function projectWithAuthoredWeapon(): array +{ $root = makeTemporaryProject('ichiloto-source-'); - $path = $root . '/assets/Data/items.php'; - copy($game . '/assets/Data/items.php', $path); + $path = $root . '/assets/Data/Weapons/0002-ember-blade.php'; + file_put_contents($path, AUTHORED_WEAPON_RECORD); return [$root, $path]; } @@ -38,57 +65,51 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category)); } -it('changes one authored value and leaves every other byte alone', function () { - $project = projectWithAuthoredInventory(); +/** + * Returns the lines of an edited file that differ from the original. + * + * @return string[] The changed lines. + */ +function changedLines(string $before, string $after): array +{ + $beforeLines = explode("\n", $before); - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + return array_values(array_filter( + explode("\n", $after), + static fn(string $line, int $index): bool => ($beforeLines[$index] ?? null) !== $line, + ARRAY_FILTER_USE_BOTH, + )); +} - [$root, $path] = $project; - $before = (string) file_get_contents($path); +it('changes one authored value and leaves every other byte alone', function () { + [$root, $path] = projectWithAuthoredWeapon(); + $database = inventoryDatabase($root, 'weapons'); + $index = array_search('Ember Blade', $database->getEntryLabels(), true); - $database = inventoryDatabase($root, 'items'); - $database->setField(0, 'price', '55'); + $database->setField($index, 'price', '450'); $database->save(); $after = (string) file_get_contents($path); - $beforeLines = explode("\n", $before); - $afterLines = explode("\n", $after); - $changed = []; - foreach ($afterLines as $index => $line) { - if (($beforeLines[$index] ?? null) !== $line) { - $changed[] = $line; - } - } + expect(substr_count($after, "\n"))->toBe(substr_count(AUTHORED_WEAPON_RECORD, "\n")) + ->and(changedLines(AUTHORED_WEAPON_RECORD, $after))->toBe([" 'price' => 450,"]) + ->and(inventoryDatabase($root, 'weapons')->getRecordByIndex($index)?->get('price'))->toBe(450); +}); - // Exactly one line differs, and it is the one asked for. - expect($afterLines)->toHaveCount(count($beforeLines)) - ->and($changed)->toHaveCount(1) - ->and(trim($changed[0]))->toBe('price: 55,') - // The authored spelling of everything else survives. - ->and($after)->toContain("new \\Ichiloto\\Engine\\Entities\\Inventory\\Items\\Item(") - ->and($after)->toContain("id: 'item.s-potion',") - ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;') - ->and($after)->toContain('equipmentType: \Ichiloto\Engine\Entities\Enumerations\WeaponType::SWORD,') - // The file is still PHP, and still the same list of definitions. - ->and(PhpSourceDocument::parse($after)->entryCount())->toBe(PhpSourceDocument::parse($before)->entryCount()); - - $reloaded = inventoryDatabase($root, 'items'); - expect($reloaded->getRecordByIndex(0)?->get('price'))->toBe(55); -})->group('engine'); +it('edits a nested value in place, keeping the comments around it', function () { + [$root, $path] = projectWithAuthoredWeapon(); + $database = inventoryDatabase($root, 'weapons'); + $index = array_search('Ember Blade', $database->getEntryLabels(), true); -it('writes nothing at all when nothing changed', function () { - $project = projectWithAuthoredInventory(); + $database->setField($index, 'parameterChanges.attack', '9'); + $database->save(); - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + expect((string) file_get_contents($path))->toBe(str_replace("'attack' => 6,", "'attack' => 9,", AUTHORED_WEAPON_RECORD)); +}); - [$root, $path] = $project; - $before = (string) file_get_contents($path); - $modifiedAt = filemtime($path); +it('writes nothing at all when nothing changed', function () { + [$root] = projectWithAuthoredWeapon(); + $before = sourceHashTree($root); foreach (['items', 'weapons', 'armors'] as $category) { $database = inventoryDatabase($root, $category); @@ -103,80 +124,21 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas $database->save(); } - expect((string) file_get_contents($path))->toBe($before) - ->and(filemtime($path))->toBe($modifiedAt); -})->group('engine'); - -it('edits a weapon inside its nested constructor without touching the others', function () { - $project = projectWithAuthoredInventory(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } - - [$root, $path] = $project; - $before = (string) file_get_contents($path); - - $database = inventoryDatabase($root, 'weapons'); - $database->setField(0, 'parameterChanges.attack', '9'); - $database->save(); - - $after = (string) file_get_contents($path); - $changed = array_values(array_filter( - array_map( - static fn(string $line, int $index): ?string => ($line !== (explode("\n", $before)[$index] ?? null)) ? $line : null, - explode("\n", $after), - array_keys(explode("\n", $after)), - ), - static fn(?string $line): bool => $line !== null, - )); - - expect($changed)->toHaveCount(1) - ->and(trim($changed[0]))->toBe('attack: 9,') - // The nested constructor itself is untouched, not rebuilt. - ->and($after)->toContain('parameterChanges: new \Ichiloto\Engine\Entities\ParameterChanges('); - - $reloaded = inventoryDatabase($root, 'weapons'); - expect($reloaded->getRecordByIndex(0)?->get('parameterChanges.attack'))->toBe(9); -})->group('engine'); - -it('leaves every unrelated file in the project byte-identical', function () { - $project = projectWithAuthoredInventory(); - - if ($project === null) { - $this->markTestSkipped('The game project is not reachable from this checkout.'); - } + expect(sourceHashTree($root))->toBe($before); +}); - [$root, $path] = $project; +it('leaves every other file in the project byte-identical', function () { + [$root] = projectWithAuthoredWeapon(); $before = sourceHashTree($root); $database = inventoryDatabase($root, 'items'); - $database->setField(1, 'description', 'A potion that restores rather more HP.'); - $database->save(); - - $after = sourceHashTree($root); - - expect(array_keys(array_diff_assoc($after, $before)))->toBe(['assets/Data/items.php']); -})->group('engine'); - -it('keeps an authored file whose entries are not named-argument calls working as before', function () { - // The fixture project authors its items positionally, which cannot be - // patched by argument name. That file still saves through the ordinary - // writer rather than failing, and the record still changes. - $root = makeTemporaryProject('ichiloto-source-'); - $path = $root . '/assets/Data/items.php'; - - expect((string) file_get_contents($path))->toContain("new Item('S-Potion'"); - - $database = inventoryDatabase($root, 'items'); - $database->setField(0, 'price', '77'); + $database->setField(1, 'description', 'Cures poison, and tastes of it.'); $database->save(); - $reloaded = inventoryDatabase($root, 'items'); - - expect($reloaded->getRecordByIndex(0)?->get('price'))->toBe(77); + expect(array_keys(array_diff_assoc(sourceHashTree($root), $before)))->toBe(['assets/Data/Items/0002-antidote.php']); }); + // -- The document itself --------------------------------------------------- it('reads, replaces, adds and removes one argument at a time', function () { @@ -223,6 +185,8 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas // A single-line call takes an argument inline. $inline = $document->withArgument(1, 'sellable', 'false'); expect($inline->argumentSource(1, 'sellable'))->toBe('false') + ->and($inline->source)->toContain('new Item(') + ->and($inline->source)->toContain('new \Ichiloto\Engine\Entities\Inventory\Items\Item(') ->and($inline->source)->toContain("price: 80, sellable: false)"); }); @@ -277,3 +241,13 @@ function inventoryDatabase(string $root, string $category): ProjectRecordDatabas expect($emptied->entryCount())->toBe(1) ->and($emptied->argumentSource(0, 'name'))->toBe("'Two'"); }); + +it('refuses to regenerate a commented file it cannot edit in place, rather than drop the comments', function () { + $root = makeTemporaryProject('ichiloto-source-'); + $path = $root . '/assets/Data/states.php'; + file_put_contents($path, " 'poison', 'name' => 'Poison']],\n);\n"); + $file = \Ichiloto\Editor\Database\PhpDataFile::load($path); + + expect($file->isEditable())->toBeFalse() + ->and($file->readOnlyReason)->toContain('comments inside its data'); +}); diff --git a/tests/Unit/KnowledgeAuthoringTest.php b/tests/Unit/KnowledgeAuthoringTest.php index fc0d5d18..45bdbaa6 100644 --- a/tests/Unit/KnowledgeAuthoringTest.php +++ b/tests/Unit/KnowledgeAuthoringTest.php @@ -274,3 +274,41 @@ function knowledgeDatabase(string $root, string $category): ProjectRecordDatabas expect((require $path)['reports'][0]['id'] ?? null)->toBe('report.first'); }); + +it('labels a record type and links a summon record without disturbing the bare ids', function () { + $root = makeTemporaryProject('ichiloto-knowledge-'); + $path = writeKnowledgeCatalog($root); + + $types = knowledgeDatabase($root, 'knowledge_record_types'); + $index = $types->addRecord(); + $types->setField($index, 'type', 'summon'); + $types->setField($index, 'label', 'Synthetic Spirits'); + $types->save(); + + $subjects = knowledgeDatabase($root, 'knowledge_subjects'); + $index = $subjects->addRecord(); + $subjects->setField($index, 'id', 'summon.wisp'); + $subjects->setField($index, 'recordType', 'summon'); + $subjects->setField($index, 'displayName', 'Wisp'); + $subjects->setField($index, 'quickCard', ''); + $subjects->setField($index, 'summon', 'wisp'); + $subjects->save(); + + $after = require $path; + expect($after['recordTypes'])->toBe(['creature', 'person', ['id' => 'summon', 'label' => 'Synthetic Spirits']]) + ->and($after['subjects'][2]['summon'])->toBe('wisp') + ->and($after['subjects'][0])->not->toHaveKey('summon') + ->and(knowledgeDatabase($root, 'knowledge_record_types')->getRecordByIndex(2)?->get('label'))->toBe('Synthetic Spirits'); + + $catalog = ProjectDirectoryContext::run($root, static fn(): mixed => new \Ichiloto\Engine\Progress\Knowledge\KnowledgeCatalog( + (static fn(): mixed => require $root . '/assets/Data/knowledge.php')(), + )); + expect($catalog->getRecordType('summon')?->name)->toBe('Synthetic Spirits') + ->and($catalog->subject('summon.wisp')?->summonId)->toBe('wisp'); + + // Clearing the label writes the bare id back. + $types = knowledgeDatabase($root, 'knowledge_record_types'); + $types->setField(2, 'label', ''); + $types->save(); + expect((require $path)['recordTypes'])->toBe(['creature', 'person', 'summon']); +}); diff --git a/tests/Unit/LastLegendMapIntegrityTest.php b/tests/Unit/LastLegendMapIntegrityTest.php index cc9ae0bb..0f8f9bf4 100644 --- a/tests/Unit/LastLegendMapIntegrityTest.php +++ b/tests/Unit/LastLegendMapIntegrityTest.php @@ -3,8 +3,16 @@ declare(strict_types=1); use Ichiloto\Editor\Field\MapEncounters; +use Ichiloto\Editor\History\CommandHistory; +use Ichiloto\Editor\History\PaintStrokeCommand; +use Ichiloto\Editor\MapSourceRefusal; +use Ichiloto\Editor\ProjectDirectoryContext; use Ichiloto\Editor\ProjectMap; use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapLayer; +use Ichiloto\Engine\Field\MapLayerSource; +use Ichiloto\Engine\IO\Console\TerminalText; /** * Production evidence over disposable copies of Last Legend: the maps that @@ -21,6 +29,7 @@ */ function mapTreeState(string $root): array { + clearstatcache(); $state = []; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/assets/Maps', FilesystemIterator::SKIP_DOTS)); @@ -38,15 +47,25 @@ function mapTreeState(string $root): array return $state; } +function createLastLegendMapIntegrityProject(string $prefix): ?string +{ + $root = disposableLastLegend($prefix); + if (getenv('ICHILOTO_GAME_SRC')) { + expect($root)->not->toBeNull('ICHILOTO_GAME_SRC is pinned but its Last Legend fixture is unusable'); + } + + return $root; +} + it('opens every Last Legend map, and merely opening, validating and saving clean writes nothing', function () { - $root = disposableLastLegend('last-legend-map-integrity-'); + $root = createLastLegendMapIntegrityProject('last-legend-map-integrity-'); if ($root === null) { $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); } - foreach (glob($root . '/assets/Maps/**') ?: [] as $ignored) { - // glob only forces the copy to exist; the workspace does the walk. + foreach (array_keys(mapTreeState($root)) as $path) { + touch($root . '/' . $path, 1000000000); } $before = mapTreeState($root); @@ -59,6 +78,7 @@ function mapTreeState(string $root): array $preserved++; } + $map->validateLayerContracts(); $map->save(); } @@ -67,7 +87,7 @@ function mapTreeState(string $root): array }); it('edits one metadata field on the require-and-enum map and restores it byte-for-byte', function () { - $root = disposableLastLegend('last-legend-map-edit-'); + $root = createLastLegendMapIntegrityProject('last-legend-map-edit-'); if ($root === null) { $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); @@ -75,9 +95,7 @@ function mapTreeState(string $root): array $directory = $root . '/assets/Maps/overworld/garden-of-roads'; - if (! is_dir($directory)) { - $this->markTestSkipped('The baseline holds no garden-of-roads map.'); - } + expect(is_dir($directory))->toBeTrue('the pinned game must contain Garden of Roads'); $before = mapTreeState($root); $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); @@ -119,7 +137,7 @@ function mapTreeState(string $root): array }); it('round-trips Garden BGM and encounters through the accepted map runtime metadata model', function () { - $root = disposableLastLegend('last-legend-map-garden-'); + $root = createLastLegendMapIntegrityProject('last-legend-map-garden-'); if ($root === null) { $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); @@ -127,9 +145,7 @@ function mapTreeState(string $root): array $directory = $root . '/assets/Maps/overworld/garden-of-roads'; - if (! is_dir($directory)) { - $this->markTestSkipped('The baseline holds no garden-of-roads map.'); - } + expect(is_dir($directory))->toBeTrue('the pinned game must contain Garden of Roads'); $map = ProjectMap::fromDirectory($root . '/assets/Maps', $directory); $before = (string) file_get_contents($map->dataPath); @@ -157,3 +173,224 @@ function mapTreeState(string $root): array $map->save(); expect((string) file_get_contents($map->dataPath))->toBe($before, 'the committed bytes are back'); }); + +/** Independently inventory the source tree so read-only or undiscovered maps cannot evade coverage. */ +function getIntegrityMaps(string $root, bool $production): array +{ + $files = array_keys(mapTreeState($root)); + $dataFiles = array_values(array_filter($files, static fn(string $path): bool => str_ends_with($path, '.data.php'))); + $gridFiles = array_values(array_filter($files, static fn(string $path): bool => preg_match('/\.(map|deco|event)\.php$/', $path) === 1)); + $workspace = ProjectWorkspace::fromProject($root); + expect(count($workspace->maps))->toBeGreaterThanOrEqual($production ? 30 : 1); + $loadedData = []; + $loadedGrids = []; + foreach ($workspace->maps as $map) { + expect($map->getGridSourceIssue())->toBeNull($map->mapId) + ->and($map->dataSourceIssue())->toBeNull($map->mapId) + ->and($map->isLegacyMap())->toBeFalse($map->mapId) + ->and(is_file($map->mapPath))->toBeFalse($map->mapId . ' has no root map grid'); + $map->validateLayerContracts(); + $loadedData[] = substr($map->dataPath, strlen($root) + 1); + foreach ($map->getLayers() as $layer) { + $loadedGrids[] = substr($layer['path'], strlen($root) + 1); + } + } + sort($loadedData); + sort($loadedGrids); + expect($loadedData)->toBe($dataFiles, 'every authored map was opened') + ->and($loadedGrids)->toBe($gridFiles, 'every gameplay, decoration and event file was opened'); + + return $workspace->maps; +} + +/** Prefer an occupied cell, but also exercise intentionally empty layers. */ +function findIntegrityCell(ProjectMap $map, string $layer): array +{ + $first = null; + for ($y = 0; $y < $map->getHeight(); $y++) { + for ($x = 0; $x < $map->getWidth(); $x++) { + if ($map->hasLayerCell($layer, $x, $y)) { + $first ??= [$x, $y]; + if ($map->getLayerSymbol($layer, $x, $y) !== ' ') { + return [$x, $y]; + } + } + } + } + expect($first)->not->toBeNull($map->mapId . '/' . $layer . ' must have an editable cell'); + + return $first; +} + +/** Raw source lines include their exact line endings, indentation and formatter tags. */ +function assertIntegrityRowEdit(string $before, string $after, int $row, string $path): void +{ + $bodyLine = null; + foreach (token_get_all($before, TOKEN_PARSE) as $token) { + if (is_array($token) && $token[0] === T_START_HEREDOC) { + $bodyLine = $token[2]; + break; + } + } + expect($bodyLine)->not->toBeNull($path); + $oldLines = preg_split('/(?<=\n)|(?<=\r)(?!\n)/', $before); + $newLines = preg_split('/(?<=\n)|(?<=\r)(?!\n)/', $after); + expect(count($newLines))->toBe(count($oldLines), $path . ' source line count'); + expect($newLines[$bodyLine + $row])->not->toBe($oldLines[$bodyLine + $row], $path . ' edited row'); + $newLines[$bodyLine + $row] = $oldLines[$bodyLine + $row]; + expect($newLines)->toBe($oldLines, $path . ' all other rows and source scaffolding'); +} + +it('round-trips every authored gameplay decoration and event layer through save undo and redo', function (bool $production) { + $root = $production ? createLastLegendMapIntegrityProject('last-legend-layer-roundtrip-') : layeredMapProject(true); + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + $maps = getIntegrityMaps($root, $production); + $visited = []; + foreach ($maps as $map) { + foreach ($map->getLayers() as $layer) { + foreach (array_keys(mapTreeState($root)) as $path) { + touch($root . '/' . $path, 1000000000); + } + $before = mapTreeState($root); + $path = $layer['path']; + $relativePath = substr($path, strlen($root) + 1); + $source = (string) file_get_contents($path); + $widths = array_map(count(...), MapLayer::parseGrid(MapGridSource::readFile($path))); + [$x, $y] = findIntegrityCell($map, $layer['id']); + $symbol = $map->getLayerSymbol($layer['id'], $x, $y); + $style = $map->getLayerCellStyle($layer['id'], $x, $y); + // Preserve decoration crop keys and event identities; recolouring is still a real edit. + $painted = $layer['decoration'] || $layer['id'] === 'event' ? $symbol : ($symbol === '#' ? '@' : '#'); + $prefix = $style['prefix'] === '' ? '' : ''; + $command = new PaintStrokeCommand($map, $layer['id']); + $command->appendCell($x, $y, $symbol, $painted, $style['prefix'], $style['suffix'], $prefix, ''); + $history = new CommandHistory(); + $command->execute(); + $history->record($command); + expect($command->hasChanges())->toBeTrue(); + $map->save(); + $edited = (string) file_get_contents($path); + assertIntegrityRowEdit($source, $edited, $y, $relativePath); + expect(array_map(count(...), MapLayer::parseGrid(MapGridSource::readFile($path))))->toBe($widths, $relativePath . ' row widths'); + $reloaded = ProjectDirectoryContext::run($root, fn() => ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory)); + expect($reloaded->getLayerSymbol($layer['id'], $x, $y))->toBe($painted) + ->and($reloaded->getLayerCellStyle($layer['id'], $x, $y))->toBe(['prefix' => $prefix, 'suffix' => '']) + ->and($reloaded->isDirty())->toBeFalse(); + $after = mapTreeState($root); + expect(array_keys($after))->toBe(array_keys($before)); + foreach ($before as $file => $state) { + if ($file !== $relativePath) { + expect($after[$file])->toBe($state, $file . ' untouched bytes and mtime'); + } + } + expect($after[$relativePath][0])->not->toBe($before[$relativePath][0]); + expect($history->undo())->toBe($command); + $map->save(); + expect(file_get_contents($path))->toBe($source, $relativePath . ' undo after save'); + expect($history->redo())->toBe($command); + $map->save(); + expect(file_get_contents($path))->toBe($edited, $relativePath . ' redo after save'); + $history->undo(); + $map->save(); + $restored = mapTreeState($root); + foreach ($before as $file => $state) { + expect($restored[$file][0])->toBe($state[0], $file . ' restored bytes'); + if ($file !== $relativePath) { + expect($restored[$file][1])->toBe($state[1], $file . ' untouched mtime after history'); + } + } + $clean = mapTreeState($root); + $map->save(); + expect(mapTreeState($root))->toBe($clean, 'saving the restored clean map writes nothing'); + $visited[] = $relativePath; + } + } + sort($visited); + expect($visited)->toBe(array_values(array_filter(array_keys(mapTreeState($root)), static fn(string $path): bool => preg_match('/\.(map|deco|event)\.php$/', $path) === 1))); +})->with(['ragged probe' => false, 'Last Legend' => true]); + +it('previews every authored map as the Engine composed terminal grid including unsaved edits', function (bool $production) { + $root = $production ? createLastLegendMapIntegrityProject('last-legend-layer-preview-') : layeredMapProject(true); + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + $before = mapTreeState($root); + foreach (getIntegrityMaps($root, $production) as $map) { + $composed = MapLayerSource::loadFromDirectory($map->directory, $map->mapId)->getComposedGrid(); + $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $composed); + $snapshot = $map->captureLayerSnapshot(); + $hidden = array_fill_keys(array_column($map->getLayers(), 'id'), false); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($expected, $map->mapId); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), showNpcOverlay: true, layerVisibility: $hidden, activeLayer: 'event', terminalPreview: true, dimInactive: true))->toBe($expected, 'canvas overlays and visibility do not change terminal output'); + $cropped = array_map(static fn(array $row): string => rtrim(implode('', array_slice($row, 1, 3))), array_slice($composed, 1, 4)); + expect($map->renderPreview(3, 4, 1, 1, terminalPreview: true))->toBe(array_pad($cropped, 4, ''), $map->mapId . ' clipped viewport'); + $gameplay = []; + foreach ($map->getLayers() as $layer) { + if ($layer['decoration'] || $layer['id'] === 'event') { + [$x, $y] = findIntegrityCell($map, $layer['id']); + $map->setLayerCell($layer['id'], $x, $y, '!', '', ''); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($expected, $layer['path'] . ' is terminal-invisible'); + } else { + $gameplay[] = $layer['id']; + } + } + $top = $gameplay[array_key_last($gameplay)]; + [$x, $y] = findIntegrityCell($map, $top); + $symbol = TerminalText::stripAnsi($composed[$y][$x]) === '#' ? '@' : '#'; + $map->setLayerCell($top, $x, $y, $symbol, '', ''); + $composed[$y][$x] = TerminalText::visibleSymbols('' . $symbol . '')[0]; + $edited = array_map(static fn(array $row): string => rtrim(implode('', $row)), $composed); + expect($edited)->not->toBe($expected) + ->and($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($edited, 'unsaved topmost gameplay edit is visible'); + $map->restoreLayerSnapshot($snapshot); + expect($map->renderPreview($map->getWidth(), $map->getHeight(), terminalPreview: true))->toBe($expected); + } + expect(mapTreeState($root))->toBe($before, 'preview never rewrites authored files'); +})->with(['ragged probe' => false, 'Last Legend' => true]); + +it('refuses executable source in every authored layer before evaluation save duplicate or move', function (bool $production) { + $root = $production ? createLastLegendMapIntegrityProject('last-legend-layer-refusal-') : layeredMapProject(true); + if ($root === null) { + $this->markTestSkipped('No Last Legend checkout is pinned (ICHILOTO_GAME_SRC).'); + } + foreach (getIntegrityMaps($root, $production) as $map) { + $data = (string) file_get_contents($map->dataPath); + $dataMtime = filemtime($map->dataPath); + file_put_contents($map->dataPath, "getLayers() as $layer) { + $path = $layer['path']; + $source = (string) file_get_contents($path); + $mtime = filemtime($path); + $injected = "file_put_contents(" . var_export($root . '/grid-executed', true) . ", 'yes');\n"; + file_put_contents($path, preg_replace('/<\?php\s*/', " ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); + expect($load)->toThrow(MapSourceRefusal::class, basename($path)) + ->and($load)->toThrow(MapSourceRefusal::class, 'T_STRING at line 2'); + foreach ([ + fn() => $map->save(), + fn() => $map->duplicateTo($root . '/assets/Maps/refused-copy', 'refused-copy', 'Refused Copy'), + fn() => $map->moveTo('refused-move'), + ] as $action) { + expect($action)->toThrow(MapSourceRefusal::class, basename($path)); + expect(mapTreeState($root))->toBe($poisoned, $path . ' refusal leaves all bytes and mtimes untouched'); + } + expect(is_file($root . '/grid-executed'))->toBeFalse() + ->and(is_file($root . '/data-executed'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/refused-copy'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/refused-move'))->toBeFalse(); + } finally { + file_put_contents($path, $source); + touch($path, $mtime); + } + } + } finally { + file_put_contents($map->dataPath, $data); + touch($map->dataPath, $dataMtime); + } + } +})->with(['ragged probe' => false, 'Last Legend' => true]); diff --git a/tests/Unit/LayerEditorTest.php b/tests/Unit/LayerEditorTest.php new file mode 100644 index 00000000..597db917 --- /dev/null +++ b/tests/Unit/LayerEditorTest.php @@ -0,0 +1,257 @@ + CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::SOLID' . $extra . '];'); +} + +/** The graphics fixture map with tile layer settings in its data file. */ +function layerEditorGraphicsMap(string $settings): array +{ + $root = mapGraphicsProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => {$settings},", (string) file_get_contents($data))); + + return [loadLayeredMap($root), $root]; +} + +/** @return list The map folder's layer and tile layer files. */ +function layerEditorFiles(ProjectMap $map): array +{ + $files = [...(glob($map->directory . '/layers/*.php') ?: []), ...(glob($map->directory . '/graphics/*.php') ?: [])]; + + return array_map(static fn(string $path): string => basename(dirname($path)) . '/' . basename($path), $files); +} + +it('creates a decoration layer at the next order as one undo step that a save writes', function () { + $map = loadLayeredMap(layeredMapProject()); + + $edit = LayerEditor::createLayer($map, 'shadows', true); + $created = array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === $edit['layer']); + + expect($created)->toMatchArray(['name' => 'shadows', 'order' => 8, 'decoration' => true]) + ->and($edit['question'])->toBeNull(); + $edit['command']->undo(); + expect(array_column($map->getLayers(), 'name'))->not->toContain('shadows'); + $edit['command']->execute(); + $map->save(); + expect(layerEditorFiles($map))->toContain('layers/08.shadows.deco.php'); +}); + +it('reorders a layer by exchanging orders, saving renamed files and removing the old ones together', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $bytes = [file_get_contents($map->directory . '/layers/04.buildings.map.php'), file_get_contents($map->directory . '/layers/07.detail.deco.php')]; + + expect(LayerEditor::findAdjacentLayerOrder($map, 'map:4', LayerEditor::ABOVE))->toBe(7); + $edit = LayerEditor::moveLayer($map, 'map:4', 7); + $map->save(); + + expect(layerEditorFiles($map))->toBe(['layers/01.terrain.map.php', 'layers/04.detail.deco.php', 'layers/07.buildings.map.php']) + ->and([file_get_contents($map->directory . '/layers/07.buildings.map.php'), file_get_contents($map->directory . '/layers/04.detail.deco.php')])->toBe($bytes) + ->and(array_column(loadLayeredMap($root)->getLayers(), 'name'))->toBe(['terrain', 'detail', 'buildings', 'Events']); + + $edit['command']->undo(); + $map->save(); + expect(layerEditorFiles($map))->toBe(['layers/01.terrain.map.php', 'layers/04.buildings.map.php', 'layers/07.detail.deco.php']); +}); + +it('asks before a reorder that changes collisions and changes nothing until confirmed', function () { + $root = layeredMapProject(); + writeLayerEditorCollisions($root); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $revision = $map->stateVersion(); + $order = LayerEditor::findAdjacentLayerOrder($map, 'map:4', LayerEditor::BELOW); + + $asked = LayerEditor::moveLayer($map, 'map:4', $order); + + expect($asked['command'])->toBeNull() + ->and($asked['question'])->toContain('Moving buildings to order 01 changes collision at 3 cell(s)') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and($map->stateVersion())->toBe($revision) + ->and(fn() => $map->moveLayer('map:4', $order))->toThrow(MapSourceRefusal::class, 'Explicit confirmation is required'); + + $moved = LayerEditor::moveLayer($map, 'map:4', $order, true); + expect(array_column($map->getLayers(), 'name'))->toBe(['buildings', 'terrain', 'detail', 'Events']); + $moved['command']->undo(); + expect($map->captureLayerSnapshot())->toBe($before); +}); + +it('refuses moving past the ends, the event layer and orders outside 00-99', function () { + $map = loadLayeredMap(layeredMapProject()); + + expect(fn() => LayerEditor::findAdjacentLayerOrder($map, 'map:7', LayerEditor::ABOVE))->toThrow(MapSourceRefusal::class, 'detail is already the top layer') + ->and(fn() => LayerEditor::findAdjacentLayerOrder($map, 'map:1', LayerEditor::BELOW))->toThrow(MapSourceRefusal::class, 'terrain is already the bottom layer') + ->and(fn() => LayerEditor::findAdjacentLayerOrder($map, 'map:1', 'sideways'))->toThrow(MapSourceRefusal::class, "'above' or 'below'") + ->and(fn() => LayerEditor::moveLayer($map, 'event', 3))->toThrow(MapSourceRefusal::class, 'Only authored layers') + ->and(fn() => LayerEditor::moveLayer($map, 'map:4', 100))->toThrow(MapSourceRefusal::class, '00-99') + ->and($map->isDirty())->toBeFalse(); +}); + +it('keeps layer ids unique when a created layer takes an order a moved layer was loaded with', function () { + $map = loadLayeredMap(layeredMapProject()); + LayerEditor::moveLayer($map, 'map:4', 9); + + $id = $map->createLayer('fixtures', false, 4); + + expect($id)->not->toBe('map:4') + ->and(array_column($map->getLayers(), 'name'))->toBe(['terrain', 'fixtures', 'detail', 'buildings', 'Events']); +}); + +it('makes a layer decoration after asking, keeps the last gameplay layer, and drops tile settings that moved with it', function () { + [$map, $root] = layerEditorGraphicsMap("['floor' => ['movesWith' => 'buildings'], 'decor' => ['offset' => [0, -0.5], 'movesWith' => 'buildings']]"); + writeLayerEditorCollisions($root); + $data = file_get_contents($map->dataPath); + + expect(LayerEditor::setLayerDecoration($map, 'map:4', true)['question'])->toContain('Making buildings decoration changes collision at 3 cell(s)'); + $edit = LayerEditor::setLayerDecoration($map, 'map:4', true, true); + + expect(array_find($map->getLayers(), static fn(array $layer): bool => $layer['id'] === 'map:4')['decoration'])->toBeTrue() + ->and($map->getMapDataField(['tileLayers']))->toBe(['decor' => ['offset' => [0, -0.5]]]) + ->and(fn() => LayerEditor::setLayerDecoration($map, 'map:1', true, true))->toThrow(MapSourceRefusal::class, 'The last gameplay layer cannot become decoration.'); + $map->save(); + expect(layerEditorFiles($map))->toContain('layers/04.buildings.deco.php')->not->toContain('layers/04.buildings.map.php') + ->and($map->loadGraphics()->offsets)->toBe(['decor' => [0.0, -0.5]]); + + $edit['command']->undo(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($data) + ->and(layerEditorFiles($map))->toContain('layers/04.buildings.map.php'); +}); + +it('refuses making a layer decoration while the collision dictionary names it', function () { + $root = layeredMapProject(); + writeLayerEditorCollisions($root, ', "buildings" => ["x" => CollisionType::NONE]'); + $map = loadLayeredMap($root); + + expect(fn() => LayerEditor::setLayerDecoration($map, 'map:4', true, false)) + ->toThrow(MapSourceRefusal::class, 'must not be named in the collision dictionary. Nothing was changed.') + ->and($map->isDirty())->toBeFalse(); +}); + +it('renames a gameplay layer and the tile layers that move with it, asking first when collisions change', function () { + [$map, $root] = layerEditorGraphicsMap("['floor' => ['movesWith' => 'buildings']]"); + writeLayerEditorCollisions($root, ', "buildings" => ["x" => CollisionType::NONE]'); + + expect(LayerEditor::renameLayer($map, 'map:4', 'houses')['question']) + ->toBe('Rename buildings to houses changes collision at 2 cell(s). Shared collisions.php stays unchanged.'); + $edit = LayerEditor::renameLayer($map, 'map:4', 'houses', true); + $map->save(); + + expect($edit['layer'])->toBe('map:4') + ->and(loadLayeredMap($root)->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'houses']]) + ->and($map->getTileLayersMovingWith('map:4'))->toBe(['floor']); +}); + +it('removes a gameplay layer after asking, leaving the base layer and the tiles that moved with it moving with none', function () { + [$map, $root] = layerEditorGraphicsMap("['floor' => ['movesWith' => 'buildings']]"); + writeLayerEditorCollisions($root); + + expect(LayerEditor::removeLayer($map, 'map:4')['question'])->toContain('Removing buildings changes collision at 3 cell(s)'); + $edit = LayerEditor::removeLayer($map, 'map:4', true); + + expect($edit['layer'])->toBe('map:1') + ->and($map->hasMapDataField(['tileLayers']))->toBeFalse() + ->and(fn() => LayerEditor::removeLayer($map, 'map:1', true))->toThrow(MapSourceRefusal::class, 'The last gameplay layer cannot be removed.'); + $edit['command']->undo(); + expect($map->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'buildings']]); +}); + +it('creates an empty tile layer at a free order, refusing a taken or invalid name', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + + $edit = LayerEditor::createTileLayer($map, 'rugs'); + + expect(array_column($map->describeTileLayers()['layers'], 'name', 'order'))->toBe([1 => 'floor', 2 => 'decor', 3 => 'rugs']) + ->and(fn() => LayerEditor::createTileLayer($map, 'rugs'))->toThrow(MapSourceRefusal::class, 'Tile layer rugs already exists.') + ->and(fn() => LayerEditor::createTileLayer($map, '9lives'))->toThrow(MapSourceRefusal::class, "'9lives' is not a tile layer name."); + $map->save(); + expect(readTileRows($map->directory . '/graphics/03.rugs.tiles.php'))->toBe([[0, 0, 0, 0], [0, 0, 0, 0]]); + $edit['command']->undo(); + $map->save(); + expect(layerEditorFiles($map))->not->toContain('graphics/03.rugs.tiles.php'); +}); + +it('renames a tile layer with its settings, saving the same tiles under the new name', function () { + [$map, $root] = layerEditorGraphicsMap("['decor' => ['offset' => [0, -0.5]], 'floor' => ['movesWith' => 'buildings']]"); + $bytes = file_get_contents($map->directory . '/graphics/02.decor.tiles.php'); + + LayerEditor::renameTileLayer($map, 'decor', 'props'); + $map->save(); + $reloaded = loadLayeredMap($root); + + expect(layerEditorFiles($map))->toContain('graphics/02.props.tiles.php')->not->toContain('graphics/02.decor.tiles.php') + ->and(file_get_contents($map->directory . '/graphics/02.props.tiles.php'))->toBe($bytes) + ->and($reloaded->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'buildings'], 'props' => ['offset' => [0, -0.5]]]) + ->and($reloaded->loadGraphics()->offsets)->toBe(['props' => [0.0, -0.5]]) + ->and(fn() => LayerEditor::renameTileLayer($map, 'props', 'floor'))->toThrow(MapSourceRefusal::class, 'Tile layer floor already exists.') + ->and(fn() => LayerEditor::renameTileLayer($map, 'nothing', 'else'))->toThrow(MapSourceRefusal::class, 'There is no tile layer nothing.'); +}); + +it('reorders and removes tile layers, keeping settings naming only the layers the map has', function () { + [$map, $root] = layerEditorGraphicsMap("['decor' => ['offset' => [0.5, 0]]]"); + $data = file_get_contents($map->dataPath); + + LayerEditor::moveTileLayer($map, 'decor', LayerEditor::findAdjacentTileLayerOrder($map, 'decor', LayerEditor::BELOW)); + expect(array_column($map->describeTileLayers()['layers'], 'name'))->toBe(['decor', 'floor']); + $removed = LayerEditor::removeTileLayer($map, 'decor'); + $map->save(); + + expect(layerEditorFiles($map))->toContain('graphics/02.floor.tiles.php')->not->toContain('graphics/01.decor.tiles.php', 'graphics/02.decor.tiles.php') + ->and(loadLayeredMap($root)->hasMapDataField(['tileLayers']))->toBeFalse() + ->and(fn() => LayerEditor::findAdjacentTileLayerOrder($map, 'floor', LayerEditor::ABOVE))->toThrow(MapSourceRefusal::class, 'floor is already the top layer'); + $removed['command']->undo(); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($data) + ->and(layerEditorFiles($map))->toContain('graphics/01.decor.tiles.php', 'graphics/02.floor.tiles.php'); +}); + +it('sets tile layer settings exactly as the Engine reads them, removing what returns to none', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $data = $map->getEditableData(); + + $edit = LayerEditor::setTileLayerSettings($map, 'decor', [0.5, -0.5], 'buildings'); + $map->save(); + $layers = loadLayeredMap($root)->describeTileLayers(); + + expect($edit['command'])->not->toBeNull() + ->and(array_find($layers['layers'], static fn(array $layer): bool => $layer['name'] === 'decor')) + ->toMatchArray(['order' => 2, 'file' => 'graphics/02.decor.tiles.php', 'offset' => [0.5, -0.5], 'movesWith' => 'buildings', 'owner' => 'buildings']) + ->and($layers['issue'])->toBeNull() + ->and(LayerEditor::setTileLayerSettings($map, 'decor', [0.5, -0.5], 'buildings')['command'])->toBeNull() + ->and(fn() => LayerEditor::setTileLayerSettings($map, 'decor', [0.25, 0], null))->toThrow(MapSourceRefusal::class, 'offsets by -0.5, 0 or 0.5 field cells') + ->and(fn() => LayerEditor::setTileLayerSettings($map, 'decor', [0, 0], 'detail'))->toThrow(MapSourceRefusal::class, 'must name one of its gameplay layers') + ->and(fn() => LayerEditor::setTileLayerSettings($map, 'missing', [0, 0], null))->toThrow(MapSourceRefusal::class, 'There is no tile layer missing.'); + + LayerEditor::setTileLayerSettings($map, 'decor', [0, 0], null); + $map->save(); + expect(loadLayeredMap($root)->getEditableData())->toBe($data); +}); + +it('reports tile layer settings the Engine cannot read instead of guessing them', function () { + [$map] = layerEditorGraphicsMap("['gone' => ['offset' => [0, 0.5]]]"); + + $layers = $map->describeTileLayers(); + + expect($layers['issue'])->toContain("names 'gone', which is not one of its tile layers") + ->and(array_column($layers['layers'], 'offset'))->toBe([null, null]); +}); diff --git a/tests/Unit/LayerSourceEditsTest.php b/tests/Unit/LayerSourceEditsTest.php new file mode 100644 index 00000000..fbf2cc64 --- /dev/null +++ b/tests/Unit/LayerSourceEditsTest.php @@ -0,0 +1,26 @@ + ['buildings' /* keep */ => array_replace([], ['symbols' => []])]];"; + $document = PhpArraySourceDocument::parse($source); + $old = ['layers' => ['buildings' => ['symbols' => []]]]; + $new = ['layers' => ['houses' => ['symbols' => []]]]; + $rewritten = ArraySourceWriter::rewrite($document, $old, $new, + [['path' => ['layers', 'buildings'], 'key' => 'houses']]); + expect($rewritten->source)->toBe(str_replace("'buildings'", "'houses'", $source)); +}); + +it('refuses key renames into an existing key or across an opaque sibling', function (string $source) { + $document = PhpArraySourceDocument::parse($source); + expect(fn() => $document->renameKeyEdit(['layers', 'buildings'], 'houses'))->toThrow(SourcePreservationRefusal::class) + ->and($document->source)->toBe($source); +})->with([ + " ['buildings' => [], 'houses' => []]];", + " ['buildings' => [], strtolower('OTHER') => []]];", +]); diff --git a/tests/Unit/LayeredCanvasTest.php b/tests/Unit/LayeredCanvasTest.php new file mode 100644 index 00000000..0c2738e3 --- /dev/null +++ b/tests/Unit/LayeredCanvasTest.php @@ -0,0 +1,420 @@ +toBe($id); + } + foreach (['event', 'map:4', 'map:1'] as $id) { + callEditorMethod($editor, 'dispatchInput', '['); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($id); + } + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', 'i'); + foreach (['[', ']', 'v', 'd', 't', 'o'] as $glyph) { + callEditorMethod($editor, 'dispatchInput', $glyph); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe($glyph) + ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4'); + } +}); + +it('offers NPCs in the layer picker, entering NPC mode', function () { + [$editor] = layeredCanvasEditor(); + callEditorMethod($editor, 'dispatchInput', 'L'); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect(end($entries))->toMatchArray(['label' => 'NPCs', 'value' => 'npcs']); + callEditorMethod($editor, 'dispatchInput', '/'); + foreach (['N', 'P', 'C', "\n", "\n"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect(getEditorProperty($editor, 'editingMode'))->toBe('npc') + ->and(callEditorMethod($editor, 'getCanvasLayerTitle'))->toBe(' [NPCs]'); + // Choosing a layer again leaves NPC mode. + callEditorMethod($editor, 'dispatchInput', "\033"); + callEditorMethod($editor, 'dispatchInput', 'L'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(getEditorProperty($editor, 'editingMode'))->not->toBe('npc'); +}); + +it('chooses the layer to edit from its own picker with L, and L still paints in Paint mode', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'dispatchInput', 'L'); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Layer') + ->and(array_column($entries, 'value'))->toBe([...array_column(callEditorMethod($editor, 'getTerminalCanvasLayers'), 'id'), 'npcs']) + ->and(array_column($entries, 'label'))->toContain('Events') + ->and($entries[0]['description'])->toContain('editing'); + // Down to the next layer, then Enter edits it. + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($entries[1]['value']) + ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([]); + // Esc leaves the active layer alone. + callEditorMethod($editor, 'dispatchInput', 'L'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($entries[1]['value']); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', 'L'); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('L') + ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([]); +}); + +it('offers one layer picker entry in the command palette instead of an entry per layer', function () { + [$editor] = layeredCanvasEditor(); + $labels = array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildLayerPaletteItems')); + expect($labels)->toContain('Layers: Choose the layer to edit') + ->and(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'Layer: ') || str_starts_with($label, 'Visibility: ')))->toBe([]); +}); + +it('paints typed glyphs after entering Paint mode with the Select tool left active', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + // s picks Select in Normal mode; entering Paint mode must still paint, + // not silently change the brush symbol. + callEditorMethod($editor, 'dispatchInput', 's'); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', '-'); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('-') + ->and(getEditorProperty($editor, 'canvasTool'))->toBe(CanvasTool::BRUSH) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('PAINT: every key paints its glyph'); +}); + +it('says what typing does in Paint mode under a shape tool, and leaves Paint mode for Select', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', 'l'); + callEditorMethod($editor, 'dispatchInput', 'i'); + $before = $map->getLayerSymbol('map:4', 0, 0); + callEditorMethod($editor, 'dispatchInput', '-'); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe($before) + ->and(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('-') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('PAINT Line: keys choose the glyph'); + callEditorMethod($editor, 'selectCanvasTool', CanvasTool::SELECT); + expect(getEditorProperty($editor, 'inputMode'))->toBe('normal'); +}); + +it('uses colour selection clipboard shapes fill eyedropper and undo on gameplay and event layers', function (string $id) { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', $id); + callEditorMethod($editor, 'applyCanvasWrites', $map, [ + ['x' => 0, 'y' => 0, 'symbol' => 'Q', 'color' => 'red'], + ['x' => 1, 'y' => 0, 'symbol' => 'R', 'color' => 'blue'], + ], 'Layer test'); + $before = $map->captureGridSnapshot(); + setEditorProperty($editor, 'canvasSelection', ['x' => 0, 'y' => 0, 'width' => 2, 'height' => 1]); + callEditorMethod($editor, 'openColorPicker'); + setEditorProperty($editor, 'colorPaletteIndex', 5); // yellow + callEditorMethod($editor, 'dispatchInput', "\n"); + expect($map->getLayerSymbol($id, 0, 0))->toBe('Q') + ->and($map->getLayerColor($id, 0, 0))->toBe('yellow') + ->and($map->getLayerColor($id, 1, 0))->toBe('yellow'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'captureCanvasSelection'); + setEditorProperty($editor, 'cursorY', 1); + callEditorMethod($editor, 'pasteCanvasClipboard'); + expect($map->getLayerSymbol($id, 0, 1))->toBe('Q') + ->and($map->getLayerColor($id, 0, 1))->toBe('red'); + callEditorMethod($editor, 'pickSymbolUnderCursor'); + expect(getEditorProperty($editor, 'selectedPaintSymbol'))->toBe('Q') + ->and(getEditorProperty($editor, 'selectedPaintColor'))->toBe('red'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'selectCanvasTool', CanvasTool::LINE); + setEditorProperty($editor, 'cursorX', 0); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + setEditorProperty($editor, 'cursorX', 3); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('Q'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + setEditorProperty($editor, 'selectedPaintSymbol', 'F'); + callEditorMethod($editor, 'floodFillFromCursor'); + expect($map->getLayerSymbol($id, 3, 1))->toBe('F'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); +})->with(['map:1', 'map:4', 'event']); + +it('keeps mouse strokes layer-bound through undo and a later layer switch', function (string $id) { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', $id); + callEditorMethod($editor, 'enterPaintMode'); + setEditorProperty($editor, 'selectedPaintSymbol', 'M'); + setEditorProperty($editor, 'selectedPaintColor', 'cyan'); + $before = $map->captureGridSnapshot(); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + 2, $bounds['top'])); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<32;%d;%dM", $bounds['left'] + 2, $bounds['top'] + 1)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + 2, $bounds['top'] + 1)); + expect($map->getLayerSymbol($id, 2, 1))->toBe('M') + ->and($map->getLayerColor($id, 2, 1))->toBe('cyan'); + callEditorMethod($editor, 'selectCanvasLayer', $id === 'map:1' ? 'map:4' : 'map:1'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\x19"); + expect($map->getLayerSymbol($id, 2, 1))->toBe('M'); +})->with(['map:1', 'map:4', 'event']); + +it('renders readable gameplay immediately and keeps visibility and dimming session-only', function () { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + $frame = renderEditorPlainFrame($editor, 160, 45); + expect($frame)->toContain('./..', '.xx.') + ->not->toContain('d/..', '.xxd', 'Terminal preview', 't:Terminal'); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', 'v'); + callEditorMethod($editor, 'dispatchInput', 'd'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('....') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and($map->isDirty())->toBeFalse(); + callEditorMethod($editor, 'dispatchInput', 'v'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('./..', '.xx.') + ->and($map->captureLayerSnapshot())->toBe($before); +}); + +it('edits terminal glyphs immediately through undo redo save and reload without changing graphical bytes', function (bool $invalidGraphics) { + $root = layeredMapProject(); + if ($invalidGraphics) { + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, preg_replace("/'width' => 16/", "'width' => 0", file_get_contents($path), 1)); + } + [$editor, $map] = layeredCanvasEditor($root); + $before = sourceHashTree($map->directory); + $graphicalSource = file_get_contents($map->dataPath); + $decorationSource = file_get_contents($map->directory . '/layers/07.detail.deco.php'); + foreach (['i', 'Z', "\033", "\x13"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(array_keys(array_diff_assoc(sourceHashTree($map->directory), $before)))->toBe(['layers/01.terrain.map.php']); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($map->directory))->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + callEditorMethod($editor, 'dispatchInput', "\x12"); + $reloaded = callEditorMethod($editor, 'getSelectedMap'); + expect($reloaded)->not->toBe($map) + ->and($reloaded->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(loadLayeredMap($root)->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and(file_get_contents($reloaded->dataPath))->toBe($graphicalSource) + ->and(file_get_contents($reloaded->directory . '/layers/07.detail.deco.php'))->toBe($decorationSource) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..'); +})->with(['valid graphical source' => false, 'invalid graphical source' => true]); + +it('does not expose a read-only terminal toggle or crop painting warnings', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'dispatchInput', 't'); + foreach (['i', 'x', "\033"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect($map->getLayerSymbol('map:4', 0, 0))->toBe('x') + ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Terminal preview', 'Crop mapping', 'cell overrides'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' '); +}); + +it('excludes graphical layers and Tile art from the shell palette and inspector', function () { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + $labels = implode('\n', array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildLayerPaletteItems'))); + expect($labels)->toContain('Create gameplay layer') + ->not->toContain('detail', 'decoration', 'Tile art', 'Terminal preview'); + // Layers are chosen in their own picker, which offers no graphical layer either. + callEditorMethod($editor, 'openCanvasLayerPicker'); + $picked = implode('\n', array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label')); + expect($picked)->toContain('Terrain', 'Buildings', 'Events')->not->toContain('Detail', 'decoration'); + callEditorMethod($editor, 'dispatchInput', "\033"); + foreach (['map:1', 'map:4', 'event'] as $id) { + callEditorMethod($editor, 'selectCanvasLayer', $id); + $fields = json_encode(callEditorMethod($editor, 'getInspectorFields')); + expect($fields)->not->toContain('tile-art', 'Tile art', 'tiles2d', 'shared.png', 'detail.png'); + expect(json_encode(callEditorMethod($editor, 'getLayerInspectorFields')))->not->toContain('detail'); + } + callEditorMethod($editor, 'dispatchInput', "\x10"); + foreach (str_split('Tile art: Edit selected cell') as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(method_exists($editor, 'openCellTileArt'))->toBeFalse() + ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Layer atlas', 'Apply override') + ->and($map->captureLayerSnapshot())->toBe($before); +}); + +it('keeps NPC authoring and event overlays readable on the terminal canvas', function () { + [$editor, $map] = layeredCanvasEditor(); + $map->setNpcs(NpcCollection::fromMapData([ + ['id' => 'resident', 'name' => 'Resident', 'sprite' => 'N', 'x' => 0, 'y' => 0], + ])); + $before = $map->captureLayerSnapshot(); + callEditorMethod($editor, 'dispatchInput', 'n'); + expect(getEditorProperty($editor, 'editingMode'))->toBe('npc') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('[N].', '.xx.', 'Name: Resident'); + callEditorMethod($editor, 'dispatchInput', "\033"); + callEditorMethod($editor, 'dispatchInput', 'e'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('event') + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('./.E', '.xx.') + ->and($map->captureLayerSnapshot())->toBe($before); +}); + +it('refuses hidden decoration selection creation and stale graphical layer requests in the shell', function (string $action) { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + $disk = sourceHashTree($map->directory); + callEditorMethod($editor, 'selectCanvasLayer', 'map:7'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1'); + callEditorMethod($editor, 'openLayerPrompt', 'decoration'); + expect(getEditorProperty($editor, 'layerPrompt'))->toBeNull(); + setEditorProperty($editor, 'layerPrompt', ['action' => $action, 'id' => 'map:7', 'name' => 'hidden-art']); + getEditorProperty($editor, 'modals')->push(Modal::LAYER_EDIT); + callEditorMethod($editor, 'dispatchInput', $action === 'remove' ? 'y' : "\r"); + expect($map->captureLayerSnapshot())->toBe($before) + ->and(sourceHashTree($map->directory))->toBe($disk) + ->and($map->isDirty())->toBeFalse(); +})->with(['remove', 'rename', 'decoration']); + +it('ignores stale graphical selection and terminal read-only state when painting gameplay', function () { + [$editor, $map] = layeredCanvasEditor(); + $before = $map->captureLayerSnapshot(); + setEditorProperty($editor, 'canvasLayerStates', [$map->mapId => ['selected' => 'map:7', 'terminal' => true]]); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1'); + foreach (['i', 'Z', "\033"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('Z') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d') + ->and($map->getEditableData())->toBe($before['data']) + ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('Z/..') + ->not->toContain('Terminal preview'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureLayerSnapshot())->toBe($before); +}); + +it('requires explicit collision-change confirmation before renaming and preserves local crop source bytes', function (bool $changes) { + [$editor, $map, $root] = layeredCanvasEditor(); + $dictionaryPath = $root . '/assets/Maps/collisions.php'; + file_put_contents($dictionaryPath, ' ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID], "houses" => ["x" => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::' . ($changes ? 'PASS_THROUGH' : 'SOLID') . '], "." => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE];'); + $disk = sourceHashTree($root); + $before = $map->captureLayerSnapshot(); + $dataSource = file_get_contents($map->dataPath); + callEditorMethod($editor, 'selectCanvasLayer', 'map:4'); + callEditorMethod($editor, 'openLayerPrompt', 'rename'); + setEditorProperty($editor, 'layerPrompt', ['action' => 'rename', 'id' => 'map:4', 'name' => 'houses']); + callEditorMethod($editor, 'dispatchInput', "\n"); + if ($changes) { + expect($map->captureLayerSnapshot())->toBe($before) + ->and(sourceHashTree($root))->toBe($disk) + ->and(getEditorProperty($editor, 'layerPrompt')['confirmation'])->toContain('changes collision at 2 cell(s)', 'collisions.php stays unchanged'); + expect(renderEditorPlainFrame($editor, 160, 45))->toContain('Y:Confirm collision change'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', 'y'); + } + expect(getEditorProperty($editor, 'layerPrompt'))->toBeNull() + ->and(array_column($map->getLayers(), 'name'))->toContain('houses'); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe(str_replace("'buildings' =>", "'houses' =>", $dataSource)) + ->and(hash_file('sha256', $dictionaryPath))->toBe($disk['assets/Maps/collisions.php']); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($dataSource) + ->and(is_file($map->directory . '/layers/04.buildings.map.php'))->toBeTrue(); +})->with([false, true]); + +it('creates renames and removes layers through modal controls as undoable operations', function () { + [$editor, $map] = layeredCanvasEditor(); + callEditorMethod($editor, 'openLayerPrompt', 'create'); + foreach (str_split('fixtures') as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + callEditorMethod($editor, 'dispatchInput', "\n"); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:8'); + $map->save(); + expect(is_file($map->directory . '/layers/08.fixtures.map.php'))->toBeTrue(); + callEditorMethod($editor, 'openLayerPrompt', 'remove'); + callEditorMethod($editor, 'dispatchInput', "\n"); + expect($map->getLayers())->toHaveCount(5); + callEditorMethod($editor, 'dispatchInput', 'y'); + expect($map->getLayers())->toHaveCount(4); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->getLayers())->toHaveCount(5); +}); + +it('reads facade brushes from the catalogue on every stamp and preserves multi-row shapes in one undo step', function () { + [$editor, $map, $root] = layeredCanvasEditor(); + $directory = $root . '/assets/Graphics/Tilesets'; + mkdir($directory, 0777, true); + $path = $directory . '/buildings.txt'; + file_put_contents($path, " /\nxx\n\nabc\n def\n"); + expect(FacadeCatalogue::load($path))->toHaveCount(2); + $items = callEditorMethod($editor, 'buildLayerPaletteItems'); + expect(array_filter($items, static fn($item): bool => str_starts_with($item->label, 'Facade:')))->toHaveCount(2); + callEditorMethod($editor, 'selectFacadeBrush', $path, 0); + $before = $map->captureGridSnapshot(); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('x'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + file_put_contents($path, " /\n||\n\nabc\n def\n"); + callEditorMethod($editor, 'applyCanvasToolAtCursor'); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|') + ->and($map->getLayerSymbol('map:1', 0, 1))->toBe('.'); +}); + +it('keeps facade mouse clicks selection-only in Normal mode and stamps explicitly or in Paint mode', function () { + [$editor, $map, $root] = layeredCanvasEditor(); + mkdir($root . '/assets/Graphics/Tilesets', 0777, true); + $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; + file_put_contents($path, " /\n||\n"); + callEditorMethod($editor, 'selectFacadeBrush', $path, 0); + $before = $map->captureGridSnapshot(); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + $click = sprintf("\033[<0;%d;%dM", $bounds['left'], $bounds['top']); + $release = sprintf("\033[<0;%d;%dm", $bounds['left'], $bounds['top']); + callEditorMethod($editor, 'dispatchInput', $click); + callEditorMethod($editor, 'dispatchInput', $release); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', $click); + callEditorMethod($editor, 'dispatchInput', $release); + expect($map->getLayerSymbol('map:4', 0, 1))->toBe('|'); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + expect($map->captureGridSnapshot())->toBe($before); +}); + +it('clears facade brushes on legacy mode commands and asset navigation', function (string $command) { + $root = layeredMapProject(); + loadLayeredMap($root)->duplicateTo($root . '/assets/Maps/zz-copy', 'zz-copy', 'Copy'); + [$editor, $map] = layeredCanvasEditor($root); + mkdir($root . '/assets/Graphics/Tilesets', 0777, true); + $path = $root . '/assets/Graphics/Tilesets/buildings.txt'; + file_put_contents($path, " /\n||\n"); + callEditorMethod($editor, 'selectFacadeBrush', $path, 0); + $before = $map->captureLayerSnapshot(); + if ($command === 'asset') { + setEditorProperty($editor, 'focusedPane', 'assets'); + callEditorMethod($editor, 'dispatchInput', 'j'); + expect(getEditorProperty($editor, 'selectedAssetIndex'))->toBe(1); + } else { + callEditorMethod($editor, 'dispatchInput', $command); + } + expect(getEditorProperty($editor, 'facadeBrush'))->toBeNull() + ->and($map->captureLayerSnapshot())->toBe($before); + callEditorMethod($editor, 'stampFacadeBrush'); + expect($map->captureLayerSnapshot())->toBe($before); +})->with(['m', 'e', 'n', 'asset']); diff --git a/tests/Unit/LayeredMapTest.php b/tests/Unit/LayeredMapTest.php new file mode 100644 index 00000000..38fd6335 --- /dev/null +++ b/tests/Unit/LayeredMapTest.php @@ -0,0 +1,284 @@ +toBe(['Terrain', 'Fixtures', 'Upper Floor', 'North Wing', 'Events']); +}); + +it('loads ordered gameplay decoration and event layers through the Engine source contract', function () { + $map = loadLayeredMap(layeredMapProject()); + expect(array_column($map->getLayers(), 'name'))->toBe(['terrain', 'buildings', 'detail', 'Events']) + ->and(array_column($map->getLayers(), 'order'))->toBe([1, 4, 7, null]) + ->and($map->getLayerSymbol('map:4', 1, 0))->toBe('/') + ->and($map->getLayerSymbol('map:7', 0, 0))->toBe('d'); + $terminal = array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, terminalPreview: true)); + expect($terminal)->toBe(['./..', '.xx.']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2)))->toBe(['./.E', '.xx.']); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false)))->toBe($terminal); + foreach ([true, false] as $visible) { + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:7' => $visible])))->toBe(['./.E', '.xx.']); + } + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['map:4' => false])))->toBe(['...E', '....']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, layerVisibility: ['event' => false])))->toBe($terminal); + expect(implode('', $map->renderPreview(4, 2, activeLayer: 'map:4', dimInactive: true)))->toContain("\033[2m") + ->and(implode('', $map->renderPreview(4, 2, terminalPreview: true, layerVisibility: ['map:4' => false], dimInactive: true)))->not->toContain("\033[2m"); +}); + +it('keeps events and NPC overlays above terminal gameplay without exposing decoration', function () { + $map = loadLayeredMap(layeredMapProject()); + $map->setNpcs(NpcCollection::fromMapData([ + ['id' => 'resident', 'name' => 'Resident', 'sprite' => 'N', 'x' => 0, 'y' => 0], + ])); + $before = $map->captureLayerSnapshot(); + expect(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true)))->toBe(['N/.E', '.xx.']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true)))->toBe(['N/..', '.xx.']) + ->and(array_map(TerminalText::stripAnsi(...), $map->renderPreview(4, 2, showNpcOverlay: true, layerVisibility: ['map:4' => false])))->toBe(['N..E', '....']) + ->and($map->captureLayerSnapshot())->toBe($before); +}); + +it('does not write any untouched layer and preserves source scaffolding and unchanged colour-run rows', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $path = $map->directory . '/layers/01.terrain.map.php'; + $source = file_get_contents($path); + foreach ($map->getStoredGridPaths() as $file) { + touch($file, 1000000000); + } + $before = sourceHashTree($map->directory); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($before); + $map->setLayerCell('map:1', 0, 0, 'T', '', ''); + $map->setLayerCell('map:4', 0, 0, '|'); + $map->save(); + expect(file_get_contents($path))->toContain('// keep 01.terrain.map.php', "return <<<'AUTHORED'", '....') + ->and(filemtime($map->directory . '/layers/07.detail.deco.php'))->toBe(1000000000) + ->and(filemtime($map->eventPath))->toBe(1000000000); + $map->setLayerCell('map:1', 0, 0, '.', '', ''); + $map->save(); + expect(file_get_contents($path))->toBe($source); +}); + +it('retains CRLF source indentation comments and unedited rows', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; + $source = "setLayerCell('map:4', 2, 0, '|'); + $map->save(); + expect(file_get_contents($path))->toBe(str_replace(' / ', ' / | ', $source)); +}); + +it('preserves ragged per-row dimensions when creating and round-tripping layers', function () { + $root = layeredMapProject(true); + $map = loadLayeredMap($root); + $id = $map->createLayer('fixtures'); + $map->setLayerCell($id, 1, 1, 'i'); + $map->save(); + $loaded = loadLayeredMap($root); + foreach ($loaded->getLayerSet()->layers as $layer) { + expect(array_map($layer->getRowLength(...), range(0, $layer->height - 1)))->toBe([4, 2]); + } + expect($loaded->getLayerSymbol($id, 1, 1))->toBe('i'); +}); + +it('rolls the complete layered save and rename back after any installation failure', function () { + $map = loadLayeredMap(layeredMapProject()); + $before = sourceHashTree($map->directory); + $map->setLayerCell('map:1', 0, 0, 'T'); + $map->renameLayer('map:4', 'houses'); + $failing = new FailingFileSetOperations(failures: ['move' => [$map->directory . '/layers/04.houses.map.php']]); + expect(fn() => $map->save(files: $failing))->toThrow(FileSetTransactionFailure::class) + ->and(sourceHashTree($map->directory))->toBe($before) + ->and($map->isDirty())->toBeTrue(); + $map->save(); + expect(is_file($map->directory . '/layers/04.houses.map.php'))->toBeTrue() + ->and(is_file($map->directory . '/layers/04.buildings.map.php'))->toBeFalse(); +}); + +it('duplicates and moves every layer preserving authored bytes and numeric order', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($map->directory . '/layers'); + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); + expect(sourceHashTree($root . '/assets/Maps/copy/layers'))->toBe($before); + $moved = $map->moveTo('district/new'); + expect($moved->mapId)->toBe('district/new') + ->and(sourceHashTree($moved->directory . '/layers'))->toBe($before) + ->and(is_dir($map->directory))->toBeFalse() + ->and(is_file($moved->directory . '/new.map.php'))->toBeFalse(); +}); + +it('refuses an executable unchanged or removed layer before save duplicate or move without executing it', function (string $action) { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $map->removeLayer('map:7'); + $path = $map->directory . '/layers/07.detail.deco.php'; + file_put_contents($path, " match ($action) { + 'save' => $map->save(), + 'duplicate' => $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'), + 'move' => $map->moveTo('new'), + })->toThrow(MapSourceRefusal::class, '07.detail.deco.php'); + expect(sourceHashTree($root))->toBe($before)->and(is_file($root . '/executed'))->toBeFalse(); +})->with(['save', 'duplicate', 'move']); + +it('isolates a malformed layer and refuses mismatched dimensions before evaluating data', function () { + $root = layeredMapProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/layers/07.detail.deco.php', MapGridSource::buildSource('too short', 'BAD')); + file_put_contents($directory . '/test-map.data.php', "maps)->toHaveCount(2) + ->and($workspace->maps[1]->getGridSourceIssue())->toContain('07.detail.deco.php') + ->and($workspace->maps[0]->getGridSourceIssue())->toBeNull() + ->and(is_file($root . '/executed'))->toBeFalse(); +}); + +it('rejects externally changed or newly added layers without overwriting author work', function (bool $added) { + $map = loadLayeredMap(layeredMapProject()); + $map->setLayerCell('map:1', 0, 0, 'X'); + $pending = $map->captureLayerSnapshot(); + $path = $map->directory . '/layers/' . ($added ? '09.other.map.php' : '04.buildings.map.php'); + file_put_contents($path, MapGridSource::buildSource("new \n ", 'OTHER')); + $before = sourceHashTree($map->directory); + expect(fn() => $map->setLayerCell('map:1', 1, 0, 'Y'))->toThrow(MapSourceRefusal::class) + ->and(fn() => $map->save())->toThrow(MapSourceRefusal::class) + ->and($map->captureLayerSnapshot())->toBe($pending) + ->and($map->isDirty())->toBeTrue() + ->and(sourceHashTree($map->directory))->toBe($before); +})->with([false, true]); + +it('sets many cells as one edit, and refuses the whole edit when a layer changed on disk', function () { + $map = loadLayeredMap(layeredMapProject()); + $map->setLayerCells('map:1', [ + ['x' => 0, 'y' => 0, 'symbol' => 'A', 'prefix' => '', 'suffix' => ''], + ['x' => 1, 'y' => 0, 'symbol' => 'B'], + ['x' => 99, 'y' => 99, 'symbol' => 'C'], + ]); + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('A') + ->and($map->getLayerCellStyle('map:1', 0, 0))->toBe(['prefix' => '', 'suffix' => '']) + ->and($map->getLayerSymbol('map:1', 1, 0))->toBe('B'); + + $pending = $map->captureLayerSnapshot(); + file_put_contents($map->directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("new \n ", 'OTHER')); + expect(fn() => $map->setLayerCells('map:1', [['x' => 0, 'y' => 1, 'symbol' => 'Y'], ['x' => 1, 'y' => 1, 'symbol' => 'Z']])) + ->toThrow(MapSourceRefusal::class) + ->and($map->captureLayerSnapshot())->toBe($pending); +}); + +it('writes a stroke as one edit in which each write sees the writes before it', function () { + $map = loadLayeredMap(layeredMapProject()); + $original = [$map->getLayerSymbol('map:1', 0, 0), $map->getLayerCellStyle('map:1', 0, 0)]; + [$stroke, $changed] = CanvasEditor::writeCells($map, 'map:1', [ + ['x' => 0, 'y' => 0, 'symbol' => 'A', 'style' => ['prefix' => '', 'suffix' => '']], + ['x' => 1, 'y' => 0, 'symbol' => 'B', 'style' => ['prefix' => '', 'suffix' => '']], + ['x' => 0, 'y' => 0, 'symbol' => 'C', 'style' => ['prefix' => '', 'suffix' => '']], + ], 'Paint'); + expect($changed)->toBe(3) + ->and($map->getLayerSymbol('map:1', 0, 0))->toBe('C') + ->and($map->getLayerCellStyle('map:1', 0, 0))->toBe(['prefix' => '', 'suffix' => '']) + ->and($map->getLayerSymbol('map:1', 1, 0))->toBe('B'); + $stroke->undo(); + expect([$map->getLayerSymbol('map:1', 0, 0), $map->getLayerCellStyle('map:1', 0, 0)])->toBe($original); + $stroke->execute(); + expect($map->getLayerSymbol('map:1', 0, 0))->toBe('C'); +}); + +it('converts legacy only when a layer is created and undo restores the original file shape', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Maps/test-map/test-map.map.php'; + file_put_contents($path, str_replace(['', ''], ['', ''], file_get_contents($path))); + $map = authoredMap($root); + $before = $map->captureLayerSnapshot(); + $source = file_get_contents($map->mapPath); + $map->createLayer('buildings'); + $map->save(); + expect(is_file($map->mapPath))->toBeFalse() + ->and(file_get_contents($map->directory . '/layers/00.terrain.map.php'))->toBe($source); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(file_get_contents($map->mapPath))->toBe($source) + ->and(authoredMap($root)->isLegacyMap())->toBeTrue(); +}); + +it('composes terminal preview with the exact shared Engine style and transparency semantics', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; + file_put_contents($path, MapGridSource::buildSource(" / \n xx ", 'STYLE')); + $map = loadLayeredMap($root); + $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $map->getLayerSet()->getComposedGrid()); + expect($map->renderPreview(4, 2, showNpcOverlay: true, terminalPreview: true))->toBe($expected); + $map->setLayerCell('map:4', 2, 0, 'x', '', ''); + $expected = array_map(static fn(array $row): string => rtrim(implode('', $row)), $map->getLayerSet()->getComposedGrid()); + expect($map->renderPreview(4, 2, terminalPreview: true))->toBe($expected); +}); + +it('restores removed layer bytes through undo after save', function () { + $root = layeredMapProject(); + $map = loadLayeredMap($root); + $before = $map->captureLayerSnapshot(); + $files = sourceHashTree($map->directory); + $map->removeLayer('map:7'); + $map->save(); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($files); +}); + +it('validates combined named and flat collision fallback through the shared resolver', function () { + $root = layeredMapProject(); + file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' + CollisionType::NONE, 'x' => CollisionType::PASS_THROUGH, + 'buildings' => ['/' => CollisionType::NONE]]; +PHP); + $map = loadLayeredMap($root); + $map->validateLayerContracts(); + $dictionary = require $root . '/assets/Maps/collisions.php'; + expect(Ichiloto\Engine\Field\MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary))->toBe([[0, 0, 0, 0], [0, 0, 0, 0]]); + file_put_contents($root . '/assets/Maps/collisions.php', " []];"); + expect(fn() => $map->validateLayerContracts())->toThrow(InvalidArgumentException::class, 'Decoration'); +}); + +it('rolls every layered source back when a move changes relative data evaluation', function () { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'region' => ''", "'region' => basename(__DIR__)", file_get_contents($path))); + $map = loadLayeredMap($root); + $before = sourceHashTree($root); + expect(fn() => $map->moveTo('district/new'))->toThrow(RuntimeException::class, 'rolled back') + ->and(sourceHashTree($root))->toBe($before) + ->and(is_dir($root . '/assets/Maps/district'))->toBeFalse(); +}); + +it('keeps raw ANSI styling separate from editable glyphs and preserves untouched source rows', function (string $prefix, string $color) { + $root = layeredMapProject(); + $path = $root . '/assets/Maps/test-map/layers/04.buildings.map.php'; + $source = MapGridSource::buildSource($prefix . " / \033[0m\n xx ", 'ANSI'); + file_put_contents($path, $source); + $map = loadLayeredMap($root); + expect($map->getLayerSymbol('map:4', 1, 0))->toBe('/') + ->and($map->getLayerColor('map:4', 1, 0))->toBe($color); + $map->setLayerCell('map:4', 0, 1, '|'); + $map->save(); + expect(file_get_contents($path))->toBe(str_replace(' xx ', '|xx ', $source)); +})->with([ + ["\033[31m", 'red'], ["\033[38;2;12;34;56m", '#0c2238'], + ["\033[38;5;196m", '#ff0000'], ["\033[38;5;232m", '#080808'], +]); diff --git a/tests/Unit/LayeredPreviewTest.php b/tests/Unit/LayeredPreviewTest.php new file mode 100644 index 00000000..9c43bdf2 --- /dev/null +++ b/tests/Unit/LayeredPreviewTest.php @@ -0,0 +1,79 @@ + CollisionType::NONE, + 'buildings' => ['/' => CollisionType::SOLID, 'x' => CollisionType::PASS_THROUGH]]; +PHP); + $definition = CutsceneHydration::cinematic(['id' => 'preview', 'name' => 'Preview'], + [['type' => 'wait', 'seconds' => 1]], $root); + $preview = CinematicPreviewSession::start($root, $definition, ['mapId' => 'test-map', 'x' => 0, 'y' => 0]); + try { + $scene = new ReflectionProperty($preview, 'scene')->getValue($preview); + $manager = $scene->previewMap; + expect($manager->layers?->legacy)->toBeFalse() + ->and(new ReflectionProperty(MapManager::class, 'collisionMap')->getValue($manager))->toBe([[0, 1, 0, 0], [0, 0, 0, 0]]); + $camera = $scene->previewCamera(); + ob_start(); + $frame = $preview->frame(); + $output = ob_get_clean(); + // The preview's picture is captured, never written over the editor's screen. + expect($output)->toBe('') + // The layers composed, with the player standing on the map's first cell. + ->and(implode("\n", array_map(Ichiloto\Engine\IO\Console\TerminalText::stripAnsi(...), $frame)))->toContain('/..', '.xx.'); + $manager->unload(); + expect($manager->layers)->toBeNull() + ->and($manager->tileMap)->toBe([]) + ->and($manager->mapWidth)->toBe(0) + ->and($manager->mapHeight)->toBe(0) + ->and($camera->worldSpace)->toBe([]); + } finally { + $preview->dispose(); + } +}); + +it('plays the map\'s own field effects in the preview, as the game\'s field has them in either presentation', function () { + $root = layeredMapProject(); + file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', + Ichiloto\Engine\Field\MapGridSource::buildSource(" \n ", 'EVENT')); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'fieldEffects' => [['id' => 'glow', 'effect' => 'energy', 'anchor' => ['cell' => ['x' => 1, 'y' => 0]]]],", + (string) file_get_contents($data))); + writeTilesetTestPng($root . '/assets/Graphics/strip.png', 8, 2); + mkdir($root . '/assets/Animations/energy', 0o777, true); + file_put_contents($root . '/assets/Animations/energy/energy.timeline.php', ' 4, 'lengthFrames' => 4, 'playback' => 'loop', 'restFrame' => 2, + 'tracks' => [['id' => 'motes', 'type' => 'image', 'asset' => 'Graphics/strip.png', + 'sheet' => ['columns' => 4, 'rows' => 1], 'cells' => ['width' => 1, 'height' => 1], 'depth' => 'front', + 'keyframes' => array_map(static fn(int $frame): array => ['frame' => $frame, 'sourceFrame' => $frame], range(0, 3))]]], true) . ';'); + $definition = CutsceneHydration::cinematic(['id' => 'preview', 'name' => 'Preview'], [['type' => 'wait', 'seconds' => 1]], $root); + $preview = CinematicPreviewSession::start($root, $definition, ['mapId' => 'test-map', 'x' => 0, 'y' => 0]); + try { + $scene = new ReflectionProperty($preview, 'scene')->getValue($preview); + expect($scene->fieldEffects->count)->toBe(1); + // The preview's clock moves the map's effects as a game frame does, the cinematic's commands aside. + $glow = static fn(): int => new ReflectionProperty($scene->fieldEffects, 'sessions')->getValue($scene->fieldEffects)['map-glow']->playback->currentFrame; + $before = $glow(); + $preview->step(0.5); + expect($glow())->not->toBe($before); + // The graphical view's field has the same effect, drawn its own way. + $preview->exchangeScene([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', + 'graphical_canvas', 'canvas_overlay', 'frame_viewport', 'field_motion']])], $preview->getSceneSessionId()); + expect($scene->fieldEffects->count)->toBe(1); + $preview->detachScene(); + expect($scene->fieldEffects->count)->toBe(1); + } finally { + $preview->dispose(); + } +}); diff --git a/tests/Unit/ListFilterTest.php b/tests/Unit/ListFilterTest.php index 948100e3..7b142637 100644 --- a/tests/Unit/ListFilterTest.php +++ b/tests/Unit/ListFilterTest.php @@ -149,12 +149,12 @@ function filterEditor(): Editor // Oracle is index 1 in classes.php; the selection follows the filter. expect(callEditorMethod($editor, 'getVisibleDatabaseEntryIndexes'))->toBe([1]) - ->and(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(1); + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(1); // Down inside a one-row filter cannot walk onto a hidden entry. callEditorMethod($editor, 'dispatchInput', "\033[B"); - expect(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(1); + expect(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(1); callEditorMethod($editor, 'dispatchInput', "\033"); diff --git a/tests/Unit/ListNavigationTest.php b/tests/Unit/ListNavigationTest.php index 97f66ac5..e1f80be0 100644 --- a/tests/Unit/ListNavigationTest.php +++ b/tests/Unit/ListNavigationTest.php @@ -112,28 +112,6 @@ } }); -it('wraps the animation frame list', function (): void { - $root = makeTemporaryProject(); - - try { - $editor = deletionEditor($root); - openDatabaseCategory($editor, 'animations'); - $animation = callEditorMethod($editor, 'getSelectedAnimation'); - $maxFrames = $animation->maxFrames; - - expect($maxFrames)->toBeGreaterThan(1) - ->and(getEditorProperty($editor, 'databaseSelectedFrameIndex'))->toBe(1); - - callEditorMethod($editor, 'moveDatabaseFrameSelection', -1); - expect(getEditorProperty($editor, 'databaseSelectedFrameIndex'))->toBe($maxFrames); - - callEditorMethod($editor, 'moveDatabaseFrameSelection', 1); - expect(getEditorProperty($editor, 'databaseSelectedFrameIndex'))->toBe(1); - } finally { - removeDirectoryRecursively($root); - } -}); - it('wraps option values when cycling past either end', function (): void { $root = makeTemporaryProject(); diff --git a/tests/Unit/MapAreaTerminalTest.php b/tests/Unit/MapAreaTerminalTest.php new file mode 100644 index 00000000..a13865aa --- /dev/null +++ b/tests/Unit/MapAreaTerminalTest.php @@ -0,0 +1,101 @@ + 140, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + setEditorProperty($editor, 'focusedPane', 'canvas'); + callEditorMethod($editor, 'dispatchInput', 'a'); + + return $editor; +} + +/** Moves the canvas cursor to a cell and sends keys there. */ +function pressAreaKeys(Editor $editor, int $x, int $y, string ...$keys): void +{ + setEditorProperty($editor, 'cursorX', $x); + setEditorProperty($editor, 'cursorY', $y); + foreach ($keys as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } +} + +/** The editor's map's areas as written. */ +function readEditorAreas(Editor $editor): array +{ + return MapAreaAuthoring::readEntries(callEditorMethod($editor, 'getSelectedMap')); +} + +it('makes an area of cells selected by keyboard, writing a filled rectangle as one', function () { + [$root] = createLandmarkProject(); + $editor = areaEditor($root); + + expect(getEditorProperty($editor, 'editingMode'))->toBe('area'); + pressAreaKeys($editor, 0, 1, ' '); + pressAreaKeys($editor, 1, 1, ' '); + expect(callEditorMethod($editor, 'getCanvasPreviewCells'))->toBe([1 => [0 => null, 1 => null]]); + pressAreaKeys($editor, 1, 1, "\r", 'y', 'a', 'r', 'd', "\r"); + + expect(readEditorAreas($editor))->toBe([['id' => 'yard', 'area' => ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]]]) + ->and(getEditorProperty($editor, 'areaSelection'))->toBe([]) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Made area yard of 2 cells.'); + + // A rectangle by its corners, and a cell taken back, written as exact cells. + pressAreaKeys($editor, 1, 0, 'r'); + pressAreaKeys($editor, 2, 1, 'r'); + pressAreaKeys($editor, 2, 1, ' ', "\r", 'p', 'o', 'r', 'c', 'h', "\r"); + expect(readEditorAreas($editor)[1])->toBe(['id' => 'porch', 'area' => ['cells' => [[1, 0], [2, 0], [1, 1]]]]); +}); + +it('picks an area up with Enter to change its cells, steps through areas sharing a cell, and deletes one', function () { + [$root] = createLandmarkProject(); + $editor = areaEditor($root); + pressAreaKeys($editor, 0, 1, ' ', "\r", 'y', 'a', 'r', 'd', "\r"); + pressAreaKeys($editor, 0, 1, ' ', "\r", 'g', 'a', 't', 'e', "\r"); + + pressAreaKeys($editor, 0, 1, "\r"); + expect(getEditorProperty($editor, 'editedAreaId'))->toBe('yard'); + pressAreaKeys($editor, 0, 1, 'n'); + expect(getEditorProperty($editor, 'editedAreaId'))->toBe('gate'); + + pressAreaKeys($editor, 1, 1, ' ', "\r"); + expect(readEditorAreas($editor)[1])->toBe(['id' => 'gate', 'area' => ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]]); + + pressAreaKeys($editor, 1, 1, "\r", "\x7f"); + expect(array_column(readEditorAreas($editor), 'id'))->toBe(['yard']); + + callEditorMethod($editor, 'performUndo'); + expect(array_column(readEditorAreas($editor), 'id'))->toBe(['yard', 'gate']); +}); + +it('shows the areas in the map inspector and offers them as an event\'s subject', function () { + [$root] = createLandmarkProject(); + $editor = areaEditor($root); + pressAreaKeys($editor, 0, 1, ' ', "\r", 'y', 'a', 'r', 'd', "\r"); + $map = callEditorMethod($editor, 'getSelectedMap'); + $inspector = callEditorMethod($editor, 'createMapInspector', $map); + + $rows = array_values(array_filter($inspector->getMapFields($map), static fn(array $row): bool => ($row['target'] ?? null) === MapAreaFields::TARGET)); + $subject = array_find($inspector->getEventFields($map, 'E'), static fn(array $row): bool => ($row['target'] ?? null) === MapAreaFields::SUBJECT_TARGET); + + expect(array_column($rows, 'label'))->toBe(['Areas · 1', ' Area', ' Cells', ' Named By']) + ->and(array_column($rows, 'value'))->toBe(['', 'yard', 'x 0, y 1 · 1 x 1', 'nothing']) + ->and($subject['options'])->toBe(['', 'world_object:old-tower', 'area:yard']); +}); diff --git a/tests/Unit/MapGraphicsTest.php b/tests/Unit/MapGraphicsTest.php new file mode 100644 index 00000000..b7387d9a --- /dev/null +++ b/tests/Unit/MapGraphicsTest.php @@ -0,0 +1,497 @@ + The graphics and coverage issues reported for the test map. */ +function graphicsIssueLines(string $root, ?Severity $severity = null): array +{ + $map = loadLayeredMap($root); + $issues = array_filter( + [...MapGraphicsValidator::validate($map), ...MapGraphicsValidator::validateCoverage($map), ...MapCompletenessCheck::validate($map, null)], + static fn(Issue $issue): bool => $issue->where === 'test-map' && ($severity === null || $issue->severity === $severity), + ); + + return array_values(array_map(static fn(Issue $issue): string => $issue->message, $issues)); +} + +// -- Resize ------------------------------------------------------------------- + +it('resizes every tile layer with the map, cropping and padding with empty tiles', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $floor = $map->directory . '/graphics/01.floor.tiles.php'; + $decor = $map->directory . '/graphics/02.decor.tiles.php'; + + $map->resize(6, 3); + $map->save(); + + expect(file_get_contents($floor))->toBe("and(readTileRows($decor))->toBe([[0, 5, 0, 0, 0, 0], [0, 0, 0, 5, 0, 0], [0, 0, 0, 0, 0, 0]]); + + $map->resize(3, 1); + $map->save(); + $reloaded = loadLayeredMap($root); + $graphics = MapGraphics::loadFromDirectory($reloaded->directory, 'test-map', 'home', $reloaded->getLayerSet(), $root . '/assets'); + + expect(readTileRows($floor))->toBe([[2816, 2816, 2816]]) + ->and(readTileRows($decor))->toBe([[0, 5, 0]]) + ->and(array_map(static fn($layer): array => $layer->readTiles(), $graphics?->layers ?? []))->toBe([[[2816, 2816, 2816]], [[0, 5, 0]]]); +}); + +it('validates tile layer offsets in the map data as the Engine reads them', function () { + $root = mapGraphicsProject(); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = (string) file_get_contents($data); + + file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => ['decor' => ['offset' => [0, -0.5]]],", $source)); + expect(graphicsIssueLines($root, Severity::ERROR))->toBe([]); + + file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => ['rugs' => ['offset' => [0, 1]]],", $source)); + expect(graphicsIssueLines($root, Severity::ERROR))->toBe(["Map test-map tileLayers names 'rugs', which is not one of its tile layers."]); +}); + +it('resizes a ragged map\'s tile rows to the widths its terminal rows take', function () { + $root = mapGraphicsProject(ragged: true); + $map = loadLayeredMap($root); + + $map->resize(5, 2); + $map->save(); + $reloaded = loadLayeredMap($root); + + expect(readTileRows($map->directory . '/graphics/01.floor.tiles.php'))->toBe([[2816, 2816, 2816, 2816, 0], [2816, 2816, 0, 0, 0]]) + ->and(readTileRows($map->directory . '/graphics/02.decor.tiles.php'))->toBe([[0, 5, 0, 0, 0], [0, 5, 0, 0, 0]]) + ->and(MapGraphics::loadFromDirectory($reloaded->directory, 'test-map', 'home', $reloaded->getLayerSet(), $root . '/assets')) + ->not->toBeNull(); +}); + +it('undoes a resize back to the authored tile layer bytes', function () { + $root = mapGraphicsProject(); + [$editor, $map] = layeredCanvasEditor($root); + $before = sourceHashTree($map->directory); + $sources = $map->getTileLayerSources(); + callEditorMethod($editor, 'setEditingMode', 'map'); + + callEditorMethod($editor, 'applyInspectorFieldValue', ['label' => ' X', 'value' => '4', 'target' => 'map-size', 'field' => 'width'], '6'); + expect($map->getWidth())->toBe(6) + ->and($map->getTileLayerSources())->not->toBe($sources); + + callEditorMethod($editor, 'performUndo'); + expect($map->getWidth())->toBe(4) + ->and($map->getTileLayerSources())->toBe($sources); + + $map->save(); + expect(sourceHashTree($map->directory))->toBe($before); +}); + +it('keeps a tile layer\'s authored bytes when a resize returns it to its saved size', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($map->directory . '/graphics'); + + $map->resize(6, 3); + $map->resize(4, 2); + + expect($map->getTileLayerSources())->toBe(array_map(file_get_contents(...), array_combine( + array_keys($map->getTileLayerSources()), array_keys($map->getTileLayerSources()), + ))); + $map->save(); + expect(sourceHashTree($map->directory . '/graphics'))->toBe($before); +}); + +it('refuses a resize while a tile layer cannot be read, changing nothing', function (string $body) { + $root = mapGraphicsProject(); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', $body); + $map = loadLayeredMap($root); + $sources = $map->getTileLayerSources(); + + expect(fn() => $map->resize(6, 3))->toThrow(MapSourceRefusal::class, '02.decor.tiles.php'); + expect($map->getWidth())->toBe(4) + ->and($map->getHeight())->toBe(2) + ->and($map->getTileLayerSources())->toBe($sources) + ->and($map->isDirty())->toBeFalse(); +})->with([ + 'invalid identity' => ["0 5 0 abc\n0 0 0 5"], + 'short row' => ["0 5 0\n0 0 0 5"], + 'missing row' => ['0 5 0 0'], + // A field cell holds one whole tile; half-tile entries are refused, not reinterpreted. + 'half tile' => ["0 5L 5R 0\n0 0 0 5"], +]); + +it('carries graphics untouched through ordinary edits, and never lets an unreadable one block a save', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/graphics/02.decor.tiles.php', "setLayerCell('map:1', 0, 0, 'X'); + $map->setMapField('description', 'Edited in the TUI.'); + $map->save(); + + expect(sourceHashTree($directory . '/graphics'))->toBe($before); +}); + +it('refuses to save over tile layers changed or added after opening', function (bool $added) { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $map->resize(6, 3); + $pending = $map->captureLayerSnapshot(); + writeTileLayer($map->directory, $added ? '03.shadows.tiles.php' : '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + $before = sourceHashTree($map->directory); + + expect(fn() => $map->resize(7, 3))->toThrow(MapSourceRefusal::class, 'after opening') + ->and(fn() => $map->save())->toThrow(MapSourceRefusal::class, 'after opening') + ->and($map->captureLayerSnapshot())->toBe($pending) + ->and($map->isDirty())->toBeTrue() + ->and(sourceHashTree($map->directory))->toBe($before); +})->with(['changed' => false, 'added' => true]); + +// -- Duplicate, move and delete ------------------------------------------------- + +it('duplicates a map with its graphics, including an unsaved resize', function () { + $root = mapGraphicsProject(); + $workspace = ProjectWorkspace::fromProject($root); + $index = array_search('test-map', $workspace->mapIds, true); + $before = sourceHashTree($root . '/assets/Maps/test-map/graphics'); + + $workspace->duplicateMap($index); + expect(sourceHashTree($root . '/assets/Maps/test-map-copy/graphics'))->toBe($before); + + $map = $workspace->getMapByIndex($index); + $map->resize(5, 2); + $map->duplicateTo($root . '/assets/Maps/wider', 'wider', 'Wider'); + expect(readTileRows($root . '/assets/Maps/wider/graphics/01.floor.tiles.php'))->toBe([[2816, 2816, 2816, 2816, 0], [2816, 2816, 2816, 2816, 0]]) + ->and(sourceHashTree($root . '/assets/Maps/test-map/graphics'))->toBe($before, 'the original stays unsaved'); +}); + +it('moves a map with its graphics and leaves no folder behind', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $before = sourceHashTree($map->directory . '/graphics'); + + $moved = $map->moveTo('district/new'); + + expect(sourceHashTree($moved->directory . '/graphics'))->toBe($before) + ->and($moved->getTileLayerSources())->toHaveCount(2) + ->and(is_dir($map->directory))->toBeFalse(); +}); + +it('carries a tile layer the Engine cannot read byte for byte', function () { + $root = mapGraphicsProject(); + file_put_contents($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php', "directory . '/graphics'); + + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); + $moved = $map->moveTo('district/new'); + + expect(sourceHashTree($root . '/assets/Maps/copy/graphics'))->toBe($before) + ->and(sourceHashTree($moved->directory . '/graphics'))->toBe($before) + ->and(is_dir($map->directory))->toBeFalse(); +}); + +it('rolls a failed move back with its graphics in place', function () { + $root = mapGraphicsProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'region' => ''", "'region' => basename(__DIR__)", (string) file_get_contents($path))); + $map = loadLayeredMap($root); + $before = sourceHashTree($root); + + expect(fn() => $map->moveTo('district/new'))->toThrow(RuntimeException::class, 'rolled back') + ->and(sourceHashTree($root))->toBe($before) + ->and(is_dir($root . '/assets/Maps/test-map/graphics'))->toBeTrue() + ->and(is_dir($root . '/assets/Maps/district'))->toBeFalse(); +}); + +it('deletes a map\'s graphics with it, keeping the author\'s other files', function (bool $withNotes) { + $root = mapGraphicsProject(); + $notes = $root . '/assets/Maps/test-map/graphics/palette-notes.txt'; + if ($withNotes) { + file_put_contents($notes, "Warm floor.\n"); + } + $workspace = ProjectWorkspace::fromProject($root); + + $workspace->deleteMap(array_search('test-map', $workspace->mapIds, true)); + + expect(glob($root . '/assets/Maps/test-map/graphics/*.tiles.php') ?: [])->toBe([]) + ->and(is_file($notes))->toBe($withNotes) + ->and(is_dir($root . '/assets/Maps/test-map'))->toBe($withNotes); +})->with(['empty folder' => false, 'author notes' => true]); + +it('restores every member when deleting a tile layer fails', function () { + $root = mapGraphicsProject(); + $workspace = ProjectWorkspace::fromProject($root); + $before = sourceHashTree($root . '/assets/Maps/test-map'); + $failing = new FailingFileSetOperations(failures: ['remove' => [$root . '/assets/Maps/test-map/graphics/02.decor.tiles.php']]); + + expect(fn() => $workspace->deleteMap(array_search('test-map', $workspace->mapIds, true), $failing)) + ->toThrow(FileSetTransactionFailure::class) + ->and(sourceHashTree($root . '/assets/Maps/test-map'))->toBe($before); +}); + +// -- Map data ----------------------------------------------------------------- + +it('names a tileset through the source-preserving map data path', function () { + $root = layeredMapProject(); + writeTestTileset($root); + $map = loadLayeredMap($root); + + $map->setMapDataField(['tileset'], 'home'); + $map->save(); + + expect((string) file_get_contents($map->dataPath))->toContain('// Authored metadata remains authored.') + ->and(loadLayeredMap($root)->getMapDataField(['tileset']))->toBe('home') + // Named but not yet painted: every glyph still shows in the graphical field. + ->and(graphicsIssueLines($root))->toBe([ + 'Gameplay glyph \'.\' on terrain has no graphical presentation. At 5 cells: (0, 0), (2, 0), (3, 0) and 2 more.', + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', + ]); +}); + +// -- Validation --------------------------------------------------------------- + +it('accepts valid graphics without issues', function () { + expect(graphicsIssueLines(mapGraphicsProject()))->toBe([]); +}); + +it('reports graphics the Engine refuses as errors', function (Closure $breakGraphics, string $expected) { + $root = mapGraphicsProject(); + $breakGraphics($root, $root . '/assets/Maps/test-map'); + + expect(implode("\n", graphicsIssueLines($root, Severity::ERROR)))->toContain($expected); +})->with([ + 'missing tileset file' => [function (string $root): void { + unlink($root . '/assets/Data/Tilesets/home.php'); + }, 'Tileset home was not found at Data/Tilesets/home.php.'], + 'invalid tileset' => [function (string $root): void { + file_put_contents($root . '/assets/Data/Tilesets/home.php', " 'Home', 'sheets' => ['Z9' => 'x.png']];\n"); + }, "Tileset home: 'Z9' is not an RPG Maker sheet"], + 'graphics without a tileset' => [function (string $root, string $directory): void { + $data = $directory . '/test-map.data.php'; + file_put_contents($data, str_replace(" 'tileset' => 'home',", '', (string) file_get_contents($data))); + }, 'It has graphics/ but no kind.'], + 'badly named file' => [function (string $root, string $directory): void { + writeTileLayer($directory, 'floor.tiles.php', "0 0 0 0\n0 0 0 0"); + }, 'Tile layer test-map/graphics/floor.tiles.php must be named NN.name.tiles.php.'], + 'duplicate order' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.shadows.tiles.php', "0 0 0 0\n0 0 0 0"); + }, 'Tile layer test-map/graphics/02.shadows.tiles.php repeats order 02.'], + 'rows' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', '0 0 0 0'); + }, 'Tile layer test-map/graphics/02.decor.tiles.php must have 2 rows.'], + 'cells' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0"); + }, 'Tile layer test-map/graphics/02.decor.tiles.php row 1 must be 4 cells wide.'], + 'invalid identity' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 9999"); + }, "Tile layer test-map/graphics/02.decor.tiles.php row 1, cell 3: '9999' is not an RPG Maker tile identity."], + 'half tile' => [function (string $root, string $directory): void { + writeTileLayer($directory, '02.decor.tiles.php', "0 5L 5R 0\n0 0 0 0"); + }, "Tile layer test-map/graphics/02.decor.tiles.php row 0, cell 1: '5L' names half of tile 5, but each field cell holds one whole tile; use whole tiles, such as '5'."], + 'piece with a half tile' => [function (string $root): void { + writeTestTileset($root, pieces: ['bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['=='], + 'tiles' => ['decor' => ['5L 5R']]]]); + }, "'5L' names half of tile 5, but each field cell holds one whole tile; use whole tiles, such as '5'."], + 'executable source' => [function (string $root, string $directory): void { + file_put_contents($directory . '/graphics/02.decor.tiles.php', "toBe([ + 'Tileset home sheet A2 is unusable: Graphics/Tilesets/Home_A2.png is 30x24, which does not fit an A2 sheet of 16 x 12 tiles in one even tile size shared by every sheet (768x576 at 48 pixels).', + 'Tileset home sheet B is unusable: Invalid PNG header: Graphics/Tilesets/Home_B.png', + 'Tile layer test-map/graphics/02.decor.tiles.php uses sheets A5, C, which tileset home does not provide.', + ])->and(graphicsIssueLines($root, Severity::ERROR))->toBe([ + // Tiles from unusable sheets draw nothing, so their glyphs show: the player sees them. + 'Gameplay glyph \'.\' on terrain has no graphical presentation. At 5 cells: (0, 0), (2, 0), (3, 0) and 2 more.', + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', + ]); +}); + +it('reports glyphs no tile covers, NPCs without field sprites and copies of an NPC\'s glyph under it as errors', function () { + $root = mapGraphicsProject(); + $directory = $root . '/assets/Maps/test-map'; + writeTileLayer($directory, '01.floor.tiles.php', "2816 0 2816 2816\n2816 0 0 2816"); + writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'npcs' => [ + ['id' => 'guard', 'name' => 'Guard', 'sprite' => '@', 'x' => 0, 'y' => 0], + ['id' => 'board', 'name' => 'Board', 'sprite' => '', 'x' => 3, 'y' => 0], + ['id' => 'sign', 'name' => 'Sign', 'sprite' => 'x', 'sprites2d' => ['sheet' => 'Graphics/Characters/!\$Sign.png'], 'x' => 1, 'y' => 1], + ['id' => 'lamp', 'name' => 'Lamp', 'sprite' => 'i', 'sprites2d' => ['sheet' => 'Graphics/Characters/!\$Lamp.png'], 'x' => 3, 'y' => 1], + ['id' => 'cat', 'name' => 'Cat', 'sprite' => 'c', 'sprites2d' => ['sheet' => 'Graphics/Characters/\$Cat.png'], 'x' => 2, 'y' => 1], + ],", $source)); + + // The board draws no glyph, and the lamp stands on a covered cell. The + // sign's copy is named once, for the NPC, not again among the x cells; + // the cat stands on another glyph, an uncovered cell like any other. + expect(graphicsIssueLines($root, Severity::ERROR))->toBe([ + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + // The sign's copy of its glyph shows under it like any uncovered glyph. + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', + "NPC 'Guard' has no field artwork. At (0, 0).", + // The sheets these NPCs name are not in the project. + "NPC 'Sign' has unavailable field artwork. At (1, 1).", + "NPC 'Lamp' has unavailable field artwork. At (3, 1).", + "NPC 'Cat' has unavailable field artwork. At (2, 1).", + ])->and(graphicsIssueLines($root, Severity::WARNING))->toBe([]); +}); + +it('warns for tiles a piece draws whose glyph is no longer there, and only those', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]], + 'board' => ['name' => 'Board', 'layer' => 'buildings', 'glyphs' => ['/ '], 'tiles' => ['decor' => ['7 6']]], + ]); + // Buildings: " / " over " xx ". The windows at (1, 1) and (2, 1), and + // the board's / at (1, 0) with its blank cell beside it at (2, 0), are + // accounted for; a window tile at (3, 1) has no x, and a board tile at + // (3, 0) has no / beside it. The floor's 2816 is drawn by no piece, so + // it is never judged. + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 0 0 6\n0 5 5 5"); + $stale = array_values(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there'))); + expect($stale)->toBe(['2 tiles on the decor tile layer are drawn by a tileset piece whose glyph is no longer there: (3, 0), (3, 1).']) + ->and(graphicsIssueLines($root, Severity::WARNING))->toContain($stale[0]); + + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 7 6 0\n0 5 5 0"); + expect(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there')))->toBe([]); +}); + +it('never judges a tile that a glyph-less piece also draws, wherever it stands', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: [ + 'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]], + // A cap the window's tile also tops, placed anywhere without a glyph. + 'cap' => ['name' => 'Cap (visual)', 'layer' => 'buildings', 'glyphs' => [' '], 'tiles' => ['decor' => ['5']]], + ]); + // Window tiles stand on the x cells and, as caps, on blank cells beside them. + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "5 0 0 5\n0 5 5 5"); + + expect(array_filter(graphicsIssueLines($root), static fn(string $line): bool => str_contains($line, 'no longer there')))->toBe([]); +}); + +it('lists the cells showing the tileset\'s missing-art placeholder', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, missingArt: 255); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 255 0 0\n0 0 255 255"); + $placeholder = static fn(): array => array_values(array_filter(graphicsIssueLines($root), static fn(string $line): bool => + str_contains($line, 'missing-art placeholder'))); + expect($placeholder())->toBe(['3 cells on the decor tile layer show the missing-art placeholder: (1, 0), (2, 1), (3, 1).']) + // The author chose the placeholder for art still to make: a warning. + ->and(graphicsIssueLines($root, Severity::WARNING))->toContain($placeholder()[0]); + + // A tileset that names none has no placeholder to report. + writeTestTileset($root); + expect($placeholder())->toBe([]); +}); + +it('counts a tile as covering only the glyphs of the gameplay layer it belongs to', function () { + $root = mapGraphicsProject(); + writeTestTileset($root, pieces: ['ground' => ['name' => 'Ground', 'layer' => 'terrain', 'glyphs' => ['.'], 'tiles' => ['floor' => ['2816']]]]); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 0 0 0\n0 0 0 0"); + + // The floor belongs to terrain, so the building glyphs above it still show. + expect(graphicsIssueLines($root))->toBe([ + 'Gameplay glyph \'/\' on buildings has no graphical presentation. At (1, 0).', + 'Gameplay glyph \'x\' on buildings has no graphical presentation. At 2 cells: (1, 1), (2, 1).', + ]); +}); + +it('includes graphics problems and shown glyphs in the pre-save map checks, unsaved resizes included', function () { + $root = mapGraphicsProject(); + $map = loadLayeredMap($root); + $map->setMapDataField(['tileset'], 'missing'); + $messages = static fn(ProjectMap $map): array => array_map(static fn(Issue $issue): string => $issue->message, + MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory())); + + expect($messages($map))->toContain('Tileset missing was not found at Data/Tilesets/missing.php.'); + + $map->setMapDataField(['tileset'], 'home'); + $map->resize(6, 3); + expect(MapGraphicsValidator::validate($map))->toBe([]); + + // A glyph the player would see is an error the save reports, by the Engine's rule; the save itself still runs. + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "0 0 0 0\n0 0 0 0"); + $map = loadLayeredMap($root); + $shown = array_values(array_filter(MapValidator::validate($map, ['test-map' => $map], mapValidatorInventory()), + static fn(Issue $issue): bool => $issue->code === 'graphics.glyph')); + expect($shown)->not->toBe([]) + ->and(array_all($shown, static fn(Issue $issue): bool => $issue->severity === Severity::ERROR && $issue->map === 'test-map'))->toBeTrue(); +}); + +it('reports a map without a kind as the Engine\'s error only in a project that has tilesets', function () { + $root = layeredMapProject(); + expect(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); + + $missing = static fn(): array => array_values(array_filter(MapCompletenessCheck::validate(loadLayeredMap($root), null), + static fn(Issue $issue): bool => $issue->code === 'graphics.missing')); + expect($missing())->toBe([]); + + // In a project with tilesets the field needs the map's graphics: the Engine's rule. + writeTestTileset($root, 'interior'); + expect($missing())->toHaveCount(1) + ->and($missing()[0]->severity)->toBe(Severity::ERROR) + ->and($missing()[0]->hint)->toStartWith('Set its Kind in the Inspector, one of the tilesets in assets/Data/Tilesets.') + ->and(MapGraphicsValidator::validate(loadLayeredMap($root)))->toBe([]); + + editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'tileset' => 'interior',", $source)); + $map = loadLayeredMap($root); + expect(MapGraphicsValidator::validate($map))->toBe([]) + ->and($missing())->toBe([]) + // With a kind and no tiles yet, its glyphs show instead. + ->and(array_values(array_unique(array_filter(array_map(static fn(Issue $issue): ?string => $issue->code, MapCompletenessCheck::validate($map, null)), + static fn(?string $code): bool => str_starts_with((string) $code, 'graphics.')))))->toBe(['graphics.glyph']); +}); + +it('reports coverage through project validation, as ichiloto validate runs it', function () { + $root = mapGraphicsProject(); + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "2816 2816 2816 2816\n2816 0 2816 2816"); + + $messages = array_map(static fn(Issue $issue): string => $issue->message, array_filter( + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => $issue->where === 'test-map', + )); + expect($messages)->toContain("Gameplay glyph 'x' on buildings has no graphical presentation. At (1, 1)."); +}); + +it('reports graphics through project validation, as ichiloto validate runs it', function () { + $root = mapGraphicsProject(); + unlink($root . '/assets/Data/Tilesets/home.php'); + + $issues = array_filter( + new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)), + static fn(Issue $issue): bool => $issue->where === 'test-map' && $issue->severity === Severity::ERROR + && $issue->message === 'Tileset home was not found at Data/Tilesets/home.php.', + ); + + expect($issues)->toHaveCount(1) + ->and(array_values($issues)[0]->hint)->toContain('terminal glyphs'); +}); diff --git a/tests/Unit/MapIsolationTest.php b/tests/Unit/MapIsolationTest.php new file mode 100644 index 00000000..85eb1623 --- /dev/null +++ b/tests/Unit/MapIsolationTest.php @@ -0,0 +1,76 @@ +maps[array_search('bad-a', $workspace->mapIds, true)]; + $good = $workspace->maps[array_search('test-map', $workspace->mapIds, true)]; + $issues = (new ProjectValidator())->validate($workspace); + $badIssues = array_values(array_filter($issues, static fn ($issue): bool => str_contains($issue->message, 'Map source is read-only'))); + + expect(is_file($executed))->toBeFalse() + ->and($badA->getGridSourceIssue())->toContain('bad-a/bad-a.map.php: found T_STRING at line 2') + ->and($badA->getGridSourceIssue())->not->toContain($root) + ->and($workspace->getCanvasLines(array_search('bad-a', $workspace->mapIds, true), 40, 10))->toContain('Read-only: repair this map before editing.') + ->and($good->getGridSourceIssue())->toBeNull() + ->and($badIssues)->toHaveCount(2); + + /** @var Editor $editor */ + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', $workspace); + callEditorMethod($editor, 'reportRefusedMapsAtBoot'); + expect(getEditorProperty($editor, 'statusMessage'))->toBe('2 map(s) are read-only (Ctrl+E for details).') + ->and(getEditorProperty($editor, 'statusLevel'))->toBe(StatusLevel::WARN) + ->and(getEditorProperty($editor, 'statusDetailLines'))->toHaveCount(2); + + expect(fn () => $badA->setMapField('name', 'Changed'))->toThrow(MapSourceRefusal::class, 'read-only'); + expect(fn () => $badA->save())->toThrow(MapSourceRefusal::class, 'read-only'); + expect(fn () => $workspace->deleteMap(array_search('bad-a', $workspace->mapIds, true)))->toThrow(MapSourceRefusal::class, 'read-only'); + + $good->setTileSymbol(1, 1, 'x'); + expect($good->isDirty())->toBeTrue(); +}); + +it('writes parseable map and event grids when authored rows resemble nowdoc closers', function (): void { + $root = makeTemporaryProject('ichiloto-map-markers-'); + $directory = $root . '/assets/Maps/marker-map'; + mkdir($directory); + $map = new ProjectMap( + 'marker-map', + $directory, + $directory . '/marker-map.data.php', + $directory . '/marker-map.map.php', + $directory . '/marker-map.event.php', + ['name' => 'Marker Map'], + ['ICHILOTO_MAP;'], + ['ICHILOTO_EVENT_MAP;'], + ); + + $map->save(); + + expect(MapGridSource::readFile($map->mapPath))->toBe('ICHILOTO_MAP;') + ->and(MapGridSource::readFile($map->eventPath))->toBe('ICHILOTO_EVENT_MAP;') + ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $directory)->tileLines)->toBe(['ICHILOTO_MAP;']); +}); diff --git a/tests/Unit/MapKindTest.php b/tests/Unit/MapKindTest.php new file mode 100644 index 00000000..5d2ae6a5 --- /dev/null +++ b/tests/Unit/MapKindTest.php @@ -0,0 +1,173 @@ +current(); +} + +it('offers the project\'s kinds by name and sets one on a map without a kind, keeping its tiles, as one undo step', function () { + $root = mapGraphicsProject(); + editTestMapData($root, static fn(string $source): string => str_replace(" 'tileset' => 'home',", '', $source)); + writeTestTileset($root, 'interior'); + writeTestTileset($root, 'exterior'); + [$editor, $map] = kindEditor($root); + + expect(inspectorValueOf($editor, 'Kind'))->toBe('Not set'); + + selectInspectorField($editor, 'Kind'); + callEditorMethod($editor, 'activateInspectorField'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Kind') + ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label', 'value')) + ->toBe(['exterior' => 'Exterior', 'home' => 'Home', 'interior' => 'Interior']); + callEditorMethod($editor, 'closeEventOptionDialog', 'cancelled'); + + // Tiles drawn before the map had a kind are kept; they now draw from it. + pickInspectorReference($editor, 'Kind', 'interior'); + expect($map->getMapDataField(['tileset']))->toBe('interior') + ->and($map->getTileLayerSources())->toHaveCount(2) + ->and(inspectorValueOf($editor, 'Kind'))->toBe('Interior') + ->and($map->isDirty())->toBeTrue() + ->and(kindToast($editor)?->level)->toBe(StatusLevel::INFO) + ->and(kindToast($editor)?->message)->toBe('Test Map\'s kind is now Interior; save to keep it.'); + + callEditorMethod($editor, 'performUndo'); + expect($map->hasMapDataField(['tileset']))->toBeFalse() + ->and($map->isDirty())->toBeFalse(); +}); + +it('asks before changing a map with tiles to another kind, and clears its tiles and their settings as one undo step', function () { + $root = mapGraphicsProject(); + editTestMapData($root, static fn(string $source): string => str_replace("'tileset' => 'home',", + "'tileset' => 'home', 'tileLayers' => ['floor' => ['movesWith' => 'buildings']],", $source)); + writeTestTileset($root, 'cave'); + [$editor, $map] = kindEditor($root); + $graphics = $root . '/assets/Maps/test-map/graphics'; + $sources = $map->getTileLayerSources(); + + expect(inspectorValueOf($editor, 'Kind'))->toBe('Home'); + + // Cancel is first and leaves everything as it was. + pickInspectorReference($editor, 'Kind', 'cave'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Change Test Map\'s kind to Cave') + ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label'))->toBe(['Cancel', 'Clear 2 tile layers and change']) + ->and(getEditorProperty($editor, 'selectedEventOptionIndex'))->toBe(0); + callEditorMethod($editor, 'applySelectedEventOption'); + expect($map->getMapDataField(['tileset']))->toBe('home') + ->and($map->getTileLayerSources())->toBe($sources) + ->and($map->isDirty())->toBeFalse(); + + pickInspectorReference($editor, 'Kind', 'cave'); + setEditorProperty($editor, 'selectedEventOptionIndex', 1); + callEditorMethod($editor, 'applySelectedEventOption'); + expect($map->getMapDataField(['tileset']))->toBe('cave') + ->and($map->getTileLayerSources())->toBe([]) + ->and($map->hasMapDataField(['tileLayers']))->toBeFalse() + ->and(kindToast($editor)?->message)->toBe('Test Map\'s kind is now Cave, and its 2 tile layers are cleared; save to keep it.'); + + callEditorMethod($editor, 'performUndo'); + expect($map->getMapDataField(['tileset']))->toBe('home') + ->and($map->getTileLayerSources())->toBe($sources) + ->and($map->getMapDataField(['tileLayers']))->toBe(['floor' => ['movesWith' => 'buildings']]) + ->and($map->isDirty())->toBeFalse(); + + // Saving the change removes the old kind's tile layers; glyphs stay. + callEditorMethod($editor, 'performRedo'); + $map->save(); + $saved = require $root . '/assets/Maps/test-map/test-map.data.php'; + expect($saved['tileset'])->toBe('cave') + ->and($saved)->not->toHaveKey('tileLayers') + ->and(glob($graphics . '/*.tiles.php'))->toBe([]) + ->and(is_file($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBeTrue(); +}); + +it('changes a map without tiles to another kind without asking', function () { + $root = layeredMapProject(); + editTestMapData($root, static fn(string $source): string => str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", $source)); + writeTestTileset($root); + writeTestTileset($root, 'cave'); + [$editor, $map] = kindEditor($root); + + pickInspectorReference($editor, 'Kind', 'cave'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and($map->getMapDataField(['tileset']))->toBe('cave'); +}); + +it('changes nothing when the chosen kind is the map\'s own', function () { + [$editor, $map] = kindEditor(mapGraphicsProject()); + + pickInspectorReference($editor, 'Kind', 'home'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and($map->isDirty())->toBeFalse() + ->and(kindToast($editor)?->message)->toBe('Test Map\'s kind is already Home.'); +}); + +it('keeps a kind the project no longer has visible and diagnosable', function () { + $root = mapGraphicsProject(); + unlink($root . '/assets/Data/Tilesets/home.php'); + writeTestTileset($root, 'cave'); + [$editor, $map] = kindEditor($root); + + expect(inspectorValueOf($editor, 'Kind'))->toBe('home · not in assets/Data/Tilesets') + ->and($map->isDirty())->toBeFalse(); +}); + +it('asks a new map\'s kind before creating it, and Esc creates nothing', function () { + $root = layeredMapProject(); + writeTestTileset($root, 'interior'); + writeTestTileset($root, 'exterior'); + [$editor] = layeredCanvasEditor($root); + setEditorProperty($editor, 'focusedPane', 'assets'); + + callEditorMethod($editor, 'dispatchInput', 'A'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('New map kind') + ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label', 'value')) + ->toBe(['exterior' => 'Exterior', 'interior' => 'Interior']) + ->and(is_dir($root . '/assets/Maps/new-map'))->toBeFalse(); + + callEditorMethod($editor, 'dispatchInput', "\033"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/new-map'))->toBeFalse(); + + callEditorMethod($editor, 'dispatchInput', 'A'); + callEditorMethod($editor, 'dispatchInput', "\033[B"); + callEditorMethod($editor, 'dispatchInput', "\n"); + $data = require $root . '/assets/Maps/new-map/new-map.data.php'; + expect($data['tileset'])->toBe('interior') + ->and(kindToast($editor)?->level)->toBe(StatusLevel::SUCCESS) + ->and(kindToast($editor)?->message)->toBe('Created new-map; its kind is Interior.') + ->and(callEditorMethod($editor, 'getSelectedMap')->mapId)->toBe('new-map') + ->and(callEditorMethod($editor, 'getSelectedMap')->isDirty())->toBeFalse(); +}); + +it('creates a map without a kind at once in a project that has no tilesets', function () { + $root = layeredMapProject(); + [$editor] = layeredCanvasEditor($root); + setEditorProperty($editor, 'focusedPane', 'assets'); + + callEditorMethod($editor, 'dispatchInput', 'A'); + $data = require $root . '/assets/Maps/new-map/new-map.data.php'; + expect($data)->not->toHaveKey('tileset') + ->and(kindToast($editor)?->message)->toBe('Created new-map. It has no kind: the project has no tilesets in assets/Data/Tilesets/.'); +}); + +it('keeps the kind of a duplicated map', function () { + $root = mapGraphicsProject(); + $workspace = Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $copy = $workspace->duplicateMap(0); + + expect((require $root . "/assets/Maps/{$copy}/" . basename($copy) . '.data.php')['tileset'])->toBe('home'); +}); diff --git a/tests/Unit/MapLandmarkTerminalTest.php b/tests/Unit/MapLandmarkTerminalTest.php new file mode 100644 index 00000000..f806d3d3 --- /dev/null +++ b/tests/Unit/MapLandmarkTerminalTest.php @@ -0,0 +1,128 @@ + $row) { + if (($row['target'] ?? null) === 'map-landmarks' && ($row['field'] ?? null) === $field) { + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + return; + } + } + throw new RuntimeException('No landmark row edits ' . $field); +} + +/** Opens the row's list and chooses the entry with this value. */ +function chooseLandmarkOption(Editor $editor, string $field, string $value): void +{ + selectLandmarkField($editor, $field); + callEditorMethod($editor, 'activateInspectorField'); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue(); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + $index = array_search($value, array_column($entries, 'value'), true); + expect($index)->toBeInt(); + setEditorProperty($editor, 'selectedEventOptionIndex', $index); + callEditorMethod($editor, 'applySelectedEventOption'); +} + +it('steps an event through what it is on the minimap and picks a landmark anchor from a list', function () { + $editor = landmarkTerminalEditor(); + expect(inspectorValueOf($editor, 'Unnamed Route Markers'))->toBe('1'); + + setEditorProperty($editor, 'editingMode', 'event'); + setEditorProperty($editor, 'cursorX', 0); + setEditorProperty($editor, 'cursorY', 0); + expect(array_column(selectInspectorField($editor, 'Minimap')['choices'], 'label'))->toBe(['Unnamed route marker', 'Passage', 'Entrance', 'Exit']); + // Enter moves a short enum on, as Left and Right do. + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor)['landmarks'])->toBe([['id' => 'landmark-1', 'kind' => 'passage', 'anchor' => ['event' => 'T']]]); + callEditorMethod($editor, 'adjustInspectorOptionField', 1); + callEditorMethod($editor, 'adjustInspectorOptionField', 1); + expect(mapDataOf($editor)['landmarks'][0]['kind'])->toBe('exit') + ->and(inspectorValueOf($editor, 'Landmark'))->toBe('landmark-1 · unnamed'); + + setEditorProperty($editor, 'editingMode', 'map'); + selectLandmarkField($editor, 'anchor'); + callEditorMethod($editor, 'activateInspectorField'); + expect(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label')) + ->toBe(['Event T · Transfer Player to test-map', 'Event E · Dialogue', 'World object old-tower']); + callEditorMethod($editor, 'closeEventOptionDialog'); + chooseLandmarkOption($editor, 'anchor', 'object:old-tower'); + expect(mapDataOf($editor)['landmarks'][0])->toBe(['id' => 'landmark-1', 'kind' => 'landmark', 'anchor' => ['object' => 'old-tower']]); + callEditorMethod($editor, 'performUndo'); + expect(mapDataOf($editor)['landmarks'][0]['anchor'])->toBe(['event' => 'T']); +}); + +it('builds reveal conditions with the shared editor and reports a refusal without claiming success', function () { + $editor = landmarkTerminalEditor(); + setEditorProperty($editor, 'editingMode', 'event'); + setEditorProperty($editor, 'cursorX', 3); + setEditorProperty($editor, 'cursorY', 0); + selectInspectorField($editor, 'Minimap'); + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor)['landmarks'][0]['kind'])->toBe('building'); + setEditorProperty($editor, 'editingMode', 'map'); + + selectLandmarkField($editor, 'revealConditions'); + callEditorMethod($editor, 'activateInspectorField'); + $conditionEditor = getEditorProperty($editor, 'conditionEditor'); + expect($conditionEditor->isOpen())->toBeTrue() + ->and($conditionEditor->label())->toBe('Landmark landmark-1 Reveal Conditions'); + callEditorMethod($editor, 'dispatchInput', 'a'); + callEditorMethod($editor, 'dispatchInput', 't'); + $conditionEditor->setName('heard_of_tower'); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(mapDataOf($editor)['landmarks'][0]['revealConditions'])->toBe([['type' => 'event', 'name' => 'heard_of_tower']]) + ->and(getEditorProperty($editor, 'mapConditionField'))->toBeNull(); + + $toasts = getEditorProperty($editor, 'toasts'); + $toasts->clear(); + selectLandmarkField($editor, 'approachDistance'); + setEditorProperty($editor, 'isInspectorEditing', true); + setEditorProperty($editor, 'inspectorEditBuffer', '99'); + callEditorMethod($editor, 'commitInspectorEdit'); + expect($toasts->current()?->message)->toBe('An approach distance is 0 to 64 cells.') + ->and($toasts->pendingCount())->toBe(0) + ->and(mapDataOf($editor)['landmarks'][0])->not->toHaveKey('approachDistance'); +}); + +it('places a map in the atlas from the Terminal: scope stepped, parent entry picked from a list', function () { + [$root] = createAtlasProject(); + $editor = metadataEditor($root); + $select = static function (string $field) use ($editor): void { + foreach (callEditorMethod($editor, 'getInspectorFields') as $index => $row) { + if (($row['target'] ?? null) === 'map-atlas' && ($row['field'] ?? null) === $field) { + setEditorProperty($editor, 'selectedInspectorFieldIndex', $index); + return; + } + } + throw new RuntimeException('No atlas row edits ' . $field); + }; + $select('parent'); + callEditorMethod($editor, 'activateInspectorField'); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + expect(array_column($entries, 'label'))->toBe(['(none)', 'World · town-gate']); + setEditorProperty($editor, 'selectedEventOptionIndex', 1); + callEditorMethod($editor, 'applySelectedEventOption'); + expect(mapDataOf($editor)['atlas'])->toBe(['parent' => ['map' => 'world', 'landmark' => 'town-gate']]) + ->and(inspectorValueOf($editor, 'Global Map'))->toBe('world at town-gate'); + + $select('scope'); + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor)['atlas'])->toBe(['scope' => 'global']); +}); diff --git a/tests/Unit/MapLineInsertionTest.php b/tests/Unit/MapLineInsertionTest.php new file mode 100644 index 00000000..1b75a4d7 --- /dev/null +++ b/tests/Unit/MapLineInsertionTest.php @@ -0,0 +1,326 @@ +apply(); + $home = $root . '/assets/Maps/home'; + + expect(MapGridSource::readFile($home . '/layers/01.terrain.map.php')) + ->toBe("######\n#....#\n \n \n#.~~.#\n#....#\n######") + ->and(MapGridSource::readFile($home . '/layers/02.detail.deco.php')) + ->toBe(" \n d \n \n \n \n d \n ") + ->and(MapGridSource::readFile($home . '/home.event.php')) + ->toBe(" \n W \n \n \n \n D S \n ") + ->and(file_get_contents($home . '/layers/01.terrain.map.php'))->toContain("// keep layers/01.terrain.map.php\n") + ->and(TileLayerSource::readLayer((string) file_get_contents($home . '/graphics/01.floor.tiles.php'), 'floor')->getEntries()) + ->toBe([ + array_fill(0, 6, '2816'), array_fill(0, 6, '2816'), array_fill(0, 6, '0'), array_fill(0, 6, '0'), + array_fill(0, 6, '5'), array_fill(0, 6, '2816'), array_fill(0, 6, '2816'), + ]) + ->and(file_get_contents($home . '/graphics/01.floor.tiles.php'))->toContain('// Painted in the GUI editor.'); + + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $home); + expect($map->getHeight())->toBe(7) + ->and($map->getEventBounds('D'))->toMatchArray(['x' => 1, 'y' => 5]); +}); + +it('splices blank columns into every grid, keeping styled runs', function () { + $root = mapLineProject(); + planLineInsertion($root, 'x', 1, 1)->apply(); + $home = $root . '/assets/Maps/home'; + + expect(MapGridSource::readFile($home . '/layers/01.terrain.map.php')) + ->toBe("# #####\n# ....#\n# .~~.#\n# ....#\n# #####") + ->and(MapGridSource::readFile($home . '/home.event.php')) + ->toBe(" \n W \n \n D S \n ") + ->and(TileLayerSource::readLayer((string) file_get_contents($home . '/graphics/01.floor.tiles.php'), 'floor')->getEntries()[2]) + ->toBe(['5', '0', '5', '5', '5', '5', '5']) + ->and((require $home . '/home.data.php')['npcs'][0])->toMatchArray(['x' => 3, 'y' => 3]) + ->and((require $home . '/home.data.php')['npcs'][0]['wanderArea'])->toBe(['x' => 2, 'y' => 1, 'width' => 3, 'height' => 3]); +}); + +it('moves points, stretches straddling areas and follows transfers into the map from every file', function () { + $root = mapLineProject(); + $plan = planLineInsertion($root, 'y', 2, 2); + $plan->apply(); + $home = require $root . '/assets/Maps/home/home.data.php'; + $town = require $root . '/assets/Maps/town/town.data.php'; + + expect($home['npcs'][0])->toMatchArray(['x' => 2, 'y' => 5]) + ->and($home['npcs'][0]['wanderArea'])->toBe(['x' => 1, 'y' => 1, 'width' => 3, 'height' => 5]) + ->and($home['npcs'][1])->toMatchArray(['x' => 4, 'y' => 0]) + // The script moves the player on home, then leaves: only the first move is home's. + ->and($home['npcs'][1]['script'])->toBe([ + ['type' => 'move_player', 'x' => 1, 'y' => 6], + ['type' => 'transfer', 'map' => 'town', 'x' => 2, 'y' => 3], + ['type' => 'move_player', 'x' => 3, 'y' => 3], + ]) + ->and($home['events']['D']['data']['spawnPoint'])->toBe(['x' => 1, 'y' => 6]) + ->and($home['station'])->toBe(['x' => 3, 'y' => 3]) + ->and($home['tileLayers'])->toBe(['floor' => ['offset' => [0, 3]]]) + ->and($town['events']['A']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 6]) + ->and($town['events']['B']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 4]) + // Town's guard transfers into home; what follows is in home's space, one axis at a time. + ->and($town['npcs'][0]['script'])->toBe([ + ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 5], + ['type' => 'move_player', 'x' => 2, 'y' => 6], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 6]]], + ]) + ->and((require $root . '/assets/Data/system.php')['startingPositions']['player']['spawnPoint'])->toBe(['x' => 1, 'y' => 6]) + ->and(require $root . '/assets/Events/home-only.php')->toBe([ + ['type' => 'move_player', 'x' => 3, 'y' => 6], + ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 1.0], + ]) + ->and(file_get_contents($root . '/assets/Events/home-only.php'))->toStartWith(" 'parallel', 'lanes' => [ + [['type' => 'wait', 'seconds' => 1.0], ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3]], + ['id' => 'walk', 'commands' => [['type' => 'move_player', 'x' => 2, 'y' => 3]]], + ]], + ['type' => 'move_player', 'x' => 1, 'y' => 3], + ]; + file_put_contents($path, "apply(); + $script = (require $path)['npcs'][0]['script']; + + // The second lane can run before or after the first lane's transfer. + expect(describeMapLineHandEdits($plan))->toContain('assets/Maps/town/town.data.php npcs.0.script.0.lanes.1.commands.0.y') + ->and($script[0]['lanes'][0][1])->toMatchArray(['map' => 'home', 'y' => 5]) + ->and($script[0]['lanes'][1]['commands'][0])->toMatchArray(['y' => 3]) + // Once every lane is done the player is on home. + ->and($script[1])->toBe(['type' => 'move_player', 'x' => 1, 'y' => 5]); +}); + +it('reports what it cannot rewrite instead of guessing or flattening it', function () { + $root = mapLineProject(); + $shared = (string) file_get_contents($root . '/assets/Events/shared.php'); + $plan = planLineInsertion($root, 'y', 2, 2); + + expect(describeMapLineHandEdits($plan))->toEqualCanonicalizing([ + 'assets/Maps/home/home.data.php npcs.2.y', + 'assets/Events/shared.php 0.y', + ]) + ->and(array_column($plan->handEdits, 'reason'))->toContain('it may run on home or town') + ->and(array_map(static fn(string $path): string => substr($path, strlen($root) + 1), $plan->getChangedPaths())) + ->not->toContain('assets/Events/shared.php'); + + $plan->apply(); + $source = (string) file_get_contents($root . '/assets/Maps/home/home.data.php'); + expect($source)->toContain("'x' => 1, 'y' => 2 + 1]") + ->and($source)->toContain("// The house.\n") + ->and(file_get_contents($root . '/assets/Events/shared.php'))->toBe($shared); +}); + +it('raises the save content version with a declarative map shift step', function () { + // Saves can stand on home only where the player reaches it, so the collision is known. + $root = mapLineRemovalProject(); + $plan = planLineInsertion($root, 'y', 2, 2); + expect($plan->notes)->toBe(['Saves move with the map: save content version 1 adds this map shift.']); + $plan->apply(); + + expect(require $root . '/assets/Data/save-compatibility.php')->toBe([ + 'contentVersion' => 1, + 'migrations' => [['from' => 0, 'to' => 1, 'mapShifts' => [['map' => 'home', 'axis' => 'y', 'at' => 2, 'by' => 2]]]], + 'aliases' => [], + 'tombstones' => [], + ]) + ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0)) + ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\DeclaredPositionContentMigration::class) + ->and(describeSaveCompatibilityIssues($root))->toBe([]); +}); + +it('says existing saves are not migrated when the project has no save manifest', function () { + $root = mapLineProject(); + unlink($root . '/assets/Data/save-compatibility.php'); + + expect(planLineInsertion($root, 'x', 0, 1)->notes) + ->toBe(['Existing saves are not migrated: the project has no assets/Data/save-compatibility.php.']); +}); + +it('moves a cutscene cast and script that start on the map, and a finalizer transfer into it', function () { + $root = mapLineProject(); + $directory = $root . '/assets/Cutscenes/Cinematics/arrival'; + mkdir($directory, 0o777, true); + file_put_contents($directory . '/arrival.data.php', " 'arrival', 'name' => 'Arrival', 'startMap' => 'home', + 'cast' => [['id' => 'visitor', 'sprite' => 'v', 'x' => 2, 'y' => 4]], + 'finalizer' => [['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 3]], + ], true) . ";\n"); + file_put_contents($directory . '/arrival.script.php', " 'stage_actor', 'id' => 'extra', 'sprite' => 'e', 'x' => 3, 'y' => 3], + ['type' => 'transfer', 'map' => 'town', 'x' => 1, 'y' => 1], + ['type' => 'field_animation', 'animation' => 'spark', 'target' => ['kind' => 'position', 'x' => 1, 'y' => 1]], + ], true) . ";\n"); + + $plan = planLineInsertion($root, 'y', 2, 1); + $plan->apply(); + + expect((require $directory . '/arrival.data.php')['cast'][0])->toMatchArray(['x' => 2, 'y' => 5]) + ->and((require $directory . '/arrival.data.php')['finalizer'][0])->toMatchArray(['x' => 1, 'y' => 4]) + ->and((require $directory . '/arrival.script.php')[0])->toMatchArray(['x' => 3, 'y' => 4]) + ->and((require $directory . '/arrival.script.php')[2]['target'])->toMatchArray(['y' => 1]); +}); + +it('inserts through the palette after a count and a confirmation, and one undo restores every file', function () { + // Saves can stand on home only where the player reaches it, so the collision is known. + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + $editor = mapLineEditor($root); + $workspace = getEditorProperty($editor, 'workspace'); + setEditorProperty($editor, 'cursorY', 2); + + openMapLinePaletteItem($editor, 'Map: Insert rows above the cursor'); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeTrue(); + pressKeys($editor, "\177", '2', "\r"); + + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue() + ->and(sourceHashTree($root . '/assets'))->toBe($before); + $entries = getEditorProperty($editor, 'eventOptionDialogEntries'); + $labels = array_column($entries, 'label'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Insert 2 rows above row 3 of home') + ->and($entries[1]['description'])->toBe('Writes now, not on Save. Ctrl+Z restores files.') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Writes now, not on Save. Ctrl+Z restores files.') + ->and($labels)->toContain('assets/Maps/home/layers/01.terrain.map.php') + ->and($labels)->toContain('assets/Data/system.php') + ->and($labels)->toContain('Hand edit: assets/Events/shared.php 0.y') + ->and($labels)->toContain('Saves move with the map: save content version 1 adds this map shift.'); + + pressKeys($editor, "\033[B", "\r"); + $after = sourceHashTree($root . '/assets'); + expect($after)->not->toBe($before) + ->and(getEditorProperty($editor, 'workspace'))->not->toBe($workspace) + ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(7) + ->and(getEditorProperty($editor, 'workspace')->hasUnsavedChanges())->toBeFalse(); + + pressKeys($editor, "\x1a"); + expect(sourceHashTree($root . '/assets'))->toBe($before) + ->and(getEditorProperty($editor, 'workspace'))->toBe($workspace) + ->and($workspace->getMapByIndex(0)->getHeight())->toBe(5); + + pressKeys($editor, "\x19"); + expect(sourceHashTree($root . '/assets'))->toBe($after) + ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(7); +}); + +it('cancels from the count prompt and from the confirmation without writing', function () { + $root = mapLineProject(); + $before = sourceHashTree($root . '/assets'); + $editor = mapLineEditor($root); + + openMapLinePaletteItem($editor, 'Map: Insert columns left of the cursor'); + pressKeys($editor, "\033"); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeFalse(); + + openMapLinePaletteItem($editor, 'Map: Insert columns left of the cursor'); + pressKeys($editor, "\177", '0', "\r"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('at least 1'); + pressKeys($editor, "\033"); + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('refuses to plan while the workspace has unsaved changes', function () { + $root = mapLineProject(); + $before = sourceHashTree($root . '/assets'); + $editor = mapLineEditor($root); + getEditorProperty($editor, 'workspace')->getMapByIndex(0)->setTileSymbol(1, 1, 'x'); + + openMapLinePaletteItem($editor, 'Map: Insert rows above the cursor'); + + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Save or undo pending edits') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('offers the insert commands only on a map in Map mode', function () { + $editor = mapLineEditor(mapLineProject()); + $labels = static fn(): array => array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildPaletteItems')); + + expect($labels())->toContain('Map: Insert rows above the cursor') + ->and($labels())->toContain('Map: Insert columns left of the cursor'); + callEditorMethod($editor, 'setEditingMode', 'event'); + expect($labels())->not->toContain('Map: Insert rows above the cursor'); +}); + +it('reports coordinates in a list built with a spread instead of rewriting the wrong entry', function () { + $root = mapLineProject(); + $path = $root . '/assets/Maps/home/home.data.php'; + file_put_contents($path, str_replace(" 'npcs' => [\n", " 'npcs' => [\n ...array_map(static fn(int \$y): array => ['id' => \"post-{\$y}\", 'name' => 'Post', 'sprite' => '|', 'x' => 5, 'y' => \$y], [3]),\n", (string) file_get_contents($path))); + $source = (string) file_get_contents($path); + $plan = planLineInsertion($root, 'y', 2, 1); + $plan->apply(); + + expect(describeMapLineHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.0.y') + ->and(describeMapLineHandEdits($plan))->toContain('assets/Maps/home/home.data.php npcs.1.y') + ->and(array_column(array_filter($plan->handEdits, static fn(array $edit): bool => $edit['where'] === 'npcs.1.y'), 'reason')) + ->toBe(['it is inside an array built with a spread or a computed key']) + // Everything else in the file still moves. + ->and((require $path)['events']['D']['data']['spawnPoint'])->toBe(['x' => 1, 'y' => 5]) + ->and(substr((string) file_get_contents($path), 0, strpos($source, " 'events' =>"))) + ->toBe(substr($source, 0, strpos($source, " 'events' =>"))); +}); + +it('validates declarative map shift steps as the Engine reads them', function () { + $root = mapLineProject(); + file_put_contents($root . '/assets/Data/save-compatibility.php', " 2, + 'migrations' => [ + ['from' => 0, 'to' => 1, 'mapShifts' => [['map' => 'home', 'axis' => 'z', 'at' => 0, 'by' => 1]]], + ['from' => 1, 'to' => 2, 'class' => 'Game\\Save\\Step', 'mapShifts' => [['map' => 'home', 'axis' => 'y', 'at' => 0, 'by' => 1]]], + ], + 'aliases' => [], + 'tombstones' => [], + ], true) . ";\n"); + $messages = describeSaveCompatibilityIssues($root); + + expect(implode("\n", $messages))->toContain('axis must be "x" or "y"') + ->and(implode("\n", $messages))->toContain('must declare either a class or declared position edits'); +}); + +it('validates declarative relocation steps as the Engine reads them', function () { + $root = mapLineProject(); + $write = static fn(array $migrations, int $version) => file_put_contents($root . '/assets/Data/save-compatibility.php', " $version, + 'migrations' => $migrations, + 'aliases' => [], + 'tombstones' => [], + ], true) . ";\n"); + + $write([ + ['from' => 0, 'to' => 1, 'mapShifts' => [['map' => 'home', 'axis' => 'y', 'at' => 2, 'by' => 1]], + 'relocations' => [['map' => 'home', 'cells' => [[3, 4]], 'to' => [3, 5]]]], + ['from' => 1, 'to' => 2, 'relocations' => [['map' => 'home', 'cells' => [[1, 1]], 'to' => [1, 2]]]], + ], 2); + expect(describeSaveCompatibilityIssues($root))->toBe([]); + + $write([ + ['from' => 0, 'to' => 1, 'relocations' => [['map' => 'home', 'cells' => [[3, 4]], 'to' => [3, 4]]]], + ['from' => 1, 'to' => 2, 'class' => 'Game\\Save\\Step', 'relocations' => [['map' => 'home', 'cells' => [[1, 1]], 'to' => [1, 2]]]], + ], 2); + $messages = implode("\n", describeSaveCompatibilityIssues($root)); + + expect($messages)->toContain('is one of the cells it moves players off') + ->and($messages)->toContain('must declare either a class or declared position edits'); +}); diff --git a/tests/Unit/MapLineRemovalTest.php b/tests/Unit/MapLineRemovalTest.php new file mode 100644 index 00000000..14d11f1e --- /dev/null +++ b/tests/Unit/MapLineRemovalTest.php @@ -0,0 +1,218 @@ +apply(); + $home = $root . '/assets/Maps/home'; + + expect(MapGridSource::readFile($home . '/layers/01.terrain.map.php'))->toBe("######\n#....#\n#....#\n#..###") + ->and(MapGridSource::readFile($home . '/layers/02.detail.deco.php'))->toBe(" \n d \n d \n ") + ->and(MapGridSource::readFile($home . '/home.event.php'))->toBe(" \n W \n D S \n ") + ->and(file_get_contents($home . '/layers/01.terrain.map.php'))->toContain("// keep layers/01.terrain.map.php\n") + ->and(TileLayerSource::readLayer((string) file_get_contents($home . '/graphics/01.floor.tiles.php'), 'floor')->getEntries()) + ->toBe(array_fill(0, 4, array_fill(0, 6, '2816'))) + ->and(file_get_contents($home . '/graphics/01.floor.tiles.php'))->toContain('// Painted in the GUI editor.'); + + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $home); + expect($map->getHeight())->toBe(4) + ->and($map->getEventBounds('D'))->toMatchArray(['x' => 1, 'y' => 2]); +}); + +it('moves what is beyond the removed lines back, shrinks straddling areas and follows transfers into the map', function () { + $root = mapLineRemovalProject(); + $plan = planLineRemoval($root, 'y', 2, 1); + $plan->apply(); + $home = require $root . '/assets/Maps/home/home.data.php'; + $town = require $root . '/assets/Maps/town/town.data.php'; + + expect($home['npcs'][0])->toMatchArray(['x' => 2, 'y' => 2]) + ->and($home['npcs'][0]['wanderArea'])->toBe(['x' => 1, 'y' => 1, 'width' => 3, 'height' => 2]) + ->and($home['npcs'][1])->toMatchArray(['x' => 4, 'y' => 0]) + ->and($home['npcs'][1]['script'][0])->toBe(['type' => 'move_player', 'x' => 1, 'y' => 3]) + // After the transfer the script is on town, which keeps its rows. + ->and($home['npcs'][1]['script'][2])->toBe(['type' => 'move_player', 'x' => 3, 'y' => 3]) + ->and($home['events']['D']['data']['spawnPoint'])->toBe(['x' => 1, 'y' => 3]) + ->and($town['events']['A']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 3]) + ->and($town['events']['B']['data']['spawnPoint'])->toBe(['x' => 2, 'y' => 4]) + ->and($town['npcs'][0]['script'])->toBe([ + ['type' => 'transfer', 'map' => 'home', 'x' => 1, 'y' => 2], + ['type' => 'move_player', 'x' => 2, 'y' => 3], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 5], ['y' => 3]]], + ]) + ->and((require $root . '/assets/Data/system.php')['startingPositions']['player']['spawnPoint'])->toBe(['x' => 1, 'y' => 3]) + ->and((require $root . '/assets/Events/home-only.php')[0])->toBe(['type' => 'move_player', 'x' => 3, 'y' => 3]) + ->and(describeMapLineHandEdits($plan))->toEqualCanonicalizing([ + 'assets/Maps/home/home.data.php npcs.2.y', + 'assets/Events/shared.php 0.y', + ]); +}); + +it('refuses to remove a line an event marker stands on, writing nothing', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + + expect(fn() => planLineRemoval($root, 'y', 3, 1)) + ->toThrow(MapSourceRefusal::class, 'Move these off the removed rows first; nothing was changed: event D at x 1, y 3; event S at x 4, y 3.') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('refuses to remove lines that NPCs, spawn points, starting positions or scripts stand on, naming each one', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + + try { + planLineRemoval($root, 'y', 4, 1); + $this->fail('The removal should be refused.'); + } catch (RuntimeException $refusal) { + expect($refusal->getMessage())->toStartWith('Remove 1 row from row 5 of home is refused; nothing was changed.') + ->and($refusal->getMessage())->toContain('assets/Maps/home/home.data.php events.D.data.spawnPoint.y (it stands on a removed line)') + ->and($refusal->getMessage())->toContain('assets/Maps/town/town.data.php events.A.data.spawnPoint.y') + ->and($refusal->getMessage())->toContain('assets/Data/system.php startingPositions.player.spawnPoint.y') + ->and($refusal->getMessage())->toContain('assets/Events/home-only.php 0.y'); + } + expect(fn() => planLineRemoval($root, 'x', 2, 1))->toThrow(RuntimeException::class, 'assets/Maps/home/home.data.php npcs.0.x (it stands on a removed line)') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); + +it('moves spans beyond removed lines back, shrinks straddling ones and loses only spans wholly removed', function () { + $removal = new Ichiloto\Editor\Maps\LineRemoval('home', 'y', 2, 2); + + expect($removal->getShiftedSpan(0, 2))->toBe([0, 2]) + ->and($removal->getShiftedSpan(1, 2))->toBe([1, 1]) + ->and($removal->getShiftedSpan(1, 5))->toBe([1, 3]) + ->and($removal->getShiftedSpan(3, 3))->toBe([2, 2]) + ->and($removal->getShiftedSpan(4, 1))->toBe([2, 1]) + ->and($removal->getShiftedSpan(2, 2))->toBeNull() + ->and($removal->getShiftedCoordinate(3))->toBeNull() + ->and($removal->getShiftedCoordinate(5))->toBe(3) + ->and($removal->getShiftedCoordinate(1))->toBe(1); +}); + +it('refuses lines outside the map and a removal that would leave it empty', function () { + $root = mapLineRemovalProject(); + + expect(fn() => planLineRemoval($root, 'y', 4, 2))->toThrow(MapSourceRefusal::class, 'home has no rows 5 to 6 to remove') + ->and(fn() => planLineRemoval($root, 'x', 0, 6))->toThrow(MapSourceRefusal::class, 'would leave home empty'); +}); + +it('refuses a removal through a world object, which is drawn whole, and moves one beyond it back', function () { + $inventory = new Ichiloto\Editor\Maps\MapLineInventory(new Ichiloto\Editor\Maps\LineRemoval('home', 'y', 2, 1), []); + $inventory->addMapData('home.data.php', 'home', ['worldObjects' => [ + ['id' => 'shelf', 'resource' => 'shelf', 'anchor' => ['x' => 3, 'y' => 1], 'covers' => ['layer' => 'terrain', 'cells' => [[3, 1], [3, 2]]]], + ['id' => 'stool', 'resource' => 'stool', 'anchor' => ['x' => 1, 'y' => 3], 'covers' => ['layer' => 'terrain', 'cells' => [[1, 3]]]], + ]]); + + expect($inventory->getBlocked()['home.data.php'])->toBe([ + ['path' => ['worldObjects', 0, 'covers', 'cells', 1, 1], 'reason' => 'world object shelf covers a cell on a removed line'], + ]) + ->and($inventory->getShifts()['home.data.php'])->toBe([ + ['path' => ['worldObjects', 1, 'anchor', 'y'], 'value' => 2], + ['path' => ['worldObjects', 1, 'covers', 'cells', 0, 1], 'value' => 2], + ]); +}); + +it('lands saves standing on removed lines on the reachable cell nearest them, in the map after the removal', function () { + $root = mapLineRemovalProject(); + $plan = planLineRemoval($root, 'y', 2, 1); + + expect($plan->edit->landing)->toBe([2, 1]) + ->and($plan->notes)->toBe([ + 'Saves standing on the removed lines land at x 2, y 1.', + 'Saves move with the map: save content version 1 adds this map removal.', + ]); + $plan->apply(); + + expect(require $root . '/assets/Data/save-compatibility.php')->toBe([ + 'contentVersion' => 1, + 'migrations' => [['from' => 0, 'to' => 1, 'mapRemovals' => [['map' => 'home', 'axis' => 'y', 'at' => 2, 'count' => 1, 'to' => [2, 1]]]]], + 'aliases' => [], + 'tombstones' => [], + ]) + ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0)) + ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\DeclaredPositionContentMigration::class) + ->and(describeSaveCompatibilityIssues($root))->toBe([]); +}); + +it('lands saves on the author\'s cell when it is reachable, and refuses one that is not', function () { + $root = mapLineRemovalProject(); + + expect(planLineRemoval($root, 'y', 2, 1, [3, 2])->edit->landing)->toBe([3, 2]) + ->and(fn() => planLineRemoval($root, 'y', 2, 1, [0, 1])) + ->toThrow(RuntimeException::class, 'Saves cannot land at x 0, y 1: the player cannot reach that cell.'); +}); + +it('says existing saves are not migrated, and needs no landing, when the project has no save manifest', function () { + $root = mapLineRemovalProject(); + unlink($root . '/assets/Data/save-compatibility.php'); + unlink($root . '/assets/Maps/collisions.php'); + $plan = planLineRemoval($root, 'y', 2, 1); + + expect($plan->notes)->toBe(['Existing saves are not migrated: the project has no assets/Data/save-compatibility.php.']) + ->and($plan->edit->landing)->toBeNull(); +}); + +it('reports relative route steps that may cross the edited lines for a hand check', function () { + $root = mapLineRemovalProject(); + $path = $root . '/assets/Events/home-only.php'; + file_put_contents($path, str_replace("return [\n", "return [\n ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'down', 'count' => 2]]],\n", + (string) file_get_contents($path))); + + expect(array_column(planLineRemoval($root, 'y', 2, 1)->handEdits, 'reason', 'where')) + ->toMatchArray(['0.steps' => 'its relative steps may cross the removed lines; check where the route ends']) + ->and(array_column(MapLinePlanner::planInsertion($root, 'home', 'y', 2, 1)->handEdits, 'reason', 'where')) + ->toMatchArray(['0.steps' => 'its relative steps may cross the inserted lines; check where the route ends']); +}); + +it('removes through the palette after a count and a confirmation, and one undo restores every file', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + $editor = mapLineEditor($root); + setEditorProperty($editor, 'cursorY', 2); + + openMapLinePaletteItem($editor, 'Map: Remove rows from the cursor down'); + expect(getEditorProperty($editor, 'modals')->has(Ichiloto\Editor\UI\Modal::MAP_LINES))->toBeTrue(); + pressKeys($editor, "\r"); + + $labels = array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label'); + expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Remove 1 row from row 3 of home') + ->and($labels)->toContain('Saves standing on the removed lines land at x 2, y 1.') + ->and($labels)->toContain('Hand edit: assets/Events/shared.php 0.y') + ->and(sourceHashTree($root . '/assets'))->toBe($before); + + pressKeys($editor, "\033[B", "\r"); + expect(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(4) + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Removed 1 row from row 3 of home.'); + + pressKeys($editor, "\x1a"); + expect(sourceHashTree($root . '/assets'))->toBe($before) + ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->getHeight())->toBe(5); +}); + +it('reports a refused removal in the status line without opening a confirmation', function () { + $root = mapLineRemovalProject(); + $before = sourceHashTree($root . '/assets'); + $editor = mapLineEditor($root); + setEditorProperty($editor, 'cursorY', 4); + + openMapLinePaletteItem($editor, 'Map: Remove rows from the cursor down'); + pressKeys($editor, "\r"); + + expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('is refused; nothing was changed.') + ->and(sourceHashTree($root . '/assets'))->toBe($before); +}); diff --git a/tests/Unit/MapPhysicalOccupancyGeometryTest.php b/tests/Unit/MapPhysicalOccupancyGeometryTest.php new file mode 100644 index 00000000..3f1062a0 --- /dev/null +++ b/tests/Unit/MapPhysicalOccupancyGeometryTest.php @@ -0,0 +1,449 @@ + 'home',\n"; + file_put_contents($directory . '/test-map.data.php', " 'Synthetic geometry',\n 'region' => '',\n" + . " 'description' => strtoupper('authored'),\n 'note' => \$note,\n" + . $tileset . " 'events' => [],\n // Physical ground, not terminal art.\n" + . " 'occupancy' => {$declaration},\n];\n"); +} + +function loadOccupancyGeometryMap(string $root): ProjectMap +{ + return ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map'); +} + +it('resizes physical cells with terminal event and graphical geometry and pads only with solid cells', function (bool $legacy, bool $ragged) { + $root = createOccupancyGeometryProject($ragged, $legacy); + $map = loadOccupancyGeometryMap($root); + $original = $map->getResolvedCollisionMap(); + $source = (string) file_get_contents($map->dataPath); + $map->resize(6, 3); + $expected = array_map(static fn(array $row): array => array_pad($row, 6, CollisionType::SOLID->value), $original); + $expected[] = array_fill(0, 6, CollisionType::SOLID->value); + expect($map->getResolvedCollisionMap())->toBe($expected) + ->and($map->getTileSymbol(5, 2))->toBe(' ') + ->and($map->getEventMarkerAt(5, 2))->toBeNull() + ->and(file_get_contents($map->dataPath))->toBe($source); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected) + ->and(file_get_contents($map->dataPath))->toContain("strtoupper('authored')", "'note' => \$note", '// Physical ground, not terminal art.'); + if (! $legacy) { + expect(readTileRows($map->directory . '/graphics/01.floor.tiles.php')[2])->toBe(array_fill(0, 6, 0)); + } + $map->resize(2, 1); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe([array_slice($original[0], 0, 2)]); +})->with([false, true])->with([false, true]); + +it('inserts physical cells at the same line as presentation cells including ragged row and column edges', function (bool $legacy, bool $ragged, string $axis, int $at) { + $root = createOccupancyGeometryProject($ragged, $legacy); + $map = loadOccupancyGeometryMap($root); + $expected = $map->getResolvedCollisionMap(); + if ($axis === 'y') { + $beside = $expected[$at] ?? $expected[$at - 1]; + array_splice($expected, $at, 0, array_fill(0, 2, array_fill(0, count($beside), CollisionType::SOLID->value))); + } else { + foreach ($expected as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, array_fill(0, 2, CollisionType::SOLID->value)); + } + } + unset($row); + } + $before = $map->captureGridSnapshot(); + $map->insertLines($axis, $at, 2); + $after = $map->captureGridSnapshot(); + expect($map->getResolvedCollisionMap())->toBe($expected) + ->and(array_map(count(...), $map->getLayerSet()->getComposedGrid()))->toBe(array_map(count(...), $expected)); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected); + $map->restoreGridSnapshot($before); + $map->save(); + expect($map->getMapDataField(['occupancy']))->toBe($before['occupancy']); + $map->restoreGridSnapshot($after); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected); +})->with([false, true])->with([false, true])->with([ + 'first column' => ['x', 0], 'middle column' => ['x', 1], 'ragged boundary' => ['x', 2], 'last column' => ['x', 4], + 'first row' => ['y', 0], 'middle row' => ['y', 1], 'last row' => ['y', 2], +]); + +it('keeps resize history meaningful across save undo redo and unrelated metadata edits', function () { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $originals = sourceHashTree($map->directory); + $before = $map->captureGridSnapshot(); + $map->resize(6, 3); + $after = $map->captureGridSnapshot(); + $history = new CommandHistory(); + $history->record(new GenericCommand('Resize', fn() => $map->restoreGridSnapshot($after), fn() => $map->restoreGridSnapshot($before))); + $map->save(); + $resized = sourceHashTree($map->directory); + expect($map->isDirty())->toBeFalse(); + $history->undo(); + expect($map->isDirty())->toBeTrue()->and($map->getMapDataField(['occupancy']))->toBe($before['occupancy']); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($originals); + $history->redo(); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($resized)->and($map->isDirty())->toBeFalse(); + $map->setMapField('name', 'Later metadata'); + $history->undo(); + expect($map->getDisplayName())->toBe('Later metadata')->and($map->getMapDataField(['occupancy']))->toBe($before['occupancy']); +}); + +it('carries physical geometry through the existing inspector resize command', function () { + $root = createOccupancyGeometryProject(); + [$editor, $map] = layeredCanvasEditor($root); + $before = sourceHashTree($map->directory); + $physical = $map->getResolvedCollisionMap(); + callEditorMethod($editor, 'setEditingMode', 'map'); + callEditorMethod($editor, 'applyInspectorFieldValue', ['label' => ' X', 'value' => '4', 'target' => 'map-size', 'field' => 'width'], '6'); + expect($map->getResolvedCollisionMap()[0])->toBe([...$physical[0], CollisionType::SOLID->value, CollisionType::SOLID->value]); + $map->save(); + callEditorMethod($editor, 'performUndo'); + $map->save(); + expect(sourceHashTree($map->directory))->toBe($before)->and($map->getResolvedCollisionMap())->toBe($physical); + callEditorMethod($editor, 'performRedo'); + expect($map->getResolvedCollisionMap()[0])->toBe([...$physical[0], CollisionType::SOLID->value, CollisionType::SOLID->value]); +}); + +it('does not add physical declarations during legacy geometry edits or snapshot restoration', function (string $operation) { + $root = mapGraphicsProject(ragged: true); + $map = loadLayeredMap($root); + $source = file_get_contents($map->dataPath); + $before = $map->captureGridSnapshot(); + if ($operation === 'resize') { + $map->resize(5, 3); + } else { + $map->insertLines($operation, 1, 1); + } + $map->save(); + expect($map->hasMapDataField(['occupancy']))->toBeFalse()->and(file_get_contents($map->dataPath))->toBe($source); + $map->restoreGridSnapshot($before); + $map->save(); + expect($map->hasMapDataField(['occupancy']))->toBeFalse()->and(file_get_contents($map->dataPath))->toBe($source); +})->with(['resize', 'x', 'y']); + +it('refuses malformed declared physical data before grid state or history changes', function (mixed $rows, string $operation) { + $root = createOccupancyGeometryProject(); + $directory = $root . '/assets/Maps/test-map'; + writeGeometryOccupancyData($directory, $rows); + $map = loadOccupancyGeometryMap($root); + $data = $map->getMapDataField([]); + $grids = $map->getGridSources(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + $history = new CommandHistory(); + $apply = match ($operation) { + 'resize' => fn() => $map->resize(6, 3), + 'no-op resize' => fn() => $map->resize(4, 2), + 'snapshot' => fn() => $map->captureGridSnapshot(), + default => fn() => $map->insertLines($operation, 1, 1), + }; + expect(function () use ($apply, $history): void { + $apply(); + $history->record(new GenericCommand('Geometry', fn() => null, fn() => null)); + })->toThrow(MapSourceRefusal::class, 'occupancy'); + expect($map->getGridSources())->toBe($grids)->and($map->getMapDataField([]))->toBe($data) + ->and($map->getContentVersion())->toBe($version)->and($history->count())->toBe(0) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with([ + 'declared null' => [null], 'non-list' => [['one' => []]], 'missing rows' => [[]], + 'short row' => [[[CollisionType::NONE], array_fill(0, 4, CollisionType::NONE)]], + 'integer cells' => [array_fill(0, 2, array_fill(0, 4, 0))], + 'unresolved passage' => [array_fill(0, 2, array_fill(0, 4, CollisionType::PASS_THROUGH))], +])->with(['resize', 'no-op resize', 'x', 'y', 'snapshot']); + +it('refuses computed physical source edits before mutation rather than flattening them', function (string $operation, bool $wholeSource) { + $root = createOccupancyGeometryProject(); + $directory = $root . '/assets/Maps/test-map'; + $rows = loadOccupancyGeometryMap($root)->getMapDataField(['occupancy']); + writeGeometryOccupancyData($directory, $rows, expression: 'array_values(' . PhpValueExporter::export($rows, 1) . ')'); + if ($wholeSource) { + $path = $directory . '/test-map.data.php'; + file_put_contents($path, str_replace('return [', 'return array_merge([', str_replace("];\n", "]);\n", (string) file_get_contents($path)))); + } + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $operation === 'resize' ? $map->resize(6, 3) : $map->insertLines($operation, 1, 1)) + ->toThrow(MapSourceRefusal::class); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['resize', 'x', 'y'])->with([false, true]); + +it('refuses unsafe occupancy snapshots before changing grids or undo stacks', function (string $failure) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $map->resize(6, 3); + $after = $map->captureGridSnapshot(); + $bad = $before; + if ($failure === 'old snapshot') { + unset($bad['occupancy']); + } elseif ($failure === 'non-array snapshot') { + $bad['occupancy'] = 'not physical rows'; + } elseif ($failure === 'wrong geometry') { + $bad['occupancy'] = $after['occupancy']; + } else { + $bad['occupancy'][0][0] = CollisionType::PASS_THROUGH; + } + $history = new CommandHistory(); + $history->record(new GenericCommand('Resize', fn() => $map->restoreGridSnapshot($after), fn() => $map->restoreGridSnapshot($bad))); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $history->undo())->toThrow(MapSourceRefusal::class); + expect($map->captureGridSnapshot())->toBe($after)->and($map->getContentVersion())->toBe($version) + ->and($history->count())->toBe(1)->and($history->canRedo())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['old snapshot', 'malformed snapshot', 'non-array snapshot', 'wrong geometry']); + +it('retains no-op and older snapshot compatibility when physical state is already valid', function (bool $declared) { + $root = $declared ? createOccupancyGeometryProject() : mapGraphicsProject(); + $map = loadOccupancyGeometryMap($root); + $snapshot = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + $map->resize(4, 2); + expect($map->captureGridSnapshot())->toBe($snapshot)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); + if (! $declared) { + unset($snapshot['occupancy']); + $map->resize(6, 3); + $map->restoreGridSnapshot($snapshot); + expect($map->isDirty())->toBeFalse()->and($map->hasMapDataField(['occupancy']))->toBeFalse(); + } +})->with([false, true]); + +it('refuses external source changes before geometry or history mutation', function (string $operation) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + file_put_contents($map->dataPath, (string) file_get_contents($map->dataPath) . "// External author edit.\n"); + $hashes = sourceHashTree($root); + $history = new CommandHistory(); + $apply = match ($operation) { + 'resize' => fn() => $map->resize(6, 3), + 'restore' => fn() => $map->restoreGridSnapshot($before), + default => fn() => $map->insertLines($operation, 1, 1), + }; + $history->record(new GenericCommand('Geometry', fn() => null, $apply)); + expect(fn() => $history->undo())->toThrow(MapSourceRefusal::class, 'changed after opening'); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($history->count())->toBe(1)->and($history->canRedo())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['resize', 'x', 'y', 'restore']); + +it('keeps declared physical data untouched when graphical geometry preflight refuses', function (string $operation) { + $root = createOccupancyGeometryProject(); + writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', "0 bad 0 0\n0 0 0 5"); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $operation === 'resize' ? $map->resize(6, 3) : $map->insertLines($operation, 1, 1)) + ->toThrow(MapSourceRefusal::class, '02.decor.tiles.php'); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); +})->with(['resize', 'x', 'y']); + +it('rolls back physical data and every grid together when geometry save installation fails', function (string $operation) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $hashes = sourceHashTree($map->directory); + if ($operation === 'resize') { + $map->resize(6, 3); + } else { + $map->insertLines($operation, 1, 1); + } + $physical = $map->getResolvedCollisionMap(); + $failure = null; + try { + $map->save(files: new FailingFileSetOperations(failures: ['move' => [$map->eventPath]])); + } catch (FileSetTransactionFailure $thrown) { + $failure = $thrown; + } + expect($failure)->not->toBeNull()->and($failure->wasRolledBack)->toBeTrue() + ->and(sourceHashTree($map->directory))->toBe($hashes)->and($map->getResolvedCollisionMap())->toBe($physical) + ->and($map->isDirty())->toBeTrue(); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($physical)->and($map->isDirty())->toBeFalse(); +})->with(['resize', 'x', 'y']); + +it('includes physical geometry in the project insertion source set with coordinate edits and exact undo', function (string $axis, bool $withCoordinates) { + $root = createOccupancyGeometryProject(); + $directory = $root . '/assets/Maps/test-map'; + $path = $directory . '/test-map.data.php'; + if ($withCoordinates) { + file_put_contents($path, str_replace("'events' => [],", "'npcs' => [['id' => 'visitor', 'name' => 'Synthetic visitor', 'sprite' => 'v', 'x' => 2, 'y' => 1]],\n" + . " 'events' => ['D' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\SleepEventTrigger',\n" + . " 'data' => ['spawnPoint' => ['x' => 2, 'y' => 1]]]],", (string) file_get_contents($path))); + } + $original = loadOccupancyGeometryMap($root); + $before = sourceHashTree($root); + $originalPhysical = $original->getResolvedCollisionMap(); + $expected = $originalPhysical; + if ($axis === 'x') { + foreach ($expected as &$row) { + array_splice($row, 1, 0, [CollisionType::SOLID->value, CollisionType::SOLID->value]); + } + unset($row); + } else { + array_splice($expected, 1, 0, array_fill(0, 2, array_fill(0, 4, CollisionType::SOLID->value))); + } + $plan = MapLinePlanner::planInsertion($root, 'test-map', $axis, 1, 2); + expect(sourceHashTree($root))->toBe($before)->and($plan->getChangedPaths())->toContain($path); + $plan->apply(); + $after = sourceHashTree($root); + $map = loadOccupancyGeometryMap($root); + expect($map->getResolvedCollisionMap())->toBe($expected) + ->and(file_get_contents($path))->toContain("strtoupper('authored')", "'note' => \$note", '// Physical ground, not terminal art.'); + if ($withCoordinates) { + $point = $axis === 'x' ? ['x' => 4, 'y' => 1] : ['x' => 2, 'y' => 3]; + expect(['x' => $map->getMapDataField(['npcs', 0, 'x']), 'y' => $map->getMapDataField(['npcs', 0, 'y'])])->toBe($point) + ->and($map->getEventField('D', ['data', 'spawnPoint']))->toBe($point); + } + $plan->revert(); + expect(sourceHashTree($root))->toBe($before)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($originalPhysical); + $plan->apply(); + expect(sourceHashTree($root))->toBe($after)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($expected); +})->with(['x', 'y'])->with([false, true]); + +it('refuses a project insertion with malformed or unrewritable occupancy before proposing any writes', function (bool $malformed) { + $root = createOccupancyGeometryProject(); + $map = loadOccupancyGeometryMap($root); + $rows = $map->getMapDataField(['occupancy']); + writeGeometryOccupancyData($map->directory, $malformed ? null : $rows, + expression: $malformed ? null : 'array_values(' . PhpValueExporter::export($rows, 1) . ')'); + $before = sourceHashTree($root); + expect(fn() => MapLinePlanner::planInsertion($root, 'test-map', 'y', 1, 1))->toThrow(MapSourceRefusal::class) + ->and(sourceHashTree($root))->toBe($before); +})->with([false, true]); + +it('preserves unrelated object expressions in isolated insertion readback with and without declared occupancy', function (bool $declared) { + $root = $declared ? createOccupancyGeometryProject() : mapGraphicsProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($path, str_replace("'events' => [],", "'events' => [], 'futureObject' => new \\DateTimeImmutable('2001-02-03'),", + (string) file_get_contents($path))); + $before = sourceHashTree($root); + $plan = MapLinePlanner::planInsertion($root, 'test-map', 'x', 1, 1); + $plan->apply(); + $map = loadOccupancyGeometryMap($root); + expect($map->hasMapDataField(['occupancy']))->toBe($declared) + ->and($map->getMapDataField(['futureObject'])->format('Y-m-d'))->toBe('2001-02-03') + ->and(file_get_contents($path))->toContain("new \\DateTimeImmutable('2001-02-03')"); + if ($declared) { + expect(array_map(count(...), $map->getResolvedCollisionMap()))->toBe([5, 5]); + } + $plan->revert(); + expect(sourceHashTree($root))->toBe($before); +})->with([false, true]); + +it('retains physical geometry in the existing revisioned insertion command and its undo redo', function (string $axis) { + $root = createOccupancyGeometryProject(); + $session = EditorSession::open($root); + $read = $session->readMap('test-map'); + $before = sourceHashTree($root); + $physical = loadOccupancyGeometryMap($root)->getResolvedCollisionMap(); + $question = $session->insertMapLines('test-map', $read['revision'], $axis, 1, 2); + expect($question['paths'])->toContain('assets/Maps/test-map/test-map.data.php') + ->and(sourceHashTree($root))->toBe($before); + $session->insertMapLines('test-map', $read['revision'], $axis, 1, 2, 'write', $question['confirm']); + $after = sourceHashTree($root); + $inserted = loadOccupancyGeometryMap($root)->getResolvedCollisionMap(); + expect($inserted)->not->toBe($physical)->and($session->readMap('test-map')['physicalOccupancy'])->toBeTrue(); + $session->undo(); + expect(sourceHashTree($root))->toBe($before)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($physical); + $session->redo(); + expect(sourceHashTree($root))->toBe($after)->and(loadOccupancyGeometryMap($root)->getResolvedCollisionMap())->toBe($inserted); +})->with(['x', 'y']); + +it('follows legacy geometry exactly when a ragged map contains an empty interior row', function (string $operation, int $at) { + $root = createOccupancyGeometryProject(legacy: true); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource("...\n\n..", 'GROUND')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" E \n\n ", 'EVENTS')); + $rows = [[CollisionType::EXIT, CollisionType::NONE, CollisionType::COUNTER], [], [CollisionType::ENCOUNTER, CollisionType::ITEM]]; + writeGeometryOccupancyData($directory, $rows, legacy: true); + $map = loadOccupancyGeometryMap($root); + $expected = $rows; + if ($operation === 'resize') { + $expected = array_map(static fn(array $row): array => array_pad($row, 4, CollisionType::SOLID), $expected); + $expected[] = array_fill(0, 4, CollisionType::SOLID); + $map->resize(4, 4); + } elseif ($operation === 'y') { + $beside = $expected[$at] ?? $expected[$at - 1]; + array_splice($expected, $at, 0, [array_fill(0, count($beside), CollisionType::SOLID)]); + $map->insertLines('y', $at, 1); + } else { + foreach ($expected as &$row) { + if (count($row) >= $at) { + array_splice($row, $at, 0, [CollisionType::SOLID]); + } + } + unset($row); + $map->insertLines('x', $at, 1); + } + expect($map->getMapDataField(['occupancy']))->toBe($expected) + ->and(array_map(count(...), $map->getLayerSet()->getComposedGrid()))->toBe(array_map(count(...), $expected)); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getMapDataField(['occupancy']))->toBe($expected); +})->with(['resize' => ['resize', 0], 'empty column' => ['x', 0], 'short columns' => ['x', 3], + 'empty adjacent row' => ['y', 1], 'append row' => ['y', 3]]); + +it('grows an empty legacy physical row deliberately and refuses unrepresentable empty row insertions without mutation', function () { + $root = createOccupancyGeometryProject(legacy: true); + $directory = $root . '/assets/Maps/test-map'; + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource('', 'GROUND')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource('', 'EVENTS')); + writeGeometryOccupancyData($directory, [[]], legacy: true); + $map = loadOccupancyGeometryMap($root); + $before = $map->captureGridSnapshot(); + $version = $map->getContentVersion(); + $hashes = sourceHashTree($root); + expect(fn() => $map->insertLines('y', 0, 1))->toThrow(MapSourceRefusal::class, 'Nothing was changed.'); + expect($map->captureGridSnapshot())->toBe($before)->and($map->getContentVersion())->toBe($version) + ->and($map->isDirty())->toBeFalse()->and(sourceHashTree($root))->toBe($hashes); + $map->resize(2, 2); + $map->save(); + expect(loadOccupancyGeometryMap($root)->getMapDataField(['occupancy']))->toBe(array_fill(0, 2, array_fill(0, 2, CollisionType::SOLID))); +}); diff --git a/tests/Unit/MapRuntimeMetadataTest.php b/tests/Unit/MapRuntimeMetadataTest.php index 0988d984..5897e98c 100644 --- a/tests/Unit/MapRuntimeMetadataTest.php +++ b/tests/Unit/MapRuntimeMetadataTest.php @@ -92,6 +92,49 @@ expect(require $root . '/assets/Maps/test-map/test-map.data.php')->not->toHaveKey('bgm'); }); +// -- Movement ---------------------------------------------------------------- + +it('turns dashing off for one map with a switch, writing the Engine\'s key and removing it again', function () { + $root = metadataProject(); + $editor = metadataEditor($root); + $map = callEditorMethod($editor, 'getSelectedMap'); + + expect(inspectorValueOf($editor, 'Disable Dashing'))->toBe('false') + ->and(mapDataOf($editor))->not->toHaveKey('disableDashing'); + + // A switch toggles in place in the Terminal editor. + selectInspectorField($editor, 'Disable Dashing'); + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor)['disableDashing'])->toBeTrue() + ->and(inspectorValueOf($editor, 'Disable Dashing'))->toBe('true') + ->and(Ichiloto\Engine\Field\DashSettings::readMapDisablesDashing(mapDataOf($editor)))->toBeTrue() + ->and($map->isDirty())->toBeTrue(); + $map->save(); + expect((require $root . '/assets/Maps/test-map/test-map.data.php')['disableDashing'])->toBeTrue(); + + // Allowing it again removes the key: an absent key follows the project's dash. + selectInspectorField($editor, 'Disable Dashing'); + callEditorMethod($editor, 'activateInspectorField'); + expect(mapDataOf($editor))->not->toHaveKey('disableDashing'); + $map->save(); + expect(require $root . '/assets/Maps/test-map/test-map.data.php')->not->toHaveKey('disableDashing'); +}); + +it('serves Disable Dashing to the GUI session as a switch, one undo step each way', function () { + $root = metadataProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = (string) file_get_contents($path); + $session = Ichiloto\Editor\Session\EditorSession::open($root); + $row = static fn(): array => array_find($session->readInspector('test-map')['rows'], + static fn(array $row): bool => $row['label'] === 'Disable Dashing') ?? throw new RuntimeException('Missing Disable Dashing row'); + + expect($row())->toMatchArray(['kind' => 'boolean', 'value' => 'false', 'depth' => 1]); + $session->applyInspector('test-map', $session->readInspector('test-map')['revision'], $row()['key'], 'true'); + expect($row()['value'])->toBe('true'); + $session->undo(); + expect($row()['value'])->toBe('false')->and((string) file_get_contents($path))->toBe($source); +}); + // -- Encounters -------------------------------------------------------------- it('reads the engine\'s defaults without writing them, and shows them as defaults', function () { @@ -266,7 +309,7 @@ 'a block that is not an array' => ['every few steps', 'is string, not an array'], 'troops that are not troop weights' => [['troops' => 'Bat x 2'], 'troops are string'], 'a troop keyed by number' => [['troops' => [5 => 'Bat x 2']], 'keyed by int'], - 'a weight that is an array' => [['troops' => ['Bat x 2' => ['weight' => 3]]], 'weight that is array'], + 'a weight that is an array' => [['troops' => ['Bat x 2' => ['weight' => [3]]]], 'weight that is array'], ]); // -- Validation -------------------------------------------------------------- @@ -451,8 +494,8 @@ $authored = MapEncounters::of(['troops' => ['Bat x 2' => 3, 'Lone Rat' => 1], 'rate' => 22, 'tiles' => 'any']); expect($authored->rows())->toBe([ - ['name' => 'Bat x 2', 'weight' => 3], - ['name' => 'Lone Rat', 'weight' => 1], + ['name' => 'Bat x 2', 'weight' => 3, 'arena' => null, 'areas' => [], 'entry' => null], + ['name' => 'Lone Rat', 'weight' => 1, 'arena' => null, 'areas' => [], 'entry' => null], ]) ->and($authored->rate())->toBe(22) ->and($authored->authoredRate())->toBe(22) diff --git a/tests/Unit/MapSaveIntegrityTest.php b/tests/Unit/MapSaveIntegrityTest.php index a5759e60..a21b2304 100644 --- a/tests/Unit/MapSaveIntegrityTest.php +++ b/tests/Unit/MapSaveIntegrityTest.php @@ -250,20 +250,31 @@ ->and(tripletState($map))->toBe($before, 'and wrote nothing'); }); -it('replaces a variable-backed value with plain data while the shared variable survives for its other users', function () { +it('refuses an edit to a value the file builds in code, naming the variable, and writes nothing', function () { $root = authoredMapProject(); $map = authoredMap($root); + $before = tripletState($map); + $script = $map->getEventField('W', ['data', 'script']); - // W's script is written as `$watchScript`. Replacing the value is safe: - // the entry gets the literal, the prelude variable stays untouched for - // anything else that names it. - $map->setEventField('W', ['data', 'script'], [['type' => 'text', 'name' => 'X', 'text' => 'Rewritten.']]); - $map->save(); - $data = (string) file_get_contents($map->dataPath); + // W's script is `$watchScript`, which the file fills with a `require`. + // Writing the edited script as a literal would leave that code running + // for nothing, so the edit is refused before anything is written. + $refusal = null; + + try { + $map->setEventField('W', ['data', 'script'], [['type' => 'text', 'name' => 'X', 'text' => 'Rewritten.']]); + } catch (MapSourceRefusal $thrown) { + $refusal = $thrown; + } - expect($data)->toContain("\$watchScript = require dirname(__DIR__, 2) . '/Events/harbour-watch.php';") - ->and($data)->toContain('Rewritten.') - ->and($map->getEventField('W', ['data', 'script']))->toBe([['type' => 'text', 'name' => 'X', 'text' => 'Rewritten.']]); + expect($refusal)->not->toBeNull() + ->and($refusal->getMessage())->toContain('test-map.data.php') + ->and($refusal->getMessage())->toContain('events.W.data.script') + ->and($refusal->getMessage())->toContain('$watchScript') + ->and($refusal->getMessage())->toContain('own code builds or uses') + ->and($map->getEventField('W', ['data', 'script']))->toBe($script) + ->and($map->isDirty())->toBeFalse() + ->and(tripletState($map))->toBe($before, 'and wrote nothing'); }); it('keeps opaque siblings byte-identical while a safe edit lands beside them', function () { @@ -444,6 +455,45 @@ // -- First save, creation and move -------------------------------------------- +it('refuses executable grid source at load without running its side effect', function (string $member) { + $root = authoredMapProject(); + $sourcePath = $root . "/assets/Maps/test-map/test-map.{$member}.php"; + $marker = $root . '/grid-was-executed'; + file_put_contents($sourcePath, " authoredMap($root))->toThrow(MapSourceRefusal::class, "test-map.{$member}.php"); + expect(is_file($marker))->toBeFalse(); +})->with(['map', 'event']); + +it('refuses an externally replaced grid before save, duplicate or move changes files', function (string $member, string $action) { + $root = authoredMapProject(); + $map = authoredMap($root); + if ($action === 'dirty save') { + $map->setTileSymbol(1, 1, '~'); + } + + $sourcePath = $member === 'map' ? $map->mapPath : $map->eventPath; + file_put_contents($sourcePath, "isDirty(); + + $operation = static function () use ($map, $root, $action): void { + if ($action === 'duplicate') { + $map->duplicateTo($root . '/assets/Maps/copy', 'copy', 'Copy'); + } elseif ($action === 'move') { + $map->moveTo('district/harbour'); + } else { + $map->save(); + } + }; + + expect($operation)->toThrow(MapSourceRefusal::class, basename($sourcePath)); + expect(sourceHashTree($root))->toBe($before) + ->and($map->isDirty())->toBe($dirtyBefore) + ->and(is_dir($root . '/assets/Maps/copy'))->toBeFalse() + ->and(is_dir($root . '/assets/Maps/district'))->toBeFalse(); +})->with(['map', 'event'])->with(['clean save', 'dirty save', 'duplicate', 'move']); + it('leaves no partial folder when a first save fails', function () { $root = authoredMapProject(); ProjectMap::createBlank($root . '/assets/Maps/new-quarter', 'new-quarter', 'New Quarter'); @@ -533,7 +583,7 @@ ->and($movedData)->toContain("'station' => ['x' => 3, 'y' => 1],"); }); -it('refuses a move when a staged grid member no longer evaluates at its destination', function (string $member) { +it('removes executable grid sources from the move contract before a transaction starts', function (string $member) { $root = authoredMapProject(); $map = authoredMap($root); $portableData = str_replace( @@ -545,7 +595,6 @@ $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); file_put_contents($map->dataPath, $portableData); - $map = authoredMap($root); $sourcePath = $member === 'map' ? $map->mapPath : $map->eventPath; $sharedPath = $root . '/assets/Maps/shared-' . $member . '.php'; file_put_contents($sharedPath, " $map->moveTo('district/harbour')) - ->toThrow(RuntimeException::class, "harbour.{$member}.php does not evaluate at district/harbour"); + ->toThrow(MapSourceRefusal::class, "test-map.{$member}.php"); expect(is_dir($root . '/assets/Maps/district'))->toBeFalse() ->and(tripletState($map))->toBe($before); })->with(['map', 'event']); @@ -572,15 +620,12 @@ ); $portableData = str_replace("'script' => \$watchScript,", "'script' => [],", $portableData); $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); - file_put_contents($map->dataPath, $portableData); - - $mapText = require $map->mapPath; - file_put_contents( - $map->mapPath, - "eventPath, true) . ";\n\nreturn " - . var_export($mapText, true) - . ";\n", + $portableData = str_replace( + 'return [', + "if (basename(__DIR__) === 'harbour') { throw new \\RuntimeException('destination data refusal'); }\n\nreturn [", + $portableData, ); + file_put_contents($map->dataPath, $portableData); $map = authoredMap($root); if ($retainSourceDirectory) { @@ -601,7 +646,7 @@ $fileBefore = stat($map->dataPath); expect(fn() => $map->moveTo('district/harbour')) - ->toThrow(RuntimeException::class, 'harbour.map.php does not evaluate at district/harbour'); + ->toThrow(RuntimeException::class, 'harbour.data.php does not evaluate at district/harbour'); clearstatcache(true, $map->directory); $directoryAfter = stat($map->directory); $fileAfter = stat($map->dataPath); @@ -633,18 +678,14 @@ ); $portableData = str_replace("'script' => \$watchScript,", "'script' => [],", $portableData); $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); - file_put_contents($map->dataPath, $portableData); - - $mapText = require $map->mapPath; $sourceDirectory = $map->directory; - file_put_contents( - $map->mapPath, - "dataPath, $portableData); $map = authoredMap($root); $moved = $map->moveTo('district/harbour'); @@ -664,13 +705,10 @@ ); $portableData = str_replace("'script' => \$watchScript,", "'script' => [],", $portableData); $portableData = str_replace("'script' => require dirname(__DIR__, 2) . '/Events/harbour-watch.php',", "'script' => [],", $portableData); - file_put_contents($map->dataPath, $portableData); - - $mapText = require $map->mapPath; $mapsRoot = $root . '/assets/Maps'; - file_put_contents( - $map->mapPath, - "dataPath, $portableData); $map = authoredMap($root); $other = ProjectMap::fromDirectory($mapsRoot, $mapsRoot . '/other'); $mapBefore = tripletState($map); @@ -698,7 +736,7 @@ ->and(is_dir($mapsRoot . '/new'))->toBeFalse(); }); -it('moves a map whose authored grid declares a named function', function (string $member) { +it('removes named-function grid sources from Editor loading', function (string $member) { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $portableData = str_replace( @@ -719,17 +757,11 @@ ); file_put_contents($sourcePath, rtrim($source) . "\n}\n\nreturn {$function}();\n"); - $map = authoredMap($root); - $moved = $map->moveTo('district/harbour'); - - expect($moved->mapId)->toBe('district/harbour') - ->and(is_dir($root . '/assets/Maps/test-map'))->toBeFalse() - ->and((string) file_get_contents( - $member === 'map' ? $moved->mapPath : $moved->eventPath, - ))->toContain("function {$function}(): string"); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, "test-map.{$member}.php"); + expect(is_dir($root . '/assets/Maps/district'))->toBeFalse(); })->with(['map', 'event']); -it('evaluates a moved triplet in runtime order when members share declarations', function () { +it('removes grid calls into data-file declarations', function () { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $mapPath = $root . '/assets/Maps/test-map/test-map.map.php'; @@ -749,15 +781,11 @@ file_put_contents($dataPath, $portableData); file_put_contents($mapPath, "moveTo('district/harbour'); - - expect($moved->mapId)->toBe('district/harbour') - ->and($moved->getWidth())->toBe($map->getWidth()) - ->and((string) file_get_contents($moved->mapPath))->toContain('editorSharedMapGridFixture()'); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, 'test-map.map.php'); + expect(function_exists('editorSharedMapGridFixture'))->toBeFalse(); }); -it('evaluates staged map members under their final basenames', function () { +it('removes filename-dependent executable grid sources', function () { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $mapPath = $root . '/assets/Maps/test-map/test-map.map.php'; @@ -778,11 +806,8 @@ . var_export($mapText, true) . " : '';\n", ); - $map = authoredMap($root); - $moved = $map->moveTo('district/harbour'); - - expect($moved->mapId)->toBe('district/harbour') - ->and(basename($moved->mapPath))->toBe('harbour.map.php'); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, 'test-map.map.php'); + expect(is_dir($root . '/assets/Maps/district'))->toBeFalse(); }); it('moves preserved map data containing an object without reconstructing it in the editor process', function () { @@ -811,7 +836,7 @@ ->and($reloaded->getEditableData()['opaque']->label)->toBe('preserved'); }); -it('fingerprints the final shared object state after the complete map load unit', function () { +it('removes grid mutation of shared data-file objects', function () { $root = authoredMapProject(); $dataPath = $root . '/assets/Maps/test-map/test-map.data.php'; $mapPath = $root . '/assets/Maps/test-map/test-map.map.php'; @@ -834,11 +859,7 @@ "label = 'after-grid';\n\nreturn " . var_export($mapText, true) . ";\n", ); - $map = authoredMap($root); - $moved = $map->moveTo('district/harbour'); - - expect($moved->getEditableData()['opaque']->label)->toBe('after-grid') - ->and(ProjectMap::fromDirectory($root . '/assets/Maps', $moved->directory)->getEditableData()['opaque']->label)->toBe('after-grid'); + expect(fn() => authoredMap($root))->toThrow(MapSourceRefusal::class, 'test-map.map.php'); }); // -- Editor-level flows ------------------------------------------------------- diff --git a/tests/Unit/MapSourceHistoryTest.php b/tests/Unit/MapSourceHistoryTest.php new file mode 100644 index 00000000..3f5b0323 --- /dev/null +++ b/tests/Unit/MapSourceHistoryTest.php @@ -0,0 +1,93 @@ + 'Source history fixture', + 'description' => strtoupper('kept'), + 'note' => $note, + 'region' => 'original', + 'events' => [ + 'E' => [ + 'class' => 'Ichiloto\Engine\Events\Triggers\ChestEventTrigger', + 'data' => ['lootType' => 'gold', 'loot' => 2], + 'cue' => [ + // Preserve the independently authored condition and expression. + 'symbol' => '!', 'color' => 'bright-blue', + 'kind' => strtolower('STORY'), + ], + ], + ], + ]; + PHP; + file_put_contents($file, $source); + $originalData = (static fn(string $path): array => require $path)($file); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $old = $map->getMapDataField($path); + $map->setMapDataField($path, null); + $map->save(); + expect($map->isDirty())->toBeFalse() + ->and($map->hasMapDataField($path))->toBeFalse(); + $removed = file_get_contents($file); + + if ($layerHistory) { + $before = $map->captureLayerSnapshot(); + $map->setMapField('region', 'temporary layer edit'); + $after = $map->captureLayerSnapshot(); + $map->restoreLayerSnapshot($before); + $map->restoreLayerSnapshot($after); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(file_get_contents($file))->toBe($removed); + } + + $map->setMapDataField($path, $old); + expect($map->getMapDataField([]))->toBe($originalData); + $map->save(); + expect(file_get_contents($file))->toBe($source) + ->and($map->isDirty())->toBeFalse() + ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getMapDataField($path))->toBe($old); + $map->setMapDataField($path, null); + $map->save(); + expect(file_get_contents($file))->toBe($removed); + $map->setMapDataField($path, $old); + $map->setMapField('region', 'later unrelated edit'); + $map->save(); + expect(file_get_contents($file))->toBe(str_replace("'region' => 'original'", "'region' => 'later unrelated edit'", $source)); +})->with([ + 'computed map field' => [['description']], + 'variable map field' => [['note']], + 'computed event field' => [['events', 'E', 'cue', 'kind']], + 'parent containing expression' => [['events', 'E', 'cue']], +])->with([false, true]); + +it('keeps the saved validation checkpoint when replaying layer history with incomplete command drafts', function (array $command) { + $root = makeTemporaryProject('editor-source-checkpoint-'); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $saved = $map->captureLayerSnapshot(); + $hashes = sourceHashTree($root); + $map->setMapDataField(['events', 'D'], [ + 'class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, + 'data' => ['script' => [$command]], + ]); + $draft = $map->captureLayerSnapshot(); + $map->restoreLayerSnapshot($saved); + $map->restoreLayerSnapshot($draft); + expect(fn() => $map->save())->toThrow(InvalidArgumentException::class) + ->and(sourceHashTree($root))->toBe($hashes) + ->and($map->isDirty())->toBeTrue(); + $map->restoreLayerSnapshot($saved); + $map->save(); + expect(sourceHashTree($root))->toBe($hashes)->and($map->isDirty())->toBeFalse(); +})->with([ + 'route draft' => [['type' => 'move_route', 'subject' => 'player', 'waypoints' => []]], + 'inn draft' => [['type' => 'inn', 'presentation' => ['treatment' => 'leader', 'leaders' => ['' => '']]]], +]); diff --git a/tests/Unit/MapValidatorTest.php b/tests/Unit/MapValidatorTest.php index 511acd85..30b77981 100644 --- a/tests/Unit/MapValidatorTest.php +++ b/tests/Unit/MapValidatorTest.php @@ -3,7 +3,12 @@ declare(strict_types=1); use Ichiloto\Editor\ProjectMap; +use Ichiloto\Editor\Validation\Issue; use Ichiloto\Editor\Validation\MapValidator; +use Ichiloto\Editor\Validation\Severity; +use Ichiloto\Engine\Events\Triggers\DialogueEventTrigger; +use Ichiloto\Engine\Events\Triggers\SleepEventTrigger; +use Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger; /** * Builds an in-memory map with the given data and event rows. @@ -26,60 +31,61 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'E' => ['class' => 'SomeTrigger', 'data' => []], + 'E' => ['class' => DialogueEventTrigger::class, 'data' => []], ], ], ['E ', ' ']); - expect(MapValidator::validate($map, ['town' => $map]))->toBe([]); + expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toBe([]); }); -it('warns about event markers without definitions', function () { +it('reports an event marker without a definition as an error the map still saves with', function () { $map = validatorMap('town', ['name' => 'Town', 'events' => []], ['E ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('marker E') - ->and($warnings[0])->toContain('no definition'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->map)->toBe('town') + // The Engine's rule: its map loader refuses the marker. + ->and($issues[0]->code)->toBe('event.unmapped') + ->and($issues[0]->message)->toContain('E') + ->and($issues[0]->hint)->not->toBe(''); }); -it('warns when a marker does not occupy one solid rectangle', function () { +it('accepts a marker painted in any shape, as the runtime triggers its exact cells', function () { $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'E' => ['class' => 'SomeTrigger', 'data' => []], + 'E' => ['class' => DialogueEventTrigger::class, 'data' => []], ], - ], [' E ', 'EEE ']); + ], [' E E', 'EE ']); - $warnings = MapValidator::validate($map, ['town' => $map]); - - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('marker E') - ->and($warnings[0])->toContain('solid rectangle'); + expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toBe([]); }); -it('warns about dangling transfer destinations', function () { +it('reports a transfer to a map that does not exist as an error', function () { $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'vanished-map']], + 'T' => ['class' => TransferPlayerTrigger::class, 'data' => ['destinationMap' => 'vanished-map']], ], ], ['T ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('vanished-map') - ->and($warnings[0])->toContain('does not exist'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toContain('vanished-map') + ->and($issues[0]->message)->toContain('does not exist'); }); -it('warns about spawn points outside the destination map', function () { +it('reports a spawn point outside the destination map as an error', function () { $destination = validatorMap('cave', ['name' => 'Cave', 'events' => []]); $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ 'T' => [ - 'class' => 'Transfer', + 'class' => TransferPlayerTrigger::class, 'data' => [ 'destinationMap' => 'cave', 'spawnPoint' => ['x' => 40, 'y' => 1], @@ -88,38 +94,50 @@ function validatorMap(string $mapId, array $data, array $eventLines = [' ', ' ], ], ['T ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map, 'cave' => $destination]); + $issues = MapValidator::validate($map, ['town' => $map, 'cave' => $destination], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('spawn point (40, 1)') - ->and($warnings[0])->toContain('cave'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toContain('spawn point (40, 1)') + ->and($issues[0]->message)->toContain('cave'); }); it('validates spawn points against this map when no destination is set', function () { $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'S' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 1, 'y' => 1]]], - 'B' => ['class' => 'Spawn', 'data' => ['spawnPoint' => ['x' => 9, 'y' => 9]]], + 'S' => ['class' => SleepEventTrigger::class, 'data' => ['spawnPoint' => ['x' => 1, 'y' => 1]]], + 'B' => ['class' => SleepEventTrigger::class, 'data' => ['spawnPoint' => ['x' => 9, 'y' => 9]]], ], ], ['SB ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - expect($warnings)->toHaveCount(1) - ->and($warnings[0])->toContain('B:') - ->and($warnings[0])->toContain('this map'); + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toContain('B:') + ->and($issues[0]->message)->toContain('this map'); }); it('collects multiple findings in one pass', function () { $map = validatorMap('town', [ 'name' => 'Town', 'events' => [ - 'T' => ['class' => 'Transfer', 'data' => ['destinationMap' => 'gone']], + 'T' => ['class' => TransferPlayerTrigger::class, 'data' => ['destinationMap' => 'gone']], ], ], ['TX ', ' ']); - $warnings = MapValidator::validate($map, ['town' => $map]); + expect(MapValidator::validate($map, ['town' => $map], mapValidatorInventory()))->toHaveCount(2); +}); + +it('warns before a save that a tiles2d crop table is no longer read', function () { + $map = validatorMap('town', [ + 'name' => 'Town', + 'events' => [], + 'tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []], + ]); + + $issues = MapValidator::validate($map, ['town' => $map], mapValidatorInventory()); - expect($warnings)->toHaveCount(2); + expect(array_map(static fn(Issue $issue): array => [$issue->severity, $issue->message], $issues)) + ->toBe([[Severity::WARNING, 'tiles2d is no longer read; the map shows its terminal glyphs until it has a tileset.']]); }); diff --git a/tests/Unit/MixedMaterialAuthoringTest.php b/tests/Unit/MixedMaterialAuthoringTest.php new file mode 100644 index 00000000..2334b514 --- /dev/null +++ b/tests/Unit/MixedMaterialAuthoringTest.php @@ -0,0 +1,152 @@ + $blank, + '02.floors.deco.php' => $blank, + '03.rugs.deco.php' => $blank, + '04.walls.map.php' => $walls, + '05.wall-detail.deco.php' => $blank, + '06.fixtures.map.php' => $fixtures, + ] as $file => $grid) { + file_put_contents($directory . '/layers/' . $file, MapGridSource::buildSource($grid, 'ROOM', "// Keep $file.\n")); + } + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource($blank, 'EVENTS')); + file_put_contents($directory . '/test-map.data.php', " 'Mixed materials', 'region' => '', 'events' => [], + 'npcs' => [['id' => 'notice', 'name' => 'Notice', 'x' => 4, 'y' => 0, 'sprite' => '', + 'movement' => 'fixed', 'dialogue' => [['text' => 'Read the notice.']]]], + ], true) . ";\n"); + file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP' + CollisionType::NONE, '#' => CollisionType::SOLID, 'i' => CollisionType::SOLID]; +PHP); + return $root; +} + +/** Select a cell with a Normal-mode mouse click, then paint through the real key router. */ +function paintMixedMaterial(Editor $editor, int $x, int $y, string $symbol): void +{ + callEditorMethod($editor, 'dispatchInput', "\033"); + $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds'); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dM", $bounds['left'] + $x, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', sprintf("\033[<0;%d;%dm", $bounds['left'] + $x, $bounds['top'] + $y)); + callEditorMethod($editor, 'dispatchInput', 'i'); + callEditorMethod($editor, 'dispatchInput', $symbol); + callEditorMethod($editor, 'dispatchInput', "\033"); +} + +function getMixedMaterialCanvasRows(Editor $editor): array +{ + $window = callEditorMethod($editor, 'createCanvasWindow'); + return array_map(static fn(string $line): string => substr(TerminalText::stripAnsi($line), 0, 10), + array_slice($window->content, ProjectWorkspace::CANVAS_HEADER_ROWS, 7)); +} + +it('preserves mixed graphical materials through model save restore and reload without changing terminal gameplay', function () { + $root = createMixedMaterialProject(); + $map = loadLayeredMap($root); + $dictionary = require $root . '/assets/Maps/collisions.php'; + $collision = MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary); + $terminal = $map->renderPreview(10, 7); + $original = $map->captureLayerSnapshot(); + $originalFiles = sourceHashTree($root . '/assets/Maps'); + expect($collision[1][2])->toBe(CollisionType::NONE->value) + ->and($collision[4][2])->toBe(CollisionType::NONE->value) + ->and($collision[0][1])->toBe(CollisionType::SOLID->value) + ->and($collision[3][5])->toBe(CollisionType::SOLID->value); + + foreach ([ + 'map:2' => [[1, 1, 'w'], [2, 1, 'w'], [3, 1, 'w'], [7, 1, 'k'], [8, 1, 'k'], [1, 4, 's'], [2, 4, 's']], + 'map:3' => [[2, 1, 'r'], [2, 4, 'c']], + 'map:5' => [[1, 0, 'w'], [5, 3, 'o']], + ] as $id => $cells) { + foreach ($cells as [$x, $y, $glyph]) { + $map->setLayerCell($id, $x, $y, $glyph); + } + } + $painted = $map->captureLayerSnapshot(); + $map->save(); + $saved = sourceHashTree($root . '/assets/Maps'); + expect(array_keys(array_diff_assoc($saved, $originalFiles)))->toBe([ + 'test-map/layers/02.floors.deco.php', 'test-map/layers/03.rugs.deco.php', 'test-map/layers/05.wall-detail.deco.php', + ]); + $map->restoreLayerSnapshot($original); + $map->save(); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($originalFiles); + $map->restoreLayerSnapshot($painted); + $map->save(); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($saved); + $reloaded = loadLayeredMap($root); + expect($reloaded->getLayerSymbol('map:2', 7, 1))->toBe('k') + ->and($reloaded->getLayerSymbol('map:3', 2, 4))->toBe('c') + ->and($reloaded->getLayerSymbol('map:5', 5, 3))->toBe('o') + ->and($reloaded->renderPreview(10, 7))->toBe($terminal) + ->and(MapCollisionResolver::resolveLayers($reloaded->getLayerSet(), $dictionary))->toBe($collision) + ->and($reloaded->getEditableData()['npcs'][0]['dialogue'])->toBe([['text' => 'Read the notice.']]) + ->and($reloaded->getEditableData()['npcs'][0]['sprite'])->toBe(''); +}); + +it('excludes material markers and crop controls from the TUI while terminal editing preserves their source', function () { + $root = createMixedMaterialProject(); + $map = loadLayeredMap($root); + foreach (['map:2' => [1, 1, 'w'], 'map:3' => [2, 1, 'r'], 'map:5' => [1, 0, 'o']] as $id => [$x, $y, $glyph]) { + $map->setLayerCell($id, $x, $y, $glyph); + } + $map->save(); + $before = sourceHashTree($root . '/assets/Maps'); + [$editor, $map] = layeredCanvasEditor($root); + $terminal = ['####i#####', '# #', '# #', '##########', '# #', '# #', '##########']; + expect(getMixedMaterialCanvasRows($editor))->toBe($terminal); + foreach (['map:4', 'map:6', 'event', 'map:1'] as $id) { + callEditorMethod($editor, 'dispatchInput', ']'); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe($id); + } + foreach (['map:2', 'map:3', 'map:5'] as $id) { + callEditorMethod($editor, 'selectCanvasLayer', $id); + expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1'); + } + $labels = implode("\n", array_map(static fn($item): string => $item->label, callEditorMethod($editor, 'buildLayerPaletteItems'))); + expect($labels)->not->toContain('floors', 'rugs', 'wall-detail', 'Tile art', 'Terminal preview') + ->and(json_encode(callEditorMethod($editor, 'getLayerInspectorFields')))->not->toContain('floors', 'rugs', 'wall-detail', 'tiles2d', 'tile-art'); + + paintMixedMaterial($editor, 2, 1, '#'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect($map->getLayerSymbol('map:1', 2, 1))->toBe('#') + ->and(array_keys(array_diff_assoc(sourceHashTree($root . '/assets/Maps'), $before)))->toBe(['test-map/layers/01.terrain.map.php']); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(sourceHashTree($root . '/assets/Maps'))->toBe($before) + ->and(getMixedMaterialCanvasRows($editor))->toBe($terminal); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + callEditorMethod($editor, 'dispatchInput', "\x12"); + $loaded = callEditorMethod($editor, 'getSelectedMap'); + expect($loaded->getLayerSymbol('map:1', 2, 1))->toBe('#') + ->and($loaded->getLayerSymbol('map:2', 1, 1))->toBe('w') + ->and($loaded->getLayerSymbol('map:3', 2, 1))->toBe('r') + ->and($loaded->getLayerSymbol('map:5', 1, 0))->toBe('o'); + callEditorMethod($editor, 'selectCanvasLayer', 'map:6'); + callEditorMethod($editor, 'dispatchInput', 'v'); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe('##########'); + callEditorMethod($editor, 'dispatchInput', 'v'); + expect(getMixedMaterialCanvasRows($editor)[0])->toBe('####i#####'); +}); diff --git a/tests/Unit/MovementRouteAuthoringTest.php b/tests/Unit/MovementRouteAuthoringTest.php new file mode 100644 index 00000000..f2159d37 --- /dev/null +++ b/tests/Unit/MovementRouteAuthoringTest.php @@ -0,0 +1,838 @@ + ($row['entry'] ?? []) === [$command] + && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException('Missing route field ' . $name); +} + +function inlineRouteRow(EditorSession $session, array $path): array +{ + return array_find($session->readInspector('test-map', 'S')['rows'], static fn(array $row): bool => ($row['key']['path'] ?? []) === $path) + ?? throw new RuntimeException('Missing inline route field ' . implode('.', $path)); +} + +function makeRouteOwnerProject(): array +{ + $root = cutsceneProject(); + $remember = static fn(string $id): array => ['type' => 'move_route', 'remember' => $id, 'steps' => [['direction' => 'right']]]; + $commands = [ + [...$remember('root-recorded'), 'annotation' => $remember('not-a-command')], + ['type' => 'common_event', 'id' => 'route-shared'], + ['type' => 'sequence', 'commands' => [['type' => 'move_route', 'retrace' => 'root-recorded']]], + ['type' => 'wait', 'seconds' => 1], + ]; + foreach ([ + 'route-owner' => $commands, + 'route-shared' => [$remember('common-recorded'), ['type' => 'common_event', 'id' => 'route-cycle']], + 'route-cycle' => [$remember('cycle-recorded'), ['type' => 'common_event', 'id' => 'route-shared']], + 'route-other' => [$remember('unrelated-common')], + ] as $id => $script) { + file_put_contents($root . '/assets/Events/' . $id . '.php', " [$remember('variant-a'), ['type' => 'move_route', 'retrace' => 'variant-a'], $remember('variant-a-second')]], + ['conditions' => ['flag=on'], 'script' => [$remember('variant-b'), ['type' => 'move_route', 'retrace' => 'variant-b']]], + ]; + $data['npcs'][] = ['id' => 'other', 'name' => 'Other', 'sprite' => 'O', 'x' => 6, 'y' => 1, 'script' => [$remember('other-npc')]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, 'data' => ['script' => $commands]]; + $data['events']['T'] = ['class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, 'data' => ['script' => [$remember('other-event')]]]; + $source = " 1,", "'seconds' => abs(1),", $source); + file_put_contents($mapPath, $source); + return [$root, $mapPath]; +} + +it('transports exact route row owners through the real GUI host boundary without changing source', function () { + [$root] = makeRouteOwnerProject(); + $before = sourceHashTree($root); + $input = fopen('php://memory', 'r'); + $output = fopen('php://memory', 'w+'); + $diagnostics = fopen('php://memory', 'w+'); + $host = new \Ichiloto\Editor\Session\SessionHost($input, $output, $diagnostics); + $request = static fn(string $method, array $params = []): array => + $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR)); + $list = static fn(?string $map, array $owner): array => + $request('references.list', ['map' => $map, 'category' => 'cinematic_movement_routes', 'record' => $owner]); + try { + expect($request('hello', ['protocol' => \Ichiloto\Editor\Session\SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result'); + foreach ([['script', 2, 'commands'], [0, 'script'], [1, 'script']] as $frame) { + $view = $request('npc.read', ['map' => 'harbour', 'index' => 0, 'frame' => $frame])['result']; + $command = $frame[0] === 'script' ? 0 : 1; + $row = routeAuthoringRow($view, 'Recorded Route', $command); + $reply = $list($view['map'], ['kind' => 'npc', 'index' => 0, 'frame' => $row['key']['frame']]); + $expected = match ($frame[0]) { + 'script' => ['root-recorded', 'common-recorded', 'cycle-recorded'], + 0 => ['variant-a', 'variant-a-second'], + 1 => ['variant-b'], + }; + expect($reply)->toHaveKey('result')->and(array_column($reply['result'], 'value'))->toBe($expected); + } + // A Story Script's inline commands are its record rows, in frames as an NPC's script is. + $event = $request('inspector.read', ['map' => 'harbour', 'event' => 'S', 'frame' => ['script', 2, 'commands']])['result']; + $row = routeAuthoringRow($event, 'Recorded Route'); + $reply = $list($event['map'], ['kind' => 'event', 'marker' => $row['key']['marker'], 'frame' => $row['key']['frame']]); + expect($reply)->toHaveKey('result') + ->and(array_column($reply['result'], 'value'))->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); + + $index = array_search('route-owner', $request('database.records', ['category' => 'common_events'])['result']['records'], true); + expect($index)->toBeInt(); + $view = $request('database.record', ['category' => 'common_events', 'index' => $index, 'frame' => [2, 'commands']])['result']; + $row = routeAuthoringRow($view, 'Recorded Route'); + $reply = $list(null, ['category' => 'common_events', 'index' => $index, 'frame' => $row['key']['frame']]); + expect($reply)->toHaveKey('result') + ->and(array_column($reply['result'], 'value'))->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']) + ->and($request('references.list', ['map' => 'harbour', 'category' => 'cinematic_movement_routes'])['result'])->toBe([]) + ->and($list(null, ['kind' => 'npc', 'index' => 0, 'frame' => ['script']]))->toHaveKey('error') + ->and(sourceHashTree($root))->toBe($before); + rewind($diagnostics); + expect(stream_get_contents($diagnostics))->toBe(''); + } finally { + $host->run(); + fclose($input); + fclose($output); + fclose($diagnostics); + } +}); + +it('lists only declarations reachable from the explicit live command owner including cyclic common-event references', function () { + [$root] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + foreach ([['kind' => 'npc', 'index' => 0, 'frame' => ['script', 2, 'commands']], + ['kind' => 'event', 'marker' => 'S', 'frame' => ['script', 2, 'commands']], + ['kind' => 'event', 'marker' => 'S', 'path' => ['data', 'script', '2', 'commands', '0', 'retrace']]] as $owner) { + expect(array_column($session->listReferences('harbour', 'cinematic_movement_routes', $owner), 'value')) + ->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); + } + foreach ([0 => 'variant-a', 1 => 'variant-b'] as $index => $expected) { + expect(array_column($session->listReferences('harbour', 'cinematic_movement_routes', + ['kind' => 'npc', 'index' => 0, 'frame' => [$index, 'script']]), 'value'))->toBe($index === 0 ? [$expected, 'variant-a-second'] : [$expected]); + } + expect($session->listReferences('harbour', 'cinematic_movement_routes'))->toBe([]) + ->and(fn() => $session->listReferences(null, 'cinematic_movement_routes', ['kind' => 'npc', 'index' => 0, 'frame' => ['script']])) + ->toThrow(SessionRefusal::class, 'requires its map') + ->and(fn() => $session->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'event', 'marker' => 'missing', 'path' => ['data', 'script']])) + ->toThrow(SessionRefusal::class, 'no longer present') + ->and(fn() => $session->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'npc', 'index' => 0, 'frame' => [99, 'script']])) + ->toThrow(SessionRefusal::class, 'no longer present'); +}); + +it('writes and refuses NPC route references against the same invocation while preserving undo expressions and variants', function () { + [$root, $mapPath] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + $original = file_get_contents($mapPath); + $row = routeAuthoringRow($session->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route'); + $revision = $session->readNpc('harbour', 0, ['script', 2, 'commands'])['revision']; + foreach (['variant-a', 'other-npc', 'unrelated-common', 'not-a-command'] as $bad) { + expect(fn() => $session->applyNpc('harbour', $revision, 0, $row['key'], $bad))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + } + expect($session->undo()['label'])->toBeNull()->and(file_get_contents($mapPath))->toBe($original); + $session->applyNpc('harbour', $revision, 0, $row['key'], 'common-recorded'); + expect(routeAuthoringRow($session->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route')['value'])->toBe('common-recorded'); + $session->undo(); + expect(routeAuthoringRow($session->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route')['value'])->toBe('root-recorded'); + $session->saveMap('harbour'); + expect(file_get_contents($mapPath))->toBe($original); + $session->redo(); + $session->saveMap('harbour'); + expect(file_get_contents($mapPath))->toContain('// Preserve map source.', "'seconds' => abs(1)"); + $reopened = EditorSession::open($root); + expect(routeAuthoringRow($reopened->readNpc('harbour', 0, ['script', 2, 'commands']), 'Recorded Route')['value'])->toBe('common-recorded'); + $variant = $reopened->readNpc('harbour', 0, [0, 'script']); + $row = routeAuthoringRow($variant, 'Recorded Route', 1); + expect(fn() => $reopened->applyNpc('harbour', $variant['revision'], 0, $row['key'], 'variant-b'))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + expect(fn() => $reopened->applyNpc('harbour', $variant['revision'], 0, $row['key'], 'root-recorded'))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + $reopened->applyNpc('harbour', $variant['revision'], 0, $row['key'], 'variant-a-second'); + $reopened->saveMap('harbour'); + expect((require $mapPath)['npcs'][0]['dialogue'][0]['script'][1]['retrace'])->toBe('variant-a-second') + ->and((require $mapPath)['npcs'][0]['dialogue'][1]['script'][1]['retrace'])->toBe('variant-b'); + $reopened->undo(); + $reopened->saveMap('harbour'); + expect((require $mapPath)['npcs'][0]['dialogue'][0]['script'][1]['retrace'])->toBe('variant-a'); +}); + +it('writes inline route references from the actual event script and refuses siblings without changing source or history', function () { + [$root, $mapPath] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + $frame = ['script', 2, 'commands']; + $view = $session->readInspector('harbour', 'S', $frame); + $row = routeAuthoringRow($view, 'Recorded Route'); + $original = file_get_contents($mapPath); + expect(fn() => $session->applyInspector('harbour', $view['revision'], $row['key'], 'other-event')) + ->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + expect($session->undo()['label'])->toBeNull()->and(file_get_contents($mapPath))->toBe($original); + $session->applyInspector('harbour', $view['revision'], $row['key'], 'common-recorded'); + $session->saveMap('harbour'); + expect((require $mapPath)['events']['S']['data']['script'][2]['commands'][0]['retrace'])->toBe('common-recorded') + ->and(file_get_contents($mapPath))->toContain('// Preserve map source.', "'seconds' => abs(1)"); + $session->undo(); + $session->saveMap('harbour'); + expect(file_get_contents($mapPath))->toBe($original); + $session->redo(); + $session->saveMap('harbour'); + expect(EditorSession::open($root)->listReferences('harbour', 'cinematic_movement_routes', ['kind' => 'event', 'marker' => 'S', 'frame' => $frame])) + ->toHaveCount(3); +}); + +it('uses a standalone common-event record for list and apply rather than unrelated cinematic or event records', function () { + [$root] = makeRouteOwnerProject(); + $session = EditorSession::open($root); + $index = array_search('route-owner', $session->listDatabaseRecords('common_events')['records'], true); + expect($index)->toBeInt(); + $owner = ['category' => 'common_events', 'index' => $index, 'frame' => [2, 'commands']]; + expect(array_column($session->listReferences(null, 'cinematic_movement_routes', $owner), 'value')) + ->toBe(['root-recorded', 'common-recorded', 'cycle-recorded']); + $row = routeAuthoringRow($session->readDatabaseRecord('common_events', $index, [2, 'commands']), 'Recorded Route'); + $path = $root . '/assets/Events/route-owner.php'; + $original = file_get_contents($path); + expect(fn() => $session->applyDatabaseRecord('common_events', $index, $row['key'], 'unrelated-common')) + ->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + $session->applyDatabaseRecord('common_events', $index, $row['key'], 'common-recorded'); + $session->saveDatabase('common_events'); + expect((require $path)[2]['commands'][0]['retrace'])->toBe('common-recorded')->and(file_get_contents($path))->toContain('// Preserve common source.'); + $session->undo(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveDatabase('common_events'); + $reopened = EditorSession::open($root); + expect(routeAuthoringRow($reopened->readDatabaseRecord('common_events', $index, [2, 'commands']), 'Recorded Route')['value'])->toBe('common-recorded'); +}); + +it('shares all three runtime route shapes between event and cinematic schemas without projecting one into another', function () { + foreach ([RecordSchemaCatalog::eventCommandList('script'), CutsceneSchemas::cinematicCommandList('commands')] as $schema) { + foreach (['steps' => [['direction' => 'right', 'count' => 1]], 'waypoints' => [['x' => 0], ['y' => 2]], 'retrace' => 'outbound'] as $mode => $value) { + $route = ['type' => 'move_route', 'subject' => 'player', $mode => $value, 'secondsPerStep' => 0.2, 'wait' => true]; + $fields = $schema->fieldsFor($route); + $modeField = array_find($fields, static fn($f): bool => $f->key === MovementRouteFields::MODE_FIELD); + expect($modeField->displayDefault)->toBe($mode) + ->and($schema->removeConflictingFields($route, 'secondsPerStep'))->toBe($route) + ->and($schema->nestedListFor($route)?->key)->toBe($mode === 'retrace' ? null : $mode); + if ($mode === 'retrace') { + expect(array_find($fields, static fn($f): bool => $f->key === 'retrace')->reference)->toBe('cinematic_movement_routes'); + } + } + } + $route = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'remember' => 'outbound', 'speed' => 2.0, + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.4]], 'annotation' => 'keep']; + expect(MovementRouteFields::prepareEdit([...$route, MovementRouteFields::MODE_FIELD => 'steps'], MovementRouteFields::MODE_FIELD))->toBe($route); + $waypoints = MovementRouteFields::prepareEdit([...$route, MovementRouteFields::MODE_FIELD => 'waypoints'], MovementRouteFields::MODE_FIELD); + expect($waypoints)->toBe([...array_diff_key($route, ['steps' => true]), 'waypoints' => []]); + $retrace = MovementRouteFields::prepareEdit([...$waypoints, MovementRouteFields::MODE_FIELD => 'retrace'], MovementRouteFields::MODE_FIELD); + expect($retrace)->toBe([...array_diff_key($waypoints, ['waypoints' => true, 'remember' => true]), 'retrace' => '']) + ->and(fn() => MovementRouteFields::prepareEdit([...$route, MovementRouteFields::MODE_FIELD => 'bogus'], MovementRouteFields::MODE_FIELD))->toThrow(InvalidArgumentException::class) + ->and(fn() => MovementRouteFields::prepareEdit([...$route, 'remember' => '../unsafe'], 'remember'))->toThrow(InvalidArgumentException::class) + ->and(fn() => MovementRouteFields::prepareEdit([...$waypoints, 'subject' => 'staged_actor'], 'subject'))->toThrow(InvalidArgumentException::class); +}); + +it('keeps sparse waypoint axes and zero through source preserving edits undo save and reopen', function () { + $root = makeTemporaryProject('ichiloto-route-authoring-'); + $path = $root . '/assets/Events/waypoint-route.php'; + file_put_contents($path, <<<'PHP' + 'move_route', 'subject' => 'player', 'wait' => true, + 'secondsPerStep' => 0.25, 'remember' => 'outbound', 'annotation' => 'keep', + 'waypoints' => [['x' => 2], ['y' => 1], ['x' => 4, 'y' => 2]], +]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('waypoint-route', $session->listDatabaseRecords('common_events')['records'], true); + $view = $session->readDatabaseRecord('common_events', $index); + $placement = array_find($view['rows'], static fn(array $row): bool => ($row['mapPlacement']['kind'] ?? '') === 'waypoints'); + expect($placement['mapPlacement']['points'][0]['point'])->toBe([2, null]) + ->and($placement['mapPlacement']['points'][1]['point'])->toBe([2, 1]); + $context = ['kind' => 'database', 'category' => 'common_events', 'index' => $index]; + $revision = $session->readMap('test-map')['revision']; + $session->applyMapPlacement($context, $placement['key'], $placement['mapPlacement'], 'test-map', 0, 0, 3, [1, 2], $revision); + $changed = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $row): bool => isset($row['mapPlacement'])); + expect($changed['mapPlacement']['points'][0]['point'])->toBe([0, null]); + $session->undo(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveDatabase('common_events'); + expect((require $path)[0])->toMatchArray(['secondsPerStep' => 0.25, 'remember' => 'outbound', 'annotation' => 'keep', + 'waypoints' => [['x' => 0], ['y' => 1], ['x' => 4, 'y' => 2]]]) + ->and(file_get_contents($path))->toContain('// Authored route, not a generated path.'); + $reopened = EditorSession::open($root); + $view = $reopened->readDatabaseRecord('common_events', $index); + $y = array_find($view['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 2] && ($row['name'] ?? '') === 'Y'); + expect($y)->not->toBeNull(); + $reopened->applyDatabaseRecord('common_events', $index, $y['key'], ''); + $reopened->saveDatabase('common_events'); + expect((require $path)[0]['waypoints'][2])->toBe(['x' => 4]); +}); + +it('refuses expression owned waypoint placement without flattening source or creating history', function () { + $root = makeTemporaryProject('ichiloto-route-expression-'); + $path = $root . '/assets/Events/waypoint-expression.php'; + file_put_contents($path, <<<'PHP' + 'move_route', 'subject' => 'player', 'waypoints' => [['x' => abs($destination)]]]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('waypoint-expression', $session->listDatabaseRecords('common_events')['records'], true); + $row = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $r): bool => isset($r['mapPlacement'])); + expect($row['mapPlacement']['issue'])->toContain('literal source fields') + ->and(fn() => $session->applyMapPlacement(['kind' => 'database', 'category' => 'common_events', 'index' => $index], + $row['key'], $row['mapPlacement'], 'test-map', 0, 3, 1, null, $session->readMap('test-map')['revision']))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); +}); + +it('selects live cinematic remembered route identities at nested command depths and round trips retrace', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, <<<'PHP' + 'sequence', 'commands' => [ + ['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', 'waypoints' => [['x' => 2]], 'secondsPerStep' => 0.3], + ['type' => 'move_route', 'subject' => 'player', 'retrace' => 'outbound', 'speed' => 2.0], +]]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $record = ['category' => 'cutscenes/cinematic', 'index' => 0]; + expect(array_column($session->listReferences(null, 'cinematic_movement_routes', $record), 'value'))->toBe(['outbound']) + ->and($session->listReferences(null, 'cinematic_movement_routes'))->toBe([]); + $frame = ['commands', 0, 'commands']; + $view = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + $remember = routeAuthoringRow($view, 'Remember As'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $remember['key'], 'return-path'); + expect(array_column($session->listReferences(null, 'cinematic_movement_routes', $record), 'value'))->toBe(['return-path']); + $view = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + $retrace = routeAuthoringRow($view, 'Recorded Route', 1); + expect($retrace['reference'])->toBe('cinematic_movement_routes'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $retrace['key'], 'return-path'); + $session->undo(); + $session->undo(); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->redo(); + $session->saveDatabase('cutscenes/cinematic'); + $saved = require $path; + expect($saved[0]['commands'][0])->toMatchArray(['remember' => 'return-path', 'waypoints' => [['x' => 2]], 'secondsPerStep' => 0.3]) + ->and($saved[0]['commands'][1])->toBe(['type' => 'move_route', 'subject' => 'player', 'retrace' => 'return-path', 'speed' => 2.0]) + ->and(array_column(EditorSession::open($root)->listReferences(null, 'cinematic_movement_routes', $record), 'value'))->toBe(['return-path']); +}); + +it('authors a new waypoint mode and child axes without saving the derived selector or coordinate defaults', function () { + $root = makeTemporaryProject('ichiloto-route-mode-'); + $path = $root . '/assets/Events/mode-route.php'; + file_put_contents($path, <<<'PHP' + 'move_route', 'subject' => 'player', 'remember' => 'outbound', + 'wait' => true, 'speed' => 3.0, 'annotation' => 'keep', + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.4]], +]]; +PHP); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('mode-route', $session->listDatabaseRecords('common_events')['records'], true); + $mode = routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode'); + $session->applyDatabaseRecord('common_events', $index, $mode['key'], 'waypoints'); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and(file_get_contents($path))->toBe($original); + $mode = routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode'); + $session->addDatabaseItem('common_events', $index, $mode['key'], child: true); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'x and/or y') + ->and(file_get_contents($path))->toBe($original); + $view = $session->readDatabaseRecord('common_events', $index); + $x = array_find($view['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'X'); + expect($x['value'])->toBe('(Keep current X)'); + $session->applyDatabaseRecord('common_events', $index, $x['key'], '0'); + $session->saveDatabase('common_events'); + expect((require $path)[0])->toBe(['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', + 'wait' => true, 'speed' => 3.0, 'annotation' => 'keep', 'waypoints' => [['x' => 0]]]) + ->and(file_get_contents($path))->toContain('// Preserve the command envelope.') + ->and(file_get_contents($path))->not->toContain('_routeMode'); + $session->undo(); + $session->undo(); + $session->undo(); + $session->saveDatabase('common_events'); + expect(require $path)->toBe([['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', + 'wait' => true, 'speed' => 3.0, 'annotation' => 'keep', + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.4]]]]) + ->and(file_get_contents($path))->toContain('// Preserve the command envelope.'); +}); + +it('places sparse inline map-event waypoints through existing grouped path edits', function () { + $root = makeTemporaryProject('ichiloto-event-waypoints-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => [ + ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.4, 'wait' => true, + 'waypoints' => [['x' => 2], ['y' => 1]]], + ]]]; + file_put_contents($path, "readInspector('test-map', 'S'); + $row = array_find($read['rows'], static fn(array $r): bool => ($r['mapPlacement']['kind'] ?? '') === 'waypoints'); + expect($row['mapPlacement']['points'][0]['point'])->toBe([2, null]); + $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']], + $row['key'], $row['mapPlacement'], 'test-map', 0, 0, 2, null, $read['revision']); + expect($session->undo()['label'])->toBe('Place on map'); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0])->toBe(['type' => 'move_route', 'subject' => 'player', + 'secondsPerStep' => 0.4, 'wait' => true, 'waypoints' => [['x' => 0], ['y' => 1]]]) + ->and(file_get_contents($path))->toContain('// Preserve inline event source.'); + $reopened = EditorSession::open($root)->readInspector('test-map', 'S'); + expect(array_find($reopened['rows'], static fn(array $r): bool => isset($r['mapPlacement']))['mapPlacement']['points'][0]['point'])->toBe([0, null]); +}); + +it('refuses clearing the last waypoint axis or negative coordinates without a partial edit', function () { + $root = makeTemporaryProject('ichiloto-route-invalid-'); + $path = $root . '/assets/Events/waypoint-invalid.php'; + file_put_contents($path, " 'move_route', 'subject' => 'player', 'waypoints' => [['y' => 1]]]];\n"); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $index = array_search('waypoint-invalid', $session->listDatabaseRecords('common_events')['records'], true); + $row = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $r): bool => ($r['entry'] ?? []) === [0, 0] && ($r['name'] ?? '') === 'Y'); + expect(fn() => $session->applyDatabaseRecord('common_events', $index, $row['key'], ''))->toThrow(SessionRefusal::class, 'x and/or y') + ->and(fn() => $session->applyDatabaseRecord('common_events', $index, $row['key'], '-1'))->toThrow(SessionRefusal::class, 'non-negative') + ->and($session->undo()['label'])->toBeNull(); + $session->saveDatabase('common_events'); + expect(file_get_contents($path))->toBe($original); +}); + +it('offers remembered identities in every reachable cinematic arm and referenced common event without following cycles forever', function () { + $root = cutsceneProject(); + file_put_contents($root . '/assets/Events/outbound.php', " 'move_route', 'subject' => 'player', 'remember' => 'common-route', 'steps' => [['direction' => 'right']]], + ['type' => 'common_event', 'id' => 'outbound'], + ];\n"); + file_put_contents($root . '/assets/Events/unrelated.php', " 'move_route', 'subject' => 'player', 'remember' => 'unreachable-route', 'steps' => [['direction' => 'left']]]];\n"); + $route = static fn(string $id): array => ['type' => 'move_route', 'subject' => 'player', 'remember' => $id, 'waypoints' => [['x' => 1]]]; + $commands = [ + ['type' => 'branch', 'conditions' => [], 'then' => [$route('then-route')], 'else' => [$route('else-route')]], + ['type' => 'choice', 'prompt' => 'Choose', 'options' => [['text' => 'Continue', 'then' => [$route('choice-route')]]], 'cancel' => [$route('cancel-route')]], + ['type' => 'parallel', 'lanes' => [['id' => 'first', 'commands' => [$route('lane-route')]], [$route('legacy-lane-route')]]], + ['type' => 'common_event', 'id' => 'outbound'], + ]; + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, "listReferences(null, 'cinematic_movement_routes', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value'); + expect($values)->toBe(['then-route', 'else-route', 'choice-route', 'cancel-route', 'lane-route', 'legacy-lane-route', 'common-route']); +}); + +it('shares sparse inline route controls and validation with command records through undo save and reopen', function () { + $root = makeTemporaryProject('ichiloto-inline-route-fields-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $route = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'wait' => true, + 'speed' => 2.0, 'remember' => 'outbound', 'annotation' => 'keep', 'waypoints' => [['x' => 2]]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, + 'data' => ['script' => [['type' => 'sequence', 'commands' => [$route]]]]]; + file_put_contents($path, "toBe('2')->and($y['value'])->toContain('Keep current Y') + ->and($y['kind'])->toBe('integer'); + $read = $session->readInspector('test-map', 'S'); + expect(fn() => $session->applyInspector('test-map', $read['revision'], $x['key'], '-1'))->toThrow(SessionRefusal::class, 'non-negative') + ->and(fn() => $session->applyInspector('test-map', $read['revision'], $x['key'], ''))->toThrow(SessionRefusal::class, 'x and/or y') + ->and($session->undo()['label'])->toBeNull(); + $session->applyInspector('test-map', $read['revision'], $x['key'], '0'); + $session->undo(); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($original); + $session->redo(); + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0]['commands'][0])->toBe([...$route, 'waypoints' => [['x' => 0]]]) + ->and(file_get_contents($path))->toContain('// Inline route ownership.'); + $reopened = EditorSession::open($root); + $mode = inlineRouteRow($reopened, [...$base, MovementRouteFields::MODE_FIELD]); + expect($mode['value'])->toBe('waypoints')->and($mode['options'])->toBe(['steps', 'waypoints', 'retrace']); + $read = $reopened->readInspector('test-map', 'S'); + $reopened->applyInspector('test-map', $read['revision'], $mode['key'], 'steps'); + $beforeDraft = file_get_contents($path); + expect(fn() => $reopened->saveMap('test-map'))->toThrow(InvalidArgumentException::class, 'at least one step') + ->and(file_get_contents($path))->toBe($beforeDraft) + ->and(inlineRouteRow($reopened, [...$base, MovementRouteFields::MODE_FIELD])['value'])->toBe('steps'); + $reopened->undo(); + expect(inlineRouteRow($reopened, [...$base, MovementRouteFields::MODE_FIELD])['value'])->toBe('waypoints'); + $reopened->redo(); + $steps = inlineRouteRow($reopened, [...$base, 'steps']); + $reopened->addInspectorListEntry('test-map', $reopened->readInspector('test-map', 'S')['revision'], $steps['key']); + $reopened->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0]['commands'][0])->toBe([...array_diff_key($route, ['waypoints' => true]), 'steps' => [RecordSchemaCatalog::routeStepList()->blank]]) + ->and(inlineRouteRow(EditorSession::open($root), [...$base, MovementRouteFields::MODE_FIELD])['value'])->toBe('steps') + ->and(file_get_contents($path))->not->toContain('_routeMode'); +}); + +it('refuses expression owned inline waypoint fields without source normalization or history', function () { + $root = makeTemporaryProject('ichiloto-inline-route-expression-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, + 'data' => ['script' => [['type' => 'move_route', 'waypoints' => [['x' => 2]]]]]]; + $source = " 2,", "'x' => abs(-2),", $source); + file_put_contents($path, $source); + $session = EditorSession::open($root); + $row = inlineRouteRow($session, ['data', 'script', '0', 'waypoints', '0', 'x']); + $read = $session->readInspector('test-map', 'S'); + expect(fn() => $session->applyInspector('test-map', $read['revision'], $row['key'], '3'))->toThrow(SessionRefusal::class) + ->and($session->undo()['label'])->toBeNull(); + $session->saveMap('test-map'); + expect(file_get_contents($path))->toBe($source); +}); + +it('authors inline route modes and nullable children without fabricating axes or clearing cardinal defaults', function () { + $root = makeTemporaryProject('ichiloto-inline-route-children-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $route = ['type' => 'move_route', 'subject' => 'player', 'wait' => true, 'secondsPerStep' => 0.25, + 'steps' => [['direction' => 'left', 'faceOnly' => true, 'seconds' => 0.5]]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => [$route]]]; + file_put_contents($path, "applyInspector('test-map', $session->readInspector('test-map', 'S')['revision'], $field['key'], $value); + }; + $apply(inlineRouteRow($session, [...$base, MovementRouteFields::MODE_FIELD]), 'waypoints'); + expect(fn() => $session->saveMap('test-map'))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and(file_get_contents($path))->toBe($original); + $heading = inlineRouteRow($session, [...$base, 'waypoints']); + $session->addInspectorListEntry('test-map', $session->readInspector('test-map', 'S')['revision'], $heading['key']); + expect(fn() => $session->saveMap('test-map'))->toThrow(InvalidArgumentException::class, 'x and/or y') + ->and(file_get_contents($path))->toBe($original); + $x = inlineRouteRow($session, [...$base, 'waypoints', '0', 'x']); + expect($x['value'])->toContain('Keep current X'); + $apply($x, '0'); + $session->addInspectorListEntry('test-map', $session->readInspector('test-map', 'S')['revision'], $x['key']); + $y = inlineRouteRow($session, [...$base, 'waypoints', '1', 'y']); + expect(inlineRouteRow($session, [...$base, 'waypoints', '1', 'x'])['value'])->toContain('Keep current X'); + $apply($y, '2'); + $session->saveMap('test-map'); + $expected = [...array_diff_key($route, ['steps' => true]), 'waypoints' => [['x' => 0], ['y' => 2]]]; + expect((require $path)['events']['S']['data']['script'][0])->toBe($expected) + ->and(file_get_contents($path))->not->toContain('_routeMode'); + $reopened = EditorSession::open($root); + $y = inlineRouteRow($reopened, [...$base, 'waypoints', '1', 'y']); + $reopened->removeInspectorListEntry('test-map', $reopened->readInspector('test-map', 'S')['revision'], $y['key']); + $reopened->undo(); + $reopened->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0])->toBe($expected); + for ($i = 0; $i < 5; ++$i) { $session->undo(); } + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0])->toBe($route) + ->and(file_get_contents($path))->toContain('// Mode conversion is deliberate.'); + $steps = inlineRouteRow($session, [...$base, 'steps']); + $session->addInspectorListEntry('test-map', $session->readInspector('test-map', 'S')['revision'], $steps['key']); + $session->saveMap('test-map'); + expect((require $path)['events']['S']['data']['script'][0]['steps'][1])->toBe(RecordSchemaCatalog::routeStepList()->blank); +}); + +it('refuses cross-scene and unknown retrace references using the live owning record at every frame depth', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + $route = ['type' => 'move_route', 'subject' => 'player', 'retrace' => 'outbound']; + $commands = [ + ['type' => 'move_route', 'subject' => 'player', 'remember' => 'outbound', 'steps' => [['direction' => 'right']]], + $route, + ['type' => 'sequence', 'commands' => [$route]], + ['type' => 'branch', 'conditions' => [], 'then' => [$route], 'else' => [$route]], + ['type' => 'choice', 'prompt' => 'Choose', 'options' => [['text' => 'Go', 'then' => [$route]]], 'cancel' => [$route]], + ['type' => 'parallel', 'lanes' => [['id' => 'route', 'commands' => [$route]]]], + ]; + file_put_contents($path, " 'other-scene', 'name' => 'Other', 'startMap' => 'harbour', 'skip' => ['policy' => 'forbidden']];\n"); + file_put_contents($other . '/other-scene.script.php', " 'move_route', 'subject' => 'player', 'remember' => 'other-route', 'waypoints' => [['y' => 1]]]];\n"); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frames = [[['commands'], 1], [['commands', 2, 'commands'], 0], [['commands', 3, 'then'], 0], + [['commands', 3, 'else'], 0], [['commands', 4, 'options', 0, 'then'], 0], [['commands', 4, 'cancel'], 0], + [['commands', 5, 'lanes', 0, 'commands'], 0]]; + foreach ($frames as [$frame, $command]) { + $row = routeAuthoringRow($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Recorded Route', $command); + foreach (['other-route', 'unknown-route'] as $bad) { + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row['key'], $bad))->toThrow(SessionRefusal::class, 'Unknown cinematic_movement_routes'); + } + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row['key'], 'outbound'); + } + expect($session->undo()['label'])->toBeNull() + ->and(array_column($session->listReferences(null, 'cinematic_movement_routes', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value'))->toBe(['outbound']); + $session->saveDatabase('cutscenes/cinematic'); + expect(file_get_contents($path))->toBe($original); +}); + +it('validates changed route paths through every authored arm while preserving reordered legacy paths', function () { + $invalid = ['type' => 'move_route', 'waypoints' => []]; + $old = ['type' => 'sequence', 'commands' => [$invalid]]; + MovementRouteFields::assertChangedCommandsValid($old, [...$old, 'annotation' => 'keep']); + MovementRouteFields::assertChangedCommandsValid($old, ['type' => 'sequence', 'commands' => [[...$invalid, 'secondsPerStep' => 0.2]]]); + MovementRouteFields::assertChangedCommandsValid($old, ['type' => 'sequence', 'commands' => [['type' => 'text', 'text' => 'New'], $invalid]]); + foreach (['then', 'else', 'commands', 'cancel', 'options', 'lanes', 'script', 'finalizer'] as $arm) { + expect(fn() => MovementRouteFields::assertChangedCommandsValid([], [$arm => [$invalid]]))->toThrow(InvalidArgumentException::class, 'non-empty list'); + } + expect(fn() => MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'retrace' => '']))->toThrow(InvalidArgumentException::class, 'safe stable id') + ->and(fn() => MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'steps' => [], 'waypoints' => [['x' => 0]]]))->toThrow(InvalidArgumentException::class, 'exactly one'); + expect(fn() => MovementRouteFields::assertChangedCommandsValid($old, ['type' => 'sequence', 'commands' => [$invalid, $invalid]]))->toThrow(InvalidArgumentException::class, 'non-empty list'); + foreach ([[], [[]], [['direction' => '']], [['direction' => 'diagonal']], [['direction' => 'left', 'count' => -1]], [['direction' => 'left', 'faceOnly' => 'true']]] as $steps) { + expect(fn() => MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'steps' => $steps]))->toThrow(InvalidArgumentException::class); + } + MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'steps' => [['direction' => ' RIGHT ', 'count' => '0', 'faceOnly' => true], ['direction' => 'up']]]); + MovementRouteFields::assertChangedCommandsValid([], ['type' => 'move_route', 'waypoints' => [['x' => 0], ['y' => 0]]]); +}); + +it('refuses unfinished cinematic routes at the actual paired source owner then saves completed sparse axes', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + $route = ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.3, + 'steps' => [['direction' => 'left', 'faceOnly' => true]]]; + file_put_contents($path, "readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Route Mode'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $mode['key'], 'waypoints'); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and(sourceHashTree($root))->toBe($original); + $mode = routeAuthoringRow($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Route Mode'); + $session->addDatabaseItem('cutscenes/cinematic', 0, $mode['key'], child: true); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'x and/or y') + ->and(sourceHashTree($root))->toBe($original); + $view = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame); + $x = array_find($view['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'X'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $x['key'], '0'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0])->toBe([...array_diff_key($route, ['steps' => true]), 'waypoints' => [['x' => 0]]]) + ->and(file_get_contents($path))->toContain('// Owner-validated route.'); + $reopened = EditorSession::open($root); + $mode = routeAuthoringRow($reopened->readDatabaseRecord('cutscenes/cinematic', 0, $frame), 'Route Mode'); + $reopened->applyDatabaseRecord('cutscenes/cinematic', 0, $mode['key'], 'retrace'); + $saved = sourceHashTree($root); + expect(fn() => $reopened->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'safe stable id') + ->and(sourceHashTree($root))->toBe($saved); + $reopened->undo(); + $reopened->saveDatabase('cutscenes/cinematic'); + expect(sourceHashTree($root))->toBe($saved); +}); + +it('keeps incomplete cardinal record drafts and their history on refusal then saves a corrected route', function () { + $root = makeTemporaryProject('ichiloto-route-cardinal-save-'); + $path = $root . '/assets/Events/cardinal-draft.php'; + $route = ['type' => 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.2, + 'annotation' => 'keep', 'waypoints' => [['y' => 0]]]; + file_put_contents($path, "listDatabaseRecords('common_events')['records'], true); + $mode = routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode'); + $session->applyDatabaseRecord('common_events', $index, $mode['key'], 'steps'); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'at least one step') + ->and(sourceHashTree($root))->toBe($original) + ->and(routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode')['value'])->toBe('steps'); + $session->undo(); + expect(routeAuthoringRow($session->readDatabaseRecord('common_events', $index), 'Route Mode')['value'])->toBe('waypoints'); + $session->redo(); + $session->addDatabaseItem('common_events', $index, $mode['key'], child: true); + $count = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && $row['name'] === 'Count'); + $session->applyDatabaseRecord('common_events', $index, $count['key'], '-1'); + expect(fn() => $session->saveDatabase('common_events'))->toThrow(InvalidArgumentException::class, 'non-negative integer') + ->and(sourceHashTree($root))->toBe($original); + $session->undo(); + $session->saveDatabase('common_events'); + $expected = [...array_diff_key($route, ['waypoints' => true]), 'steps' => [RecordSchemaCatalog::routeStepList()->blank]]; + expect((require $path)[0])->toBe($expected) + ->and(file_get_contents($path))->toContain('// Cardinal correction, not a fabricated path.') + ->and(routeAuthoringRow(EditorSession::open($root)->readDatabaseRecord('common_events', $index), 'Route Mode')['value'])->toBe('steps'); +}); + +it('preserves untouched loaded invalid route paths in Terminal and direct map saves but refuses newly incomplete paths', function () { + $root = makeTemporaryProject('ichiloto-route-loaded-invalid-'); + $path = $root . '/assets/Events/invalid-existing.php'; + file_put_contents($path, " 'move_route', 'waypoints' => []], ['type' => 'text', 'text' => 'Old']];\n"); + $mapPath = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $mapPath; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, + 'data' => ['annotation' => 'Old', 'script' => [['type' => 'move_route', 'steps' => []]]]]; + $mapSource = " 'move_route'", "'type' => strtolower('MOVE_ROUTE')", $mapSource); + file_put_contents($mapPath, $mapSource); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('common_events'); + $index = array_search('invalid-existing', $database->getEntryLabels(), true); + $database->setField($index, 'command1Text', 'Changed'); + $database->save(); + expect((require $path)[0])->toBe(['type' => 'move_route', 'waypoints' => []]) + ->and(file_get_contents($path))->toContain('// Legacy path preserved.', "[['type' => 'move_route', 'waypoints' => []],"); + $map = array_find($workspace->maps, static fn($map): bool => $map->mapId === 'test-map'); + $map->setEventField('S', ['data', 'annotation'], 'Changed'); + $map->save(); + expect((require $mapPath)['events']['S']['data']['script'])->toBe([['type' => 'move_route', 'steps' => []]]) + ->and(file_get_contents($mapPath))->toContain("'type' => strtolower('MOVE_ROUTE')", '// Legacy route source is not normalized.'); + $original = sourceHashTree($root); + $map->setEventField('E', ['data', 'script'], [['type' => 'move_route', 'waypoints' => []]]); + $backedUp = false; + expect(fn() => $map->save(static function () use (&$backedUp): void { $backedUp = true; }))->toThrow(InvalidArgumentException::class, 'non-empty list') + ->and($backedUp)->toBeFalse()->and(sourceHashTree($root))->toBe($original) + ->and($map->getEventField('E', ['data', 'script']))->toBe([['type' => 'move_route', 'waypoints' => []]]); +}); + +it('protects direct cutscene saves before backup or staging and preserves loaded route data during metadata edits', function () { + $root = cutsceneProject(); + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, " 'move_route', 'waypoints' => [['x' => 0]]]];\n"); + $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root); + $asset = $workspace->cutscenes->find(\Ichiloto\Editor\Cutscenes\CutsceneType::CINEMATIC, 'harbour-lanterns'); + $state = $asset->captureEditState(); + $state['data']['name'] = 'Changed metadata'; + $asset->restoreEditState($state); + $asset->save(); + expect(require $path)->toBe([['type' => 'move_route', 'waypoints' => [['x' => 0]]]]) + ->and(file_get_contents($path))->toContain('// Loaded route remains authored.'); + $original = sourceHashTree($root); + $state = $asset->captureEditState(); + $state['partner'] = [['type' => 'sequence', 'commands' => [['type' => 'move_route', 'retrace' => '']]]]; + $asset->restoreEditState($state); + $backedUp = false; + expect(fn() => $asset->save(static function () use (&$backedUp): void { $backedUp = true; }))->toThrow(InvalidArgumentException::class, 'safe stable id') + ->and($backedUp)->toBeFalse()->and(sourceHashTree($root))->toBe($original) + ->and(glob(dirname($path) . '/.*.tmp'))->toBe([]); +}); + +it('records and retraces within ordinary NPC map and standalone common-event execution sessions', function () { + $root = cutsceneProject(); + file_put_contents($root . '/assets/Maps/collisions.php', " \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE, '#' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID, '=' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID];"); + $commands = [ + ['type' => 'move_route', 'remember' => 'outbound', 'secondsPerStep' => 0, 'steps' => [['direction' => 'right']]], + ['type' => 'move_route', 'retrace' => 'outbound', 'secondsPerStep' => 0], + ]; + $path = $root . '/assets/Events/standalone-route.php'; + file_put_contents($path, ' 0.0); + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field, $commands): void { + foreach ([ + 'npc:harbour:keeper' => [$commands, ['npc' => 'keeper']], + 'harbour:event' => [$commands, ['marker' => 'event']], + 'standalone-common' => [[['type' => 'common_event', 'id' => 'standalone-route']], ['source' => 'common_event']], + ] as $identity => [$commands, $origin]) { + $interpreter = new EventInterpreter($field->scene, $field->presentation); + $session = $interpreter->run($commands, $identity, origin: $origin); + for ($tick = 0; $tick < 20 && $session->status !== EventExecutionStatus::COMPLETED; ++$tick) { + $interpreter->update(0.0); + } + expect($session?->cinematic)->toBeNull() + ->and($session?->status)->toBe(EventExecutionStatus::COMPLETED) + ->and($session?->failureMessage)->toBeNull() + ->and([$field->scene->player->position->x, $field->scene->player->position->y])->toBe([4.0, 4.0]) + ->and(fn() => $session->movementRoute('outbound'))->toThrow(RuntimeException::class, 'was not recorded in this session'); + } + }); + expect(require $path)->toBe($commands); + } finally { + $field->dispose(); + } +}); + +it('shares actual recorded movement with common events only within the owning execution lifetime', function (bool $cinematic) { + $root = cutsceneProject(); + file_put_contents($root . '/assets/Maps/collisions.php', " \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE, '#' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID, '=' => \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::SOLID];"); + file_put_contents($root . '/assets/Events/record-route.php', " 'move_route', 'remember' => 'common-recorded', 'secondsPerStep' => 0, 'steps' => [['direction' => 'right']]]];"); + file_put_contents($root . '/assets/Events/return-route.php', " 'move_route', 'retrace' => 'root-recorded', 'secondsPerStep' => 0]];"); + $commands = [ + ['type' => 'common_event', 'id' => 'record-route'], + ['type' => 'move_route', 'retrace' => 'common-recorded', 'secondsPerStep' => 0], + ['type' => 'move_route', 'remember' => 'root-recorded', 'secondsPerStep' => 0, 'steps' => [['direction' => 'right']]], + ['type' => 'common_event', 'id' => 'return-route'], + ['type' => 'wait', 'seconds' => 1], + ]; + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, 'listReferences(null, 'cinematic_movement_routes', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value')) + ->toBe(['common-recorded', 'root-recorded']) + ->and($session->listReferences('harbour', 'cinematic_movement_routes'))->toBe([]); + $definition = CinematicDefinition::fromArrays(['id' => 'route-lifetime', 'name' => 'Route Lifetime', 'startMap' => 'harbour', 'skip' => ['policy' => 'forbidden']], $commands); + $field = PreviewField::open($root, 'harbour', new Vector2(4, 4), 20, 8, fn(): float => 0.0); + try { + expect($field->mapFailure)->toBeNull(); + $field->run(function () use ($field, $definition, $commands, $cinematic): void { + $interpreter = new EventInterpreter($field->scene, $field->presentation); + $execution = $cinematic ? $interpreter->runCinematic($definition) : $interpreter->run($commands, 'ordinary-root'); + for ($tick = 0; $tick < 20 && ($execution?->pendingCommand['type'] ?? '') !== 'wait'; ++$tick) { + $interpreter->update(0.0); + } + expect($execution?->failureMessage)->toBeNull() + ->and($execution?->status)->toBe(EventExecutionStatus::YIELDED) + ->and($execution?->pendingCommand['type'])->toBe('wait') + ->and($execution->movementRoute('common-recorded')->complete)->toBeTrue() + ->and($execution->movementRoute('root-recorded')->complete)->toBeTrue() + ->and([$field->scene->player->position->x, $field->scene->player->position->y])->toBe([4.0, 4.0]) + ->and(fn() => new MovementRouteRunner($field->scene, ['retrace' => 'common-recorded'], $execution)) + ->toThrow(MovementRouteException::class, 'only be retraced once'); + $interpreter->update(2.0); + expect($execution->status)->toBe(EventExecutionStatus::COMPLETED) + ->and(fn() => $execution->movementRoute('common-recorded'))->toThrow(RuntimeException::class, 'was not recorded in this session'); + $next = $cinematic ? $interpreter->runCinematic($definition) : $interpreter->run($commands, 'ordinary-root'); + expect($next->id)->not->toBe($execution->id); + for ($tick = 0; $tick < 20 && ($next->pendingCommand['type'] ?? '') !== 'wait'; ++$tick) { + $interpreter->update(0.0); + } + expect($next->pendingCommand['type'])->toBe('wait'); + $next->cancelLanes(); + expect(fn() => $next->movementRoute('root-recorded'))->toThrow(RuntimeException::class, 'was not recorded in this session'); + }); + } finally { + $field->dispose(); + } +})->with([false, true]); + +it('offers world objects as a waypoint subject in a map event route the graphical editor reads, as in cinematics', function () { + $root = makeTemporaryProject('ichiloto-event-relative-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\ScriptEventTrigger::class, 'data' => ['script' => [ + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2]]], + ]]]; + file_put_contents($path, "readInspector('test-map', 'S', ['script'])['rows']; + $kinds = array_find($rows, static fn(array $row): bool => trim((string) ($row['label'] ?? '')) === 'Relative To' + || ($row['name'] ?? null) === 'Relative To'); + + expect($kinds)->not->toBeNull() + ->and($kinds['options'] ?? [])->toContain('world_object'); +}); diff --git a/tests/Unit/NavigationStackTest.php b/tests/Unit/NavigationStackTest.php index 35bcb43d..daaa9bdc 100644 --- a/tests/Unit/NavigationStackTest.php +++ b/tests/Unit/NavigationStackTest.php @@ -90,7 +90,7 @@ function navigationEditor(): Editor callEditorMethod($editor, 'dispatchInput', "\x07"); expect(getEditorProperty($editor, 'databaseCategoryIndex'))->toBe(DatabaseCatalog::indexOf('classes')) - ->and(getEditorProperty($editor, 'databaseSelectedClassIndex'))->toBe(1); + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['classes'] ?? 0)->toBe(1); /** @var NavigationStack $navigation */ $navigation = getEditorProperty($editor, 'navigation'); diff --git a/tests/Unit/NpcAuthoringRulesTest.php b/tests/Unit/NpcAuthoringRulesTest.php new file mode 100644 index 00000000..3d7a0fc5 --- /dev/null +++ b/tests/Unit/NpcAuthoringRulesTest.php @@ -0,0 +1,251 @@ + $npcs The npcs block. + * @param array $events Event definitions to merge. + * @return array{0: ProjectWorkspace, 1: ProjectMap} + */ +function npcRulesProject(array $npcs, array $events = []): array +{ + $root = makeTemporaryProject('ichiloto-npc-rules-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = $npcs; + $data['events'] = array_replace($data['events'] ?? [], $events); + file_put_contents($path, "getMapByIndex(0)]; +} + +/** The row with a field id among an NPC's rows. */ +function npcRulesRow(NpcInspector $inspector, int $index, string $fieldId, array $frame = []): array +{ + return array_find($inspector->getFields($index, $frame) ?? [], static fn(array $row): bool => ($row['field'] ?? null) === $fieldId) + ?? throw new RuntimeException("No NPC row {$fieldId}."); +} + +it('creates an NPC under an id its name derives, and refuses a tile outside the map or taken', function () { + [$workspace, $map] = npcRulesProject([['id' => 'gate-guard', 'name' => 'Gate Guard', 'x' => 2, 'y' => 1]]); + $authoring = new NpcAuthoring($workspace); + + $change = $authoring->create($map, 4, 2, 'Gate Guard'); + $blank = $authoring->create($map, 5, 2, ' '); + + expect($change->index)->toBe(1) + ->and($change->npc?->getId())->toBe('gate-guard-2') + ->and($map->getNpcs()->get(1)?->toArray())->toMatchArray(['x' => 4, 'y' => 2, 'movement' => 'fixed']) + ->and($blank->npc?->getName())->toBe(NpcAuthoring::DEFAULT_NAME) + ->and($blank->npc?->getId())->toBe('new-npc') + ->and(fn() => $authoring->create($map, 12, 0, 'Out'))->toThrow(NpcRefusal::class, '12,0 is outside the map.') + ->and(fn() => $authoring->create($map, 2, 1, 'Stacked'))->toThrow(NpcRefusal::class, 'Gate Guard already stands at 2,1 while both can be visible.') + ->and($map->getNpcs()->count())->toBe(3); + + // The command puts back exactly the collection; nothing else is involved. + $blank->command?->undo(); + $change->command?->undo(); + expect($map->getNpcs()->ids())->toBe(['gate-guard']) + ->and($map->isDirty())->toBeFalse(); + $change->command?->execute(); + expect($map->getNpcs()->ids())->toBe(['gate-guard', 'gate-guard-2']); +}); + +it('moves an NPC within the map onto a free tile, and changes nothing for its own tile', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 3], + ]); + $authoring = new NpcAuthoring($workspace); + + expect($authoring->move($map, 0, 2, 1)->command)->toBeNull() + ->and(fn() => $authoring->move($map, 0, -1, 1))->toThrow(NpcRefusal::class, '-1,1 is outside the map.') + ->and(fn() => $authoring->move($map, 0, 6, 3))->toThrow(NpcRefusal::class, 'Bob already stands at 6,3 while both can be visible.') + ->and(fn() => $authoring->move($map, 7, 1, 1))->toThrow(NpcRefusal::class, 'test-map has no NPC 7.') + ->and($map->isDirty())->toBeFalse(); + + $moved = $authoring->move($map, 0, 8, 2); + expect($moved->npc?->getX())->toBe(8) + ->and($moved->command?->label)->toBe('NPC move'); + $moved->command?->undo(); + expect($map->getNpcs()->get(0)?->getX())->toBe(2); +}); + +it('duplicates beside the original when that tile is free, and on its own tile when it is not', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'sprite' => 'B', 'x' => 3, 'y' => 1], + ]); + $authoring = new NpcAuthoring($workspace); + + $beside = $authoring->duplicate($map, 1); + $blocked = $authoring->duplicate($map, 0); + + expect($beside->index)->toBe(2) + ->and($beside->npc?->getId())->toBe('bob') + ->and([$beside->npc?->getX(), $beside->npc?->getY()])->toBe([4, 1]) + ->and($blocked->npc?->getId())->toBe('ann') + ->and([$blocked->npc?->getX(), $blocked->npc?->getY()])->toBe([2, 1]); +}); + +it('refuses to delete an NPC something names, naming each reference, and deletes it once nothing does', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [ + ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'a', 'steps' => [['direction' => 'north']]], + ]], + ['id' => 'c', 'name' => 'Cy', 'x' => 8, 'y' => 3], + ]); + $authoring = new NpcAuthoring($workspace); + + try { + $authoring->delete($map, 0); + $this->fail('Deleting a named NPC was not refused.'); + } catch (NpcRefusal $refusal) { + expect($refusal->getMessage())->toBe('Ann is named by NPC Bob script - resolve those before deleting.') + ->and($refusal->details)->toBe(['- NPC Bob script']); + } + + $deleted = $authoring->delete($map, 1); + expect($deleted->index)->toBeNull() + ->and($deleted->npc?->getName())->toBe('Bob') + ->and($map->getNpcs()->ids())->toBe(['a', 'c']); + + // Undo puts it back where it stood in the list, not at the end. + $deleted->command?->undo(); + expect($map->getNpcs()->ids())->toBe(['a', 'b', 'c']); +}); + +it('assigns an id to an NPC without one, once', function () { + [$workspace, $map] = npcRulesProject([['name' => 'Old Man', 'x' => 2, 'y' => 1]]); + $authoring = new NpcAuthoring($workspace); + + expect(npcRulesRow(new NpcInspector($map), 0, NpcInspector::ASSIGN_ID_FIELD)['label'])->toContain('No stable id'); + + $assigned = $authoring->assignId($map, 0); + expect($assigned->npc?->getId())->toBe('old-man') + ->and($assigned->command?->label)->toBe('Assign NPC id old-man') + ->and(fn() => $authoring->assignId($map, 0)) + ->toThrow(NpcRefusal::class, 'Old Man already has the stable id "old-man"; change it through its Id row.'); +}); + +it('edits rows through the record pane, carrying the id with a rename unless something names it', function () { + [$workspace, $map] = npcRulesProject( + [ + ['id' => 'mara', 'name' => 'Mara', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3], + ], + ['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', 'data' => ['npcId' => 'mara']]], + ); + $authoring = new NpcAuthoring($workspace); + $inspector = new NpcInspector($map); + + $kept = $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'name'), 'Old Mara'); + $followed = $authoring->applyField($inspector, 1, [], npcRulesRow($inspector, 1, 'name'), 'Robert'); + + expect($kept->npc?->getId())->toBe('mara') + ->and($kept->followedId)->toBeNull() + ->and($kept->idReferences)->toBe(['test-map event T']) + ->and($followed->followedId)->toBe('robert') + ->and($map->getNpcs()->get(1)?->getName())->toBe('Robert') + ->and($authoring->applyField($inspector, 1, [], npcRulesRow($inspector, 1, 'name'), 'Robert')->command)->toBeNull(); + + // The rename and its id are one step. + $followed->command?->undo(); + expect($map->getNpcs()->get(1)?->getId())->toBe('bob') + ->and($map->getNpcs()->get(1)?->getName())->toBe('Bob'); +}); + +it('applies the move rules to the coordinate rows, leaving nothing changed when refused', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 1], + ]); + $authoring = new NpcAuthoring($workspace); + $inspector = new NpcInspector($map); + + expect(fn() => $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'x'), '6')) + ->toThrow(NpcRefusal::class, 'Bob already stands at 6,1 while both can be visible.') + ->and(fn() => $authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'y'), '9')) + ->toThrow(NpcRefusal::class, '2,9 is outside the map.') + ->and($map->getNpcs()->get(0)?->getX())->toBe(2) + ->and($map->isDirty())->toBeFalse() + ->and(npcRulesRow($inspector, 0, 'x')['value'])->toBe('2'); + + expect($authoring->applyField($inspector, 0, [], npcRulesRow($inspector, 0, 'x'), '4')->npc?->getX())->toBe(4); +}); + +it('adds and removes dialogue variants, lines and script commands at a row', function () { + [$workspace, $map] = npcRulesProject([['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1, 'dialogue' => [['text' => 'Hi.']]]]); + $authoring = new NpcAuthoring($workspace); + $inspector = new NpcInspector($map); + + $variant = $authoring->addSubItem($inspector, 0, [], ''); + expect($map->getNpcs()->get(0)?->getDialogueAsVariants())->toHaveCount(2) + ->and($variant->command?->label)->toBe('NPC add'); + + $authoring->addSubItem($inspector, 0, [], 'variant0Line0Text'); + expect($map->getNpcs()->get(0)?->getDialogueAsVariants()[0]['lines'])->toHaveCount(2); + + $command = $authoring->addSubItem($inspector, 0, ['script'], ''); + expect($map->getNpcs()->get(0)?->getScript())->toHaveCount(1) + ->and($inspector->getFields(0, ['script']))->not->toBeEmpty(); + + $authoring->removeSubItem($inspector, 0, ['script'], 'command0Type'); + $authoring->removeSubItem($inspector, 0, [], 'variant1'); + expect($map->getNpcs()->get(0)?->getScript())->toBe([]) + ->and($map->getNpcs()->get(0)?->getDialogueAsVariants())->toHaveCount(1) + ->and($authoring->removeSubItem($inspector, 0, [], 'name')->command)->toBeNull() + ->and(fn() => $authoring->addSubItem($inspector, 0, [3, 'then'], ''))->toThrow(NpcRefusal::class, 'is no longer there'); + + $command->command?->undo(); + expect($map->getNpcs()->get(0)?->getScript())->toBe([]); +}); + +it('groups an NPC\'s rows under headings and leaves a frame that no longer resolves to the caller', function () { + [, $map] = npcRulesProject([['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1, 'mood' => 'stern', 'dialogue' => [ + ['conditions' => ['flag:met'], 'lines' => [['text' => 'Again?']]], + ]]]); + $inspector = new NpcInspector($map); + $labels = array_map(static fn(array $row): string => trim((string) $row['label']), $inspector->getFields(0) ?? []); + + expect($labels)->toContain('Identity', 'Placement', 'Movement', 'Preserved fields', 'Page 1', 'Line 1 Text') + ->and($inspector->getFields(0, [5, 'then']))->toBeNull() + ->and($inspector->getFields(0, ['script']))->not->toBeNull(); +}); + +it('warns in the terminal Inspector when a coordinate edit would stack an NPC, recording nothing', function () { + [$workspace, $map] = npcRulesProject([ + ['id' => 'a', 'name' => 'Ann', 'x' => 2, 'y' => 1], + ['id' => 'b', 'name' => 'Bob', 'x' => 6, 'y' => 1], + ]); + $editor = createEditorForTesting($workspace->projectRoot); + setEditorProperty($editor, 'workspace', $workspace); + setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + callEditorMethod($editor, 'setEditingMode', 'npc'); + callEditorMethod($editor, 'selectNpc', 0); + $x = array_find(callEditorMethod($editor, 'getInspectorFields'), static fn(array $row): bool => ($row['field'] ?? null) === 'x'); + + callEditorMethod($editor, 'applyNpcFieldValueRecorded', $x, '6'); + + expect($map->getNpcs()->get(0)?->getX())->toBe(2) + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Bob already stands at 6,1 while both can be visible. Give one a condition the other negates, such as event:x and !event:x, to share the tile.') + ->and(getEditorProperty($editor, 'history')->count())->toBe(0); +}); diff --git a/tests/Unit/NpcAuthoringTest.php b/tests/Unit/NpcAuthoringTest.php index 250eba40..479b1418 100644 --- a/tests/Unit/NpcAuthoringTest.php +++ b/tests/Unit/NpcAuthoringTest.php @@ -12,6 +12,7 @@ use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Editor\Validation\ProjectValidator; use Ichiloto\Editor\Validation\Severity; +use Ichiloto\Engine\IO\Console\TerminalText; /** * Writes a map's data file from an array, the way the fixtures are written. @@ -240,6 +241,20 @@ function npcIssueLines(string $root, ?Severity $severity = null): array ->and(ProjectNpc::strippedGlyph('<'))->toBe('<'); }); +it('keeps explicit empty NPC sprites distinct from missing sprites and diagnostic fallbacks', function () { + $entry = ['id' => 'notice', 'name' => 'Notice', 'x' => 2, 'y' => 1]; + $empty = new ProjectNpc($entry + ['sprite' => '']); + + expect($empty->getSprite())->toBe('') + ->and($empty->getVisibleSprite())->toBe('') + ->and(ProjectNpc::visibleGlyph(''))->toBe('') + ->and($empty->getSpriteWidth())->toBe(1, 'interaction and selection retain their anchor') + ->and($empty->toArray()['sprite'])->toBe('') + ->and(new ProjectNpc($entry)->getVisibleSprite())->toBe('@') + ->and(ProjectNpc::visibleGlyph(''))->toBe('@') + ->and(ProjectNpc::visibleGlyph(' '))->toBe(' '); +}); + it('refuses to change an id through with(), and folds dialogue both ways', function () { $npc = ProjectNpc::createAt('a', 'A', 0, 0); @@ -402,6 +417,50 @@ function npcIssueLines(string $root, ?Severity $severity = null): array expect($previewed[3])->toBe('# [^]#'); }); +it('leaves map-owned NPC glyphs untouched and highlights only their selected anchor', function (bool $layered, string $prefix, string $suffix) { + $root = $layered ? layeredMapProject() : npcProject([])[0]; + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $map->setLayerCell($map->getBaseLayerId(), 1, 0, 'T', $prefix, $suffix); + $map->setNpcs(NpcCollection::fromMapData([ + ['id' => 'notice', 'name' => 'Notice', 'sprite' => '', 'x' => 1, 'y' => 0, 'movement' => 'fixed', 'dialogue' => [['text' => 'Read me.']]], + ])); + $before = $map->captureLayerSnapshot(); + $files = npcHashTree($root); + $plain = $map->renderPreview(4, 2, showEventOverlay: false); + $selected = $map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true, selectedNpcIndex: 0); + + expect($map->renderPreview(4, 2, showEventOverlay: false, showNpcOverlay: true))->toBe($plain) + ->and(array_map(TerminalText::stripAnsi(...), $selected))->toBe(array_map(TerminalText::stripAnsi(...), $plain)) + ->and($selected[0])->toContain("\033[7m") + ->and(TerminalText::stripAnsi($selected[0]))->not->toContain('@', '[', ']') + ->and($map->getNpcs()->indexAt(1, 0))->toBe(0); + + // Highlight the actual visible stack, respecting hidden layers and dimming. + $hidden = ['map:4' => false]; + $plainHidden = $map->renderPreview(4, 2, layerVisibility: $hidden, activeLayer: 'event', dimInactive: true); + $selectedHidden = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, layerVisibility: $hidden, activeLayer: 'event', dimInactive: true); + expect(array_map(TerminalText::stripAnsi(...), $selectedHidden))->toBe(array_map(TerminalText::stripAnsi(...), $plainHidden)) + ->and($selectedHidden[0])->toContain("\033[7m") + ->and($map->renderPreview(4, 2, terminalPreview: true, showNpcOverlay: true, selectedNpcIndex: 0))->toBe($map->renderPreview(4, 2, terminalPreview: true)); + if ($layered) { + expect($selectedHidden[0])->toContain("\033[7mT"); + } + + $allHidden = array_fill_keys(array_column($map->getLayers(), 'id'), false); + $anchor = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, layerVisibility: $allHidden); + expect($anchor[0])->toContain("\033[7m \033[0m") + ->and($anchor[0])->not->toContain('@'); + + $directional = $map->renderPreview(4, 2, showNpcOverlay: true, selectedNpcIndex: 0, selectedNpcSprite: '^'); + expect($directional[0])->toContain('[^]') + ->and($map->captureLayerSnapshot())->toBe($before) + ->and(npcHashTree($root))->toBe($files); +})->with([ + 'legacy' => [false, '', ''], + 'layered' => [true, '', ''], + 'layered ANSI reset' => [true, "\033[0m\033[32m", "\033[0m"], +]); + it('moves an NPC on the canvas, and undoes and redoes the move', function () { [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); @@ -487,8 +546,8 @@ function npcIssueLines(string $root, ?Severity $severity = null): array // -- Inspector: every field through the shared pane ---------------------- -it('edits name, sprite, coordinates and movement without touching the id, hiding wander bounds while fixed', function () { - [$root, $path] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); +it('edits name, sprite, coordinates and movement, hiding wander bounds while fixed', function () { + [$root, $path] = npcProject([['id' => 'ann', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); $map = npcMap($editor); callEditorMethod($editor, 'selectNpc', 0); @@ -503,7 +562,8 @@ function npcIssueLines(string $root, ?Severity $severity = null): array setNpcField($editor, 'movement', 'wander'); $npc = $map->getNpcs()->get(0); - expect($npc?->getId())->toBe('a') + // Nothing names Ann and her id is the one her name derived, so it follows her new name. + expect($npc?->getId())->toBe('annabel') ->and($npc?->getName())->toBe('Annabel') ->and($npc?->getSprite())->toBe('a') ->and($npc?->getX())->toBe(5) @@ -536,6 +596,151 @@ function npcIssueLines(string $root, ?Severity $severity = null): array expect(getEditorProperty($editor, 'history')->count())->toBe($before); }); +it('shows Direction Fix beside Movement, round-trips it source-preserved, and undoes it', function () { + $entry = ['id' => 'clerk', 'name' => 'Clerk', 'sprite' => 'C', 'x' => 3, 'y' => 2, 'movement' => 'fixed', 'dialogue' => [['text' => 'Welcome.']], 'authorNote' => 'keep']; + [$root, $path] = npcProject([$entry]); + $source = file_get_contents($path); + $editor = npcEditor($root); + $map = npcMap($editor); + callEditorMethod($editor, 'selectNpc', 0); + + // Grouped under Movement, right after the movement mode, and read as the game's default. + $rows = callEditorMethod($editor, 'getInspectorFields'); + $ids = array_map(static fn(array $f): string => (string) ($f['field'] ?? ''), $rows); + $labels = array_map(static fn(array $f): string => trim((string) ($f['label'] ?? '')), $rows); + $movement = array_search('movement', $ids, true); + expect($ids[$movement + 1])->toBe('directionFix') + ->and(array_slice($labels, 0, $movement))->toContain('Movement') + ->and(npcField($editor, 'directionFix')['label'])->toContain('Direction Fix') + ->and(npcField($editor, 'directionFix')['value'])->toBe('false') + ->and(npcField($editor, 'directionFix')['editable'] ?? true)->toBeTrue(); + + // Right cycles the option in place, as every Database boolean does. + setEditorProperty($editor, 'focusedPane', 'inspector'); + restNpcCursorOn($editor, 'directionFix'); + callEditorMethod($editor, 'dispatchInput', "\033[C"); + expect($map->getNpcs()->get(0)?->toArray()['directionFix'] ?? null)->toBeTrue() + ->and(npcField($editor, 'directionFix')['value'])->toBe('true') + ->and($map->getNpcs()->get(0)?->getUnknownFields())->toBe(['authorNote']); + + callEditorMethod($editor, 'saveSelectedMap'); + $edited = file_get_contents($path); + expect(npcsOnDisk($path)[0])->toBe($entry + ['directionFix' => true]) + ->and($edited)->toContain("'directionFix' => true"); + + // A fresh editor reads it back, and the engine loads it as direction-fixed. + $reloaded = npcEditor($root); + callEditorMethod($reloaded, 'selectNpc', 0); + expect(npcField($reloaded, 'directionFix')['value'])->toBe('true'); + $scene = new class extends \Ichiloto\Engine\Scenes\Game\GameScene { + public function __construct() + { + $this->currentMapId = 'test-map'; + $this->gameState = new \Ichiloto\Engine\Core\GameState(); + $this->party = new \Ichiloto\Engine\Entities\Party(); + } + }; + $manager = new \Ichiloto\Engine\Field\NpcManager($scene); + $manager->configure(npcsOnDisk($path)); + expect($manager->findById('clerk')?->directionFix)->toBeTrue(); + + // Undo restores the file byte for byte; redo brings the flag back. + callEditorMethod($editor, 'performUndo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($source); + callEditorMethod($editor, 'performRedo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($edited); + + // False is the game's default, so it removes the key rather than writing false. + setNpcField($editor, 'directionFix', 'false'); + expect($map->getNpcs()->get(0)?->toArray())->not->toHaveKey('directionFix'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($source); +}); + +it('reports a directionFix that is not a bool, and accepts true and false', function () { + [$root] = npcProject([ + ['id' => 'word', 'name' => 'Word', 'x' => 1, 'y' => 1, 'directionFix' => 'yes'], + ['id' => 'number', 'name' => 'Number', 'x' => 2, 'y' => 1, 'directionFix' => 1], + ['id' => 'fixed', 'name' => 'Fixed', 'x' => 3, 'y' => 1, 'directionFix' => true], + ['id' => 'turns', 'name' => 'Turns', 'x' => 4, 'y' => 1, 'directionFix' => false], + ]); + + $errors = implode("\n", npcIssueLines($root, Severity::ERROR)); + $all = implode("\n", npcIssueLines($root)); + + expect($errors)->toContain('NPC Word: Its directionFix is string, not true or false.') + ->toContain('NPC Number: Its directionFix is int, not true or false.') + ->and($all)->not->toContain('NPC Fixed') + ->and($all)->not->toContain('NPC Turns') + ->and($all)->not->toContain('directionFix, which the game does not read'); +}); + +it('re-derives an NPC\'s id from its new name while nothing refers to it, in the same undo step', function () { + [$root] = npcProject([['id' => 'gate-guard', 'name' => 'Gate Guard', 'sprite' => 'G', 'x' => 6, 'y' => 3]]); + $editor = npcEditor($root); + $map = npcMap($editor); + + createNpcThroughCanvas($editor, 2, 1, ''); + expect($map->getNpcs()->get(1)?->getId())->toBe('new-npc'); + callEditorMethod($editor, 'selectNpc', 1); + getEditorProperty($editor, 'toasts')->clear(); + setNpcField($editor, 'name', 'Old Mara'); + expect($map->getNpcs()->get(1)?->getId())->toBe('old-mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('old-mara'); + + // A name another NPC's id already takes is numbered, as at creation. + setNpcField($editor, 'name', 'Gate Guard'); + expect($map->getNpcs()->get(1)?->getId())->toBe('gate-guard-2'); + + callEditorMethod($editor, 'performUndo'); + expect($map->getNpcs()->get(1)?->getId())->toBe('old-mara') + ->and($map->getNpcs()->get(1)?->getName())->toBe('Old Mara'); + callEditorMethod($editor, 'performUndo'); + expect($map->getNpcs()->get(1)?->getId())->toBe('new-npc'); +}); + +it('keeps the id of an NPC something already names when it is renamed, and says what', function (array $events, array $scriptOwner, string $names) { + [$root] = npcProject([ + ['id' => 'mara', 'name' => 'Mara', 'sprite' => 'M', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'sprite' => 'B', 'x' => 6, 'y' => 3, ...$scriptOwner], + ], $events); + $editor = npcEditor($root); + $map = npcMap($editor); + + callEditorMethod($editor, 'selectNpc', 0); + setNpcField($editor, 'name', 'Old Mara'); + + expect($map->getNpcs()->get(0)?->getId())->toBe('mara') + ->and($map->getNpcs()->get(0)?->getName())->toBe('Old Mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Its id stays mara')->toContain($names); +})->with([ + 'a dialogue event' => [['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', 'data' => ['npcId' => 'mara']]], [], 'test-map event T'], + 'a camera target nested in a sequence' => [[], ['script' => [['type' => 'sequence', 'commands' => [ + ['type' => 'camera', 'target' => ['kind' => 'npc', 'id' => 'mara']], + ]]]], 'NPC Bob script'], +]); + +it('keeps the id of an NPC only an event script reached through common_event chains names, on rename and on its Id row', function (array $bob, array $events, array $scripts) { + [$root] = npcProject([ + ['id' => 'mara', 'name' => 'Mara', 'sprite' => 'M', 'x' => 2, 'y' => 1], + ['id' => 'bob', 'name' => 'Bob', 'sprite' => 'B', 'x' => 6, 'y' => 3, ...$bob], + ], $events); + writeNpcEventScripts($root, $scripts); + $editor = npcEditor($root); + $map = npcMap($editor); + + callEditorMethod($editor, 'selectNpc', 0); + setNpcField($editor, 'name', 'Old Mara'); + expect($map->getNpcs()->get(0)?->getId())->toBe('mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Its id stays mara')->toContain('event script b (via event script a)'); + + setNpcField($editor, 'id', 'mara-at-home'); + expect($map->getNpcs()->get(0)?->getId())->toBe('mara') + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('keeps the id "mara": event script b (via event script a)'); +})->with(npcEventScriptChains()); + it('reads Movement and Sprite as their runtime defaults when unset', function () { [$root] = npcProject([['name' => 'Legacy', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); @@ -545,6 +750,68 @@ function npcIssueLines(string $root, ?Severity $severity = null): array ->and(npcField($editor, 'sprite')['value'])->toBe('@'); }); +it('authors an explicit empty base sprite through Inspector input and round-trips it with undo', function (?string $initialSprite) { + $entry = ['id' => 'notice', 'name' => 'Notice', 'x' => 3, 'y' => 2, 'movement' => 'fixed', 'dialogue' => [['text' => 'Read me.']], 'authorNote' => 'keep']; + if ($initialSprite !== null) { + $entry['sprite'] = $initialSprite; + } + [$root, $path] = npcProject([$entry]); + $source = file_get_contents($path); + $grids = npcHashTree($root . '/assets/Maps'); + $editor = npcEditor($root); + $map = npcMap($editor); + callEditorMethod($editor, 'selectNpc', 0); + setEditorProperty($editor, 'focusedPane', 'inspector'); + restNpcCursorOn($editor, 'sprite'); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect(getEditorProperty($editor, 'isDatabaseEditing'))->toBeTrue() + ->and(getEditorProperty($editor, 'databaseEditBuffer'))->toBe($initialSprite ?? '@'); + callEditorMethod($editor, 'dispatchInput', "\x7f"); + expect(getEditorProperty($editor, 'databaseEditBuffer'))->toBe(''); + callEditorMethod($editor, 'dispatchInput', "\r"); + expect($map->getNpcs()->get(0)?->toArray()['sprite'])->toBe('') + ->and(npcField($editor, 'sprite')['value'])->toBe('') + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Sprite updated.'); + callEditorMethod($editor, 'saveSelectedMap'); + $edited = file_get_contents($path); + expect(npcsOnDisk($path)[0])->toBe(array_replace($entry, ['sprite' => ''])) + ->and($edited)->toContain("'sprite' => ''"); + $reloaded = npcEditor($root); + setEditorProperty($reloaded, 'cursorX', 3); + setEditorProperty($reloaded, 'cursorY', 2); + callEditorMethod($reloaded, 'dispatchInput', "\r"); + expect(getEditorProperty($reloaded, 'selectedNpcIndex'))->toBe(0) + ->and(npcMap($reloaded)->getNpcs()->count())->toBe(1) + ->and(npcField($reloaded, 'sprite')['value'])->toBe('') + ->and(npcMap($reloaded)->getNpcs()->get(0)?->getVisibleSprite())->toBe(''); + + // The installed Engine keeps identity, dialogue and the occupied anchor without a sprite. + $scene = new class extends \Ichiloto\Engine\Scenes\Game\GameScene { + public function __construct() + { + $this->currentMapId = 'test-map'; + $this->gameState = new \Ichiloto\Engine\Core\GameState(); + $this->party = new \Ichiloto\Engine\Entities\Party(); + } + }; + $manager = new \Ichiloto\Engine\Field\NpcManager($scene); + $manager->configure(npcsOnDisk($path)); + expect($manager->findById('notice')?->sprite)->toBe('') + ->and($manager->findById('notice')?->pages)->toEqual(\Ichiloto\Engine\Field\NpcPages::fromEntry($entry)) + ->and($manager->npcAt(3, 2)?->id)->toBe('notice') + ->and($manager->visibleNpcs())->toHaveCount(1); + + callEditorMethod($editor, 'performUndo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($source); + callEditorMethod($editor, 'performRedo'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($edited); + $after = npcHashTree($root . '/assets/Maps'); + unset($grids['test-map/test-map.data.php'], $after['test-map/test-map.data.php']); + expect($after)->toBe($grids, 'NPC appearance never rewrites map or event grids'); +})->with(['authored sprite' => 'N', 'missing sprite' => [null]]); + it('authors directional sprites, previews the one under the cursor, and removes them cleanly', function () { [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); @@ -733,7 +1000,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array }); it('places the saved checkpoint in the middle of history and follows it with undo and redo', function () { - [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); + [$root] = npcProject([['id' => 'ann', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1]]); $editor = npcEditor($root); $map = npcMap($editor); callEditorMethod($editor, 'selectNpc', 0); @@ -762,7 +1029,7 @@ function npcIssueLines(string $root, ?Severity $severity = null): array callEditorMethod($editor, 'dispatchInput', "\033[3~"); callEditorMethod($editor, 'performUndo'); callEditorMethod($editor, 'saveSelectedMap'); - expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getNpcs()->ids())->toBe(['a']); + expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getNpcs()->ids())->toBe(['three']); }); it('duplicates a map with its NPC collection and internal routes intact', function () { @@ -813,6 +1080,22 @@ function npcIssueLines(string $root, ?Severity $severity = null): array // -- Validation ------------------------------------------------------------ +it('accepts explicit empty base sprites but still warns on whitespace and style-only sprites', function (array $appearance, bool $warns) { + [$root] = npcProject([ + ['id' => 'notice', 'name' => 'Notice', 'x' => 3, 'y' => 2, 'movement' => 'fixed'] + $appearance, + ]); + $messages = implode("\n", npcIssueLines($root)); + expect(str_contains($messages, 'NPC Notice: Its sprite draws nothing.'))->toBe($warns) + ->and(implode("\n", npcIssueLines($root, Severity::ERROR)))->not->toContain('NPC Notice'); +})->with([ + 'explicit empty' => [['sprite' => ''], false], + 'missing' => [[], false], + 'spaces' => [['sprite' => ' '], true], + 'formatter only' => [['sprite' => ''], true], + 'ANSI only' => [['sprite' => "\033[31m\033[0m"], true], + 'non-string false' => [['sprite' => false], true], +]); + it('reports every malformed NPC shape with the runtime consequence', function () { [$root] = npcProject([ 'not an entry', @@ -980,7 +1263,7 @@ public function __construct() restNpcCursorOn($editor, 'variant1Script'); callEditorMethod($editor, 'dispatchInput', "\r"); expect(getEditorProperty($editor, 'databaseCommandFramePath'))->toBe([1, 'script']) - ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Dialogue › Dialogue variant 2 › Script.'); + ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Dialogue › Page 2 › Script.'); callEditorMethod($editor, 'dispatchInput', 'O'); expect($map->getNpcs()->get(0)?->getDialogue()[1]['script'])->toHaveCount(1); callEditorMethod($editor, 'dispatchInput', "\033"); @@ -1096,7 +1379,7 @@ public function __construct() // -- Readable rows --------------------------------------------------------- -it('heads each dialogue variant and shortens its rows, without touching field ids', function () { +it('heads each NPC page and shortens its rows, without touching field ids', function () { [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1, 'dialogue' => [ ['lines' => [['text' => 'Hi.']]], ['conditions' => [['type' => 'switch', 'name' => 'gate_open']], 'lines' => [['text' => 'The gate is open.']]], @@ -1109,20 +1392,26 @@ public function __construct() static fn(array $f): array => ['label' => (string) ($f['label'] ?? ''), 'field' => $f['field'] ?? null, 'editable' => $f['editable'] ?? null], callEditorMethod($editor, 'getInspectorFields'), ), - static fn(array $r): bool => str_starts_with($r['label'], 'Dialogue variant') || str_starts_with((string) $r['field'], 'variant'), + static fn(array $r): bool => preg_match('/^Page \d/', $r['label']) === 1 || str_starts_with((string) $r['field'], 'variant'), )); - - expect($rows[0])->toBe(['label' => 'Dialogue variant 1', 'field' => null, 'editable' => false]) - ->and(callEditorMethod($editor, 'getInspectorFields')[array_search('Dialogue variant 1', array_column(callEditorMethod($editor, 'getInspectorFields'), 'label'), true)]['value'])->toBe('') - ->and(array_column(array_slice($rows, 1, 5), 'label'))->toBe(['When', 'Then Set', 'Script Commands', 'Line 1 Speaker', 'Line 1 Text']) - ->and(array_column(array_slice($rows, 1, 5), 'field'))->toBe(['variant0Conditions', 'variant0Sets', 'variant0Script', 'variant0Line0Name', 'variant0Line0Text']) - // The second variant's heading carries its condition line as its - // value: "Dialogue variant 2 · when switch:gate_open" on a wide pane, + $page = ['When', 'Then Set', 'Cue Symbol', 'Cue Colour', 'Cue When', 'Event Script', 'Cinematic', 'Repeats', 'Page Id', + 'Staged Speakers', 'Script Commands', 'Line 1 Speaker', 'Line 1 Emotion', 'Line 1 Text']; + $fields = static fn(int $n): array => ["variant{$n}Conditions", "variant{$n}Sets", "variant{$n}Cuesymbol", "variant{$n}Cuecolor", + "variant{$n}Cueconditions", "variant{$n}ScriptId", "variant{$n}CinematicId", "variant{$n}Reusable", "variant{$n}Id", + "variant{$n}Speakers", "variant{$n}Script", "variant{$n}Line0Name", "variant{$n}Line0Emotion", "variant{$n}Line0Text"]; + $next = count($page) + 1; + + expect($rows[0])->toBe(['label' => 'Page 1', 'field' => null, 'editable' => false]) + ->and(callEditorMethod($editor, 'getInspectorFields')[array_search('Page 1', array_column(callEditorMethod($editor, 'getInspectorFields'), 'label'), true)]['value'])->toBe('') + ->and(array_column(array_slice($rows, 1, count($page)), 'label'))->toBe($page) + ->and(array_column(array_slice($rows, 1, count($page)), 'field'))->toBe($fields(0)) + // The second page's heading carries its condition line as its + // value: "Page 2 · when switch:gate_open" on a wide pane, // the "when …" wrapped under the heading on a narrow one. - ->and($rows[6]['label'])->toBe('Dialogue variant 2') - ->and($rows[6]['editable'])->toBeFalse() - ->and(preg_replace('/\s+/', ' ', implode(' ', callEditorMethod($editor, 'getDatabaseSettingsLines'))))->toContain('Dialogue variant 2 when switch:gate_open') - ->and(array_column(array_slice($rows, 7, 5), 'field'))->toBe(['variant1Conditions', 'variant1Sets', 'variant1Script', 'variant1Line0Name', 'variant1Line0Text']); + ->and($rows[$next]['label'])->toBe('Page 2') + ->and($rows[$next]['editable'])->toBeFalse() + ->and(preg_replace('/\s+/', ' ', implode(' ', callEditorMethod($editor, 'getDatabaseSettingsLines'))))->toContain('Page 2 · when switch:gate_open') + ->and(array_column(array_slice($rows, $next + 1, count($page)), 'field'))->toBe($fields(1)); }); it('wraps a long dialogue line in the hosted Inspector, keeps the edited row single, and maps rows to fields', function () { @@ -1169,3 +1458,103 @@ public function __construct() ->and($editedLine)->toEndWith(mb_substr($long, -10)); callEditorMethod($editor, 'dispatchInput', "\033"); }); + +it('allows an NPC on an event while the event keeps a free tile, and reports one that covers it', function () { + [$root] = npcProject([ + ['id' => 'keeper', 'name' => 'Keeper', 'x' => 5, 'y' => 1], + ]); + // A three-tile area, the way a shop counter or a gathering spot is drawn. + file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', "not->toContain('Keeper'); + + // Fixed NPCs on every tile of it leave the player nowhere to stand. + [$covered] = npcProject([ + ['id' => 'keeper', 'name' => 'Keeper', 'x' => 5, 'y' => 1], + ['id' => 'left', 'name' => 'Left', 'x' => 4, 'y' => 1], + ['id' => 'right', 'name' => 'Right', 'x' => 6, 'y' => 1, 'movement' => 'wander'], + ]); + file_put_contents($covered . '/assets/Maps/test-map/test-map.event.php', "toContain('NPC Keeper: It starts on event marker E (ChestEventTrigger) at (5, 1), and NPCs cover every tile of that event') + ->and(implode("\n", npcIssueLines($covered, Severity::ERROR)))->not->toContain('event marker'); +}); + +it('steps through the phases sharing a tile with Enter on the canvas', function () { + [$root] = npcProject([ + ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]], + ['id' => 'ann', 'name' => 'Ann', 'x' => 6, 'y' => 1], + ['id' => 'ivara-returned', 'name' => 'Ivara Returned', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence']]], + ]); + $editor = npcEditor($root); + setEditorProperty($editor, 'cursorX', 2); + setEditorProperty($editor, 'cursorY', 1); + $selected = []; + + foreach ([1, 2, 3] as $press) { + callEditorMethod($editor, 'dispatchInput', "\r"); + $selected[] = getEditorProperty($editor, 'selectedNpcIndex'); + } + + expect($selected)->toBe([2, 0, 2]) + ->and(npcMap($editor)->getNpcs()->count())->toBe(3); +}); + +it('moves the page under the cursor with [ and ], keeping the cursor on it, as one undo step', function () { + [$root] = npcProject([['id' => 'a', 'name' => 'Ann', 'sprite' => 'A', 'x' => 2, 'y' => 1, 'dialogue' => [ + ['lines' => [['text' => 'Hello.']]], + ['conditions' => [['type' => 'event', 'name' => 'arrived']], 'lines' => [['text' => 'You made it.']]], + ]]]); + $editor = npcEditor($root); + callEditorMethod($editor, 'selectNpc', 0); + setEditorProperty($editor, 'focusedPane', 'inspector'); + restNpcCursorOn($editor, 'variant1Line0Text'); + $map = callEditorMethod($editor, 'getSelectedMap'); + $texts = static fn(): array => array_map(static fn(array $page): string => $page['lines'][0]['text'], $map->getNpcs()->get(0)->getDialogue()); + + callEditorMethod($editor, 'dispatchInput', '['); + $selected = callEditorMethod($editor, 'getInspectorFields')[getEditorProperty($editor, 'databaseSelectedSettingIndex')]['field'] ?? null; + + expect($texts())->toBe(['You made it.', 'Hello.'])->and($selected)->toBe('variant0Line0Text'); + callEditorMethod($editor, 'dispatchInput', '['); + expect($texts())->toBe(['You made it.', 'Hello.']); + callEditorMethod($editor, 'performUndo'); + expect($texts())->toBe(['Hello.', 'You made it.']); +}); + +it('stands an NPC facing a chosen heading, removed when cleared, and the game loads it', function () { + $entry = ['id' => 'clerk', 'name' => 'Clerk', 'sprite' => 'C', 'x' => 3, 'y' => 2, 'movement' => 'fixed', 'dialogue' => [['text' => 'Welcome.']]]; + [$root, $path] = npcProject([$entry]); + $source = file_get_contents($path); + $editor = npcEditor($root); + $map = npcMap($editor); + callEditorMethod($editor, 'selectNpc', 0); + + $ids = array_map(static fn(array $f): string => (string) ($f['field'] ?? ''), callEditorMethod($editor, 'getInspectorFields')); + expect($ids[array_search('directionFix', $ids, true) + 1])->toBe('facing') + ->and(npcField($editor, 'facing')['label'])->toContain('Stands Facing') + ->and(npcField($editor, 'facing')['value'])->toBe('south'); + + setNpcField($editor, 'facing', 'north'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(npcsOnDisk($path)[0])->toBe($entry + ['facing' => 'north']); + $scene = new class extends \Ichiloto\Engine\Scenes\Game\GameScene { + public function __construct() + { + $this->currentMapId = 'test-map'; + $this->gameState = new \Ichiloto\Engine\Core\GameState(); + $this->party = new \Ichiloto\Engine\Entities\Party(); + } + }; + $manager = new \Ichiloto\Engine\Field\NpcManager($scene); + $manager->configure(npcsOnDisk($path)); + expect($manager->findById('clerk')?->heading)->toBe(\Ichiloto\Engine\Core\Enumerations\MovementHeading::NORTH); + + // Clearing it returns to the game's default, south, by removing the key. + setNpcField($editor, 'facing', ''); + expect($map->getNpcs()->get(0)?->toArray())->not->toHaveKey('facing'); + callEditorMethod($editor, 'saveSelectedMap'); + expect(file_get_contents($path))->toBe($source); +}); diff --git a/tests/Unit/NpcGraphicalAuthoringTest.php b/tests/Unit/NpcGraphicalAuthoringTest.php new file mode 100644 index 00000000..6cd6dea1 --- /dev/null +++ b/tests/Unit/NpcGraphicalAuthoringTest.php @@ -0,0 +1,169 @@ + pack('N', strlen($bytes)) . $kind . $bytes . pack('N', crc32($kind . $bytes)); + return "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNC5', $width, $height, 8, 6, 0, 0, 0)) + . $chunk('IDAT', gzcompress(str_repeat("\0" . str_repeat("\xff\xff\xff\xff", $width), $height))) . $chunk('IEND', ''); + }; + file_put_contents($root . '/assets/Graphics/Characters/People.png', $png(48, 32)); + file_put_contents($root . '/assets/Graphics/Characters/$Guard.png', $png(12, 16)); + file_put_contents($root . '/assets/Graphics/Characters/Uneven.png', $png(50, 50)); + file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', " 'Test Map', 'events' => [], 'npcs' => [\n /* resident */ ['id' => 'resident', 'name' => 'Resident', 'sprite' => '', 'x' => 1, 'y' => 1, 'dialogue' => [['text' => 'Hello']]],\n /* neighbor */ ['id' => 'neighbor', 'name' => 'Neighbor', 'x' => 2, 'y' => 1],\n], 'custom' => strtoupper('unchanged')];\n"); + return $root; +} + +function getNpcArtPoses(): array +{ + return ['sheet' => 'Graphics/Characters/People.png', 'index' => 2, 'layer' => 100]; +} + +it('keeps graphical NPC model save restore reload and duplication source-preserving', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $original = file_get_contents($map->dataPath); + $before = $map->captureLayerSnapshot(); + $terminal = $map->renderPreview(20, 10); + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + $after = $map->captureLayerSnapshot(); + expect($map->getNpcs()->get(0)->toArray()['sprites2d'])->toEqual(getNpcArtPoses()) + ->and($map->getNpcs()->get(0)->getSprite())->toBe('') + ->and($map->renderPreview(20, 10))->toBe($terminal); + $map->save(); + $saved = file_get_contents($map->dataPath); + expect($saved)->toContain('/* resident */', '/* neighbor */', "strtoupper('unchanged')"); + $map->restoreLayerSnapshot($before); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($original); + $map->restoreLayerSnapshot($after); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe($saved); + $reloaded = ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); + expect(NpcCharacterSheet::validate($reloaded->getNpcs()->get(0)->toArray()['sprites2d'])->index)->toBe(2); + $map->duplicateTo($root . '/assets/Maps/art-copy', 'art-copy', 'Art Copy'); + expect((require $root . '/assets/Maps/art-copy/art-copy.data.php')['npcs'][0]['sprites2d'])->toEqual(getNpcArtPoses()); +}); + +it('changes one character selection in place and accepts single-character sheets', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + $map->save(); + $map->setNpcGraphicalSprites(0, ['sheet' => 'Graphics/Characters/People.png', 'index' => 5, 'layer' => 100]); + $map->save(); + expect((require $map->dataPath)['npcs'][0]['sprites2d']) + ->toBe(['sheet' => 'Graphics/Characters/People.png', 'index' => 5, 'layer' => 100]); + $map->setNpcGraphicalSprites(1, ['sheet' => 'Graphics/Characters/$Guard.png']); + $map->save(); + expect((require $map->dataPath)['npcs'][1]['sprites2d'])->toBe(['sheet' => 'Graphics/Characters/$Guard.png']); +}); + +it('refuses incomplete unsafe invalid crop and reserved layer art before changing the map', function (Closure $change) { + $map = ProjectWorkspace::fromProject(createNpcArtProject())->getMapByIndex(0); + $before = $map->captureLayerSnapshot(); + expect(fn() => $map->setNpcGraphicalSprites(0, $change(getNpcArtPoses())))->toThrow(Exception::class) + ->and($map->captureLayerSnapshot())->toBe($before); +})->with([ + fn(array $data) => [], + function (array $data) { unset($data['sheet']); return $data; }, + function (array $data) { $data['sheet'] = '../outside.png'; return $data; }, + function (array $data) { $data['sheet'] = 'Graphics/Characters/Missing.png'; return $data; }, + function (array $data) { $data['sheet'] = 'Graphics/Characters/Uneven.png'; return $data; }, + function (array $data) { $data['layer'] = 1000; return $data; }, + function (array $data) { $data['index'] = 8; return $data; }, + function (array $data) { $data['width'] = 48; return $data; }, +]); + +it('refuses opaque sprites without flattening them', function () { + $root = createNpcArtProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = str_replace("'sprite' => ''", "'sprites2d' => array_merge([], " . var_export(getNpcArtPoses(), true) . "), 'sprite' => ''", file_get_contents($path)); + file_put_contents($path, $source); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + expect(fn() => $map->setNpcGraphicalSprites(0, getNpcArtPoses()))->toThrow(RuntimeException::class, 'Opaque') + ->and(file_get_contents($path))->toBe($source); +}); + +it('removes graphical NPC authoring from the TUI while terminal sprite edits preserve graphical source', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + $map->save(); + $source = file_get_contents($map->dataPath); + $assets = sourceHashTree($root . '/assets/Graphics'); + $editor = deletionEditor($root); + $map = callEditorMethod($editor, 'getSelectedMap'); + callEditorMethod($editor, 'setEditingMode', 'npc'); + callEditorMethod($editor, 'selectNpc', 0); + setEditorProperty($editor, 'focusedPane', 'inspector'); + $fields = callEditorMethod($editor, 'getInspectorFields'); + expect(json_encode($fields))->not->toContain('__npc_art', 'Graphical Sprites', 'sprites2d') + ->and(method_exists($editor, 'openNpcSpriteArt'))->toBeFalse() + ->and(renderEditorPlainFrame($editor, 160, 45))->not->toContain('Graphical Sprites'); + $index = array_find_key($fields, static fn(array $field): bool => ($field['field'] ?? null) === 'sprite'); + expect($index)->not->toBeNull(); + setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); + foreach (["\r", 'N', "\r", "\x13"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + $saved = str_replace("'sprite' => ''", "'sprite' => 'N'", $source); + expect($map->getNpcs()->get(0)->getSprite())->toBe('N') + ->and(file_get_contents($map->dataPath))->toBe($saved); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(file_get_contents($map->dataPath))->toBe($source); + foreach (["\x19", "\x13", "\x12"] as $key) { + callEditorMethod($editor, 'dispatchInput', $key); + } + $loaded = callEditorMethod($editor, 'getSelectedMap'); + expect(file_get_contents($loaded->dataPath))->toBe($saved) + ->and($loaded->getNpcs()->get(0)->toArray()['sprites2d'])->toBe(getNpcArtPoses()) + ->and(sourceHashTree($root . '/assets/Graphics'))->toBe($assets); +}); + +it('preserves pending NPC art and all map files when the save transaction fails', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $source = file_get_contents($map->dataPath); + $map->setNpcGraphicalSprites(0, getNpcArtPoses()); + expect(fn() => $map->save(files: new FailingFileSetOperations(failures: ['move' => [$map->dataPath]])))->toThrow(RuntimeException::class) + ->and(file_get_contents($map->dataPath))->toBe($source)->and($map->isDirty())->toBeTrue(); + $map->save(); + expect((require $map->dataPath)['npcs'][0]['sprites2d'])->toBe(getNpcArtPoses()); +}); + +it('warns for malformed optional NPC graphics while accepting omission and retaining terminal-only identity', function (mixed $graphics) { + $root = createNpcArtProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $source = str_replace("'sprite' => ''", "'sprites2d' => " . var_export($graphics, true) . ", 'sprite' => ''", file_get_contents($path)); + file_put_contents($path, $source); + $workspace = ProjectWorkspace::fromProject($root); + $issues = (new \Ichiloto\Editor\Validation\ProjectValidator())->validate($workspace); + $warnings = array_filter($issues, static fn($issue) => str_contains($issue->message, 'Invalid sprites2d:')); + expect($warnings)->toHaveCount(1)->and($workspace->getMapByIndex(0)->getNpcs()->get(0)->getId())->toBe('resident'); +})->with([[null], [[]], ['invalid']]); + +it('patches one literal character selection without rewriting its comments or other fields', function () { + $root = createNpcArtProject(); + $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0); + $sheet = getNpcArtPoses(); + $map->setNpcGraphicalSprites(0, $sheet); + $map->save(); + $source = str_replace("'index' =>", "/* chosen character */ 'index' =>", file_get_contents($map->dataPath)); + file_put_contents($map->dataPath, $source); + $map = ProjectMap::fromDirectory($root . '/assets/Maps', $map->directory); + $sheet['index'] = 6; + $map->setNpcGraphicalSprites(0, $sheet); + $map->save(); + expect(file_get_contents($map->dataPath))->toBe(str_replace("'index' => 2", "'index' => 6", $source)); +}); diff --git a/tests/Unit/ObjectExportTest.php b/tests/Unit/ObjectExportTest.php index a36e9182..73f63546 100644 --- a/tests/Unit/ObjectExportTest.php +++ b/tests/Unit/ObjectExportTest.php @@ -35,6 +35,22 @@ public function __construct(array $entries = []) } } +/** + * A value built only through its own factory, as the Engine's colours are: its + * properties match its constructor, but no file may call that constructor. + */ +final class FactoryOnlyRecord +{ + private function __construct(public readonly string $name) + { + } + + public static function named(string $name): self + { + return new self($name); + } +} + it('exports an object as the constructor call that rebuilds it', function () { $code = PhpValueExporter::export(new ExportableRecord('S-Potion', 50, ['heal'])); @@ -71,6 +87,13 @@ public function __construct(array $entries = []) ->and($reason)->toContain('cannot be rebuilt'); }); +it('refuses an object only its own factory can build, rather than writing a call that cannot load', function () { + $reason = PhpValueExporter::describeUnexportable(['role' => ['colour' => FactoryOnlyRecord::named('black')]]); + + expect($reason)->toContain('FactoryOnlyRecord') + ->and(PhpValueExporter::isExportable(FactoryOnlyRecord::named('black')))->toBeFalse(); +}); + it('accepts an object every argument of which can be read back', function () { expect(PhpValueExporter::describeUnexportable([new ExportableRecord('A')]))->toBeNull() ->and(PhpValueExporter::isExportable(new ExportableRecord('A')))->toBeTrue(); diff --git a/tests/Unit/ObjectRecordCreationTest.php b/tests/Unit/ObjectRecordCreationTest.php index ae6937e6..2d33cf04 100644 --- a/tests/Unit/ObjectRecordCreationTest.php +++ b/tests/Unit/ObjectRecordCreationTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Ichiloto\Editor\Database\InventoryCatalog; use Ichiloto\Editor\Database\ProjectRecordDatabase; use Ichiloto\Editor\Database\RecordSchemaCatalog; use Ichiloto\Engine\Entities\Enumerations\ArmorType; @@ -43,7 +44,7 @@ function objectCategoryOn(string $root, string $category): ProjectRecordDatabase expect($labels)->toContain('Iron Cuirass'); - $armor = $reloaded->getRecords()[array_search('Iron Cuirass', $labels, true)]->toArray(); + $armor = InventoryCatalog::readDefinition($reloaded->getRecords()[array_search('Iron Cuirass', $labels, true)]); expect($armor)->toBeInstanceOf(Armor::class) ->and($armor->equipmentType)->toBe(ArmorType::HEAVY_ARMOR) @@ -66,15 +67,39 @@ function objectCategoryOn(string $root, string $category): ProjectRecordDatabase expect($reloadedWeapons->getEntryLabels())->toContain('Bronze Axe') ->and($reloadedItems->getEntryLabels())->toContain('Bitterroot Tonic') - // Sharing items.php cuts both ways: creating in one category must - // not disturb the other's entries. + // Creating in one inventory category must not disturb another's + // entries. ->and($reloadedItems->getEntryLabels())->toContain('S-Potion'); - $weapon = $reloadedWeapons->getRecords()[ + $weapon = InventoryCatalog::readDefinition($reloadedWeapons->getRecords()[ array_search('Bronze Axe', $reloadedWeapons->getEntryLabels(), true) - ]->toArray(); + ]); + $item = InventoryCatalog::readDefinition($reloadedItems->getRecords()[ + array_search('Bitterroot Tonic', $reloadedItems->getEntryLabels(), true) + ]); - expect($weapon)->toBeInstanceOf(Weapon::class); + expect($weapon)->toBeInstanceOf(Weapon::class) + ->and($item)->toBeInstanceOf(Item::class); +}); + +it('drops a cleared parameter change instead of writing a zero', function () { + $root = makeTemporaryProject(); + $database = objectCategoryOn($root, 'armors'); + $index = $database->addRecord(); + + // An accessory made from the armor blank starts with its Defence; the + // author clears it and gives Evasion instead. + $database->setField($index, 'name', 'Test Band'); + $database->setField($index, 'kind', 'accessory'); + $database->setField($index, 'parameterChanges.defence', ''); + $database->setField($index, 'parameterChanges.evasion', '3'); + $database->save(); + + $reloaded = objectCategoryOn($root, 'armors'); + $record = $reloaded->getRecords()[array_search('Test Band', $reloaded->getEntryLabels(), true)]; + + expect($record->get('parameterChanges'))->toBe(['evasion' => 3]) + ->and(InventoryCatalog::readDefinition($record)?->parameterChanges->defence)->toBe(0); }); it('numbers a second new armor instead of colliding, in name and in identity', function () { @@ -109,14 +134,15 @@ function objectCategoryOn(string $root, string $category): ProjectRecordDatabase ->and($byLabel['Equipment Type']['options'] ?? null)->toContain('Small Shield'); }); -it('sets the equipment type as the enum case, whatever the case typed', function () { +it('sets the equipment type as the engine names it, whatever the case typed', function () { $root = makeTemporaryProject(); $database = objectCategoryOn($root, 'armors'); $index = $database->addRecord(); $database->setField($index, 'equipmentType', 'heavy armor'); - expect($database->getRecords()[$index]->toArray()->equipmentType)->toBe(ArmorType::HEAVY_ARMOR); + expect($database->getRecords()[$index]->get('equipmentType'))->toBe('Heavy Armor') + ->and(InventoryCatalog::readDefinition($database->getRecords()[$index])?->equipmentType)->toBe(ArmorType::HEAVY_ARMOR); }); it('refuses to create rather than create what the save would drop', function () { diff --git a/tests/Unit/PermanentGrowthTest.php b/tests/Unit/PermanentGrowthTest.php index 361979fd..60e698f6 100644 --- a/tests/Unit/PermanentGrowthTest.php +++ b/tests/Unit/PermanentGrowthTest.php @@ -191,7 +191,7 @@ function growthIssues(string $root, ?Severity $severity = null): array callEditorMethod($editor, 'openDatabaseAtCategory', 'actors'); $rowFor = static function (Editor $editor, string $label): array { - foreach (callEditorMethod($editor, 'getDatabaseActorSettingsFields') as $field) { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { if (($field['label'] ?? null) === $label) { return $field; } @@ -205,7 +205,7 @@ function growthIssues(string $root, ?Severity $severity = null): array ->and($rowFor($editor, ' Assumed Growth')['options'])->toContain('growth.vigour') ->and($attack($editor))->not->toContain('growth'); - callEditorMethod($editor, 'applyDatabaseFieldValue', '__actor_growth', 'growth.vigour'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', array_values(array_filter(callEditorMethod($editor, 'getDatabaseSettingsFields'), static fn(array $row): bool => ($row['field'] ?? null) === '__actor_growth'))[0], 'growth.vigour'); expect($attack($editor))->toContain('+7 growth') // Assuming it is a view of the pane. Nothing was granted, nothing diff --git a/tests/Unit/PhysicalFootprintCodecTest.php b/tests/Unit/PhysicalFootprintCodecTest.php new file mode 100644 index 00000000..5a92d2fe --- /dev/null +++ b/tests/Unit/PhysicalFootprintCodecTest.php @@ -0,0 +1,37 @@ + $type !== CollisionType::PASS_THROUGH)); + $rows = [[...$types, null]]; + $wire = [array_map(static fn(?CollisionType $type): ?int => $type?->value, [...$types, null])]; + expect(PhysicalFootprintCodec::exportRows($rows))->toBe($wire) + ->and(PhysicalFootprintCodec::decode(PhysicalFootprintCodec::encode($rows)))->toBe($rows) + ->and(PhysicalFootprintCodec::decodeRows($wire))->toBe($rows) + ->and(PhysicalFootprintCodec::decode('[[null,null]]'))->toBe([[null, null]]) + ->and(PhysicalFootprintCodec::encode(null))->toBe(''); +}); + +it('keeps unsupported enum declarations visible for explicit raw row repair without normalizing them', function () { + $rows = [[SourceWriterUnit::FIRST]]; + expect(PhysicalFootprintCodec::encode($rows))->toContain('SourceWriterUnit::FIRST') + ->and(fn() => PhysicalFootprintCodec::decode(PhysicalFootprintCodec::encode($rows)))->toThrow(InvalidArgumentException::class) + ->and(fn() => PhysicalFootprintCodec::exportRows($rows))->toThrow(InvalidArgumentException::class); +}); + +it('rejects JSON null non lists and cells that are not final integer values before enum conversion', function (string $wire) { + expect(fn() => PhysicalFootprintCodec::decode($wire))->toThrow(InvalidArgumentException::class); +})->with(['null', 'false', '1', '"rows"', '{}', '{"0":[1]}', '[{"0":1}]', '[null]', '[1]', + '[[true]]', '[["1"]]', '[[1.0]]', '[["SOLID"]]', '[[9]]', '[[999]]', '[[[1]]]', '[[1]] trailing']); + +it('does not normalize invalid authored scalar cells into valid wire recipes', function (mixed $rows) { + expect(fn() => PhysicalFootprintCodec::exportRows($rows))->toThrow(InvalidArgumentException::class); +})->with(['null' => [null], 'scalar' => ['rows'], 'keyed' => [['rows' => []]], + 'integer' => [[[1]]], 'name' => [[['SOLID']]], 'pass through' => [[[CollisionType::PASS_THROUGH]]]]); diff --git a/tests/Unit/PlaytestTest.php b/tests/Unit/PlaytestTest.php index 70f3bcda..af50565a 100644 --- a/tests/Unit/PlaytestTest.php +++ b/tests/Unit/PlaytestTest.php @@ -2,8 +2,18 @@ declare(strict_types=1); +use Ichiloto\Editor\Cutscenes\CutsceneAsset; +use Ichiloto\Editor\Cutscenes\CutsceneType; +use Ichiloto\Editor\Cutscenes\Preview\CinematicPreviewOrigin; +use Ichiloto\Editor\Playtest\PlaytestLaunch; use Ichiloto\Editor\Playtest\PlaytestLauncher; -use Ichiloto\Editor\Playtest\PlaytestOverlay; +use Ichiloto\Editor\ProjectWorkspace; +use Ichiloto\Engine\Core\GameLaunchContext; +use Ichiloto\Engine\Scenes\Arena\LaunchTroopHandOver; +use Ichiloto\Engine\Core\GameStartupMode; +use Ichiloto\Engine\Field\MapGridSource; +use Ichiloto\Engine\Field\MapSourceReader; +use Ichiloto\Engine\IO\LaunchDataRoot; /** * Returns a path => sha1 map of every file under a directory, following no @@ -27,133 +37,185 @@ function checksumTree(string $directory): array return $checksums; } -it('builds a playtest overlay without writing into the project', function (): void { - $root = makeTemporaryProject(); - // The fixture has no system.php; a playtest needs one. - file_put_contents($root . '/assets/Data/system.php', <<<'PHP' - ['members' => ['Kaelion']], - 'startingPositions' => [ - 'player' => [ - 'destinationMap' => 'happyville/home', - 'spawnPoint' => ['x' => 8, 'y' => 4], - 'spawnSprite' => ['^'], - ], - ], - ]; - PHP); +/** The playtest data roots in the temporary directory, to show a refused launch leaves none behind. */ +function listPlaytestDataRoots(): array +{ + return glob(sys_get_temp_dir() . '/ichiloto-playtest-*', GLOB_ONLYDIR) ?: []; +} +it('plays the real project from its own root, keeping its data apart and writing nothing into the project', function (): void { + $root = playtestProject(); $before = checksumTree($root); - $overlay = PlaytestOverlay::create($root, 'test-map', 12, 5); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 12, 5); try { - expect(is_dir($overlay->root))->toBeTrue(); - expect($overlay->root)->not->toStartWith($root); + expect($launch->projectRoot)->toBe(realpath($root)) + ->and($launch->getContext())->toBe(['mode' => 'new-game', 'start' => ['mapId' => 'test-map', 'x' => 12, 'y' => 5], + 'dataRoot' => $launch->dataRoot]) + ->and([$launch->mapId, $launch->x, $launch->y])->toBe(['test-map', 12, 5]) + // Outside the project and not above it, as the Engine requires, and nothing in it but the logs. + ->and($launch->dataRoot)->not->toStartWith(realpath($root)) + ->and(LaunchDataRoot::validate($launch->dataRoot, realpath($root)))->toBe($launch->dataRoot) + ->and(array_values(array_diff(scandir($launch->dataRoot), ['.', '..'])))->toBe(['logs']) + ->and(checksumTree($root))->toBe($before); + } finally { + $launch->destroy(); + } - // The project is byte-identical. - expect(checksumTree($root))->toBe($before); + expect(is_dir($launch->dataRoot))->toBeFalse() + ->and(checksumTree($root))->toBe($before); +}); - // Assets reach through to the author's live files. - expect(is_link($overlay->root . '/assets/Maps'))->toBeTrue(); - expect(is_link($overlay->root . '/ichiloto.json'))->toBeTrue(); +it('carries the player settings into the playtest and leaves the saves behind', function (): void { + $root = playtestProject(); + mkdir($root . '/.data/saves', 0777, true); + file_put_contents($root . '/.data/player-settings.json', '{"audio":{"music":false,"sfx":false}}'); + file_put_contents($root . '/.data/saves/slot-1.json', '{}'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 1, 1); - // system.php is a real generated file, not a symlink. - $systemPath = $overlay->root . '/assets/Data/system.php'; - expect(is_link($systemPath))->toBeFalse(); - expect(is_file($systemPath))->toBeTrue(); + try { + // A copy, so a playtest's own changes never reach the project. + expect(file_get_contents($launch->dataRoot . '/player-settings.json'))->toBe('{"audio":{"music":false,"sfx":false}}') + ->and(is_link($launch->dataRoot . '/player-settings.json'))->toBeFalse() + ->and(is_dir($launch->dataRoot . '/saves'))->toBeFalse(); + } finally { + $launch->destroy(); + } - $system = require $systemPath; - expect($system['startingPositions']['player']['destinationMap'])->toBe('test-map'); - expect($system['startingPositions']['player']['spawnPoint'])->toBe(['x' => 12, 'y' => 5]); - // Untouched system data survives. - expect($system['party']['members'])->toBe(['Kaelion']); + expect(file_get_contents($root . '/.data/saves/slot-1.json'))->toBe('{}'); +}); - // Other data files still symlink to the originals. - expect(is_link($overlay->root . '/assets/Data/quests.php'))->toBeTrue(); +it('says where each kind of playtest starts in the launch context the Engine reads', function (): void { + $root = playtestProject(); + $launches = [ + 'title' => PlaytestLaunch::fromTitle($root), + 'cinematic' => PlaytestLaunch::fromCinematic($root, 'test-map', 2, 2, 'dawn-crossing'), + 'battle' => PlaytestLaunch::fromBattleTest($root, ['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]), + ]; - // Saves are isolated from the project's own. - expect(is_dir($overlay->root . '/.data'))->toBeTrue(); - expect(is_link($overlay->root . '/.data'))->toBeFalse(); + try { + foreach ($launches as $launch) { + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '=' . substr($launch->buildPlayOption(), strlen('--launch-context='))); + $contexts[] = GameLaunchContext::consumeEnvironment(); + } + [$title, $cinematic, $battle] = $contexts; + + expect($title->mode)->toBe(GameStartupMode::TITLE) + ->and($title->start)->toBeNull() + ->and($title->dataRoot)->toBe($launches['title']->dataRoot) + ->and($cinematic->mode)->toBe(GameStartupMode::NEW_GAME) + ->and([$cinematic->start->mapId, $cinematic->start->x, $cinematic->start->y])->toBe(['test-map', 2, 2]) + ->and($cinematic->cinematicId)->toBe('dawn-crossing') + ->and($battle->mode)->toBeNull() + ->and($battle->battleTest->troop)->toBe('Pair') + // A troop composed in the battle test comes back to the editor through the launch's data root. + ->and($battle->keepTroops)->toBeTrue()->and($title->keepTroops)->toBeFalse() + ->and($battle->battleTest->toArray())->toBe(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]); } finally { - $overlay->destroy(); + array_walk($launches, static fn(PlaytestLaunch $launch) => $launch->destroy()); } - - expect(is_dir($overlay->root))->toBeFalse(); - expect(checksumTree($root))->toBe($before); - - removeDirectoryRecursively($root); }); -it('destroys the overlay without following symlinks into the project', function (): void { - $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); +it('hands the play command the launch as its option, with no start mode or context of its own to compete', function (): void { + $root = playtestProject(); + putenv(GameStartupMode::ENVIRONMENT_VARIABLE . '=title'); + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '={"mode":"title"}'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 1, 1); - $questsPath = $root . '/assets/Data/quests.php'; - $before = checksumTree($root); + try { + $environment = $launch->buildEnvironment(); - $overlay = PlaytestOverlay::create($root, 'test-map', 1, 1); - $overlay->destroy(); + expect($environment)->not->toHaveKey(GameStartupMode::ENVIRONMENT_VARIABLE) + ->and($environment)->not->toHaveKey(GameLaunchContext::ENVIRONMENT_VARIABLE) + ->and($launch->buildPlayOption())->toStartWith('--launch-context=') + ->and(json_decode(substr($launch->buildPlayOption(), strlen('--launch-context=')), true))->toBe($launch->getContext()); + } finally { + putenv(GameStartupMode::ENVIRONMENT_VARIABLE); + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE); + $launch->destroy(); + } +}); - expect(is_file($questsPath))->toBeTrue(); - expect(is_dir($root . '/assets/Maps/test-map'))->toBeTrue(); - expect(checksumTree($root))->toBe($before); +it('refuses a start the Engine would not read, leaving no data root behind', function (): void { + $root = playtestProject(); + $before = listPlaytestDataRoots(); - removeDirectoryRecursively($root); + expect(fn() => PlaytestLaunch::fromCell($root, 'test-map', -1, 0))->toThrow(RuntimeException::class, 'The game cannot start that way') + ->and(fn() => PlaytestLaunch::fromCinematic($root, 'test-map', 0, 0, 'Not A Stable Id'))->toThrow(RuntimeException::class) + ->and(listPlaytestDataRoots())->toBe($before); }); -it('refuses a playtest when system.php cannot be rewritten', function (): void { +it('playtests a cinematic on a layered or a single-file start map the Engine reads from the project', function (bool $layered): void { + $root = cinematicStartMapProject($layered); + $mapDirectory = $root . '/assets/Maps/test-map'; + // A layered map keeps its grids in layers/ and has no single map file; a legacy map is that file alone. + expect(is_file($mapDirectory . '/test-map.map.php'))->toBe(! $layered) + ->and(is_dir($mapDirectory . '/layers'))->toBe($layered); + $before = checksumTree($root); + $workspace = ProjectWorkspace::fromProject($root); + $asset = CutsceneAsset::create(CutsceneType::CINEMATIC, 'dawn-crossing', $root . '/assets/Cutscenes/Cinematics', + ['id' => 'dawn-crossing', 'name' => 'Dawn Crossing', 'startMap' => 'test-map', 'commands' => []], $root); + // The origin the Cutscenes pane and the session both playtest from. + $origin = CinematicPreviewOrigin::locate($workspace, $asset, 'test-map'); + $launch = PlaytestLaunch::fromCinematic($root, (string) $origin['mapId'], $origin['x'], $origin['y'], $asset->id); + + try { + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE . '=' . substr($launch->buildPlayOption(), strlen('--launch-context='))); + $context = GameLaunchContext::consumeEnvironment(); + // The game reads the start map from the project's own root, the way the field does. + $source = MapSourceReader::readFiles(MapSourceReader::resolvePaths($launch->projectRoot . '/assets/Maps', $context->start->mapId)); + + expect($origin['mapId'])->toBe('test-map') + ->and($context->cinematicId)->toBe('dawn-crossing') + ->and([$context->start->mapId, $context->start->x, $context->start->y])->toBe(['test-map', $origin['x'], $origin['y']]) + ->and($source['layers']->legacy)->toBe(! $layered) + ->and(count($source['tiles']))->toBe($workspace->getMapByIndex(0)->getHeight()) + ->and($source['tiles'][$origin['y']][$origin['x']] ?? null)->not->toBeNull() + ->and(checksumTree($root))->toBe($before); + } finally { + putenv(GameLaunchContext::ENVIRONMENT_VARIABLE); + $launch->destroy(); + } +})->with(['layered' => true, 'single file' => false]); + +it('plays a project whose system data holds PHP objects, since nothing is rewritten for a playtest', function (): void { $root = makeTemporaryProject(); - // An object carrying state with no constructor to put it back cannot be - // written out, so the overlay refuses rather than losing it. file_put_contents( $root . '/assets/Data/system.php', "kind = 'party';\n\nreturn ['startingPositions' => ['player' => ['scope' => \$scope]]];\n", ); - expect(fn() => PlaytestOverlay::create($root, 'test-map', 1, 1)) - ->toThrow(RuntimeException::class, 'system.php contains'); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 1, 1); + $launch->destroy(); - removeDirectoryRecursively($root); + expect(is_dir($launch->dataRoot))->toBeFalse(); }); -it('runs the play command against the overlay without tmux', function (): void { - $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); - - $overlay = PlaytestOverlay::create($root, 'test-map', 3, 4); +it('runs the play command in this terminal from the project root without tmux', function (): void { + $root = playtestProject(); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 3, 4); try { - $launcher = PlaytestLauncher::discover(__FILE__); - $command = $launcher->buildCommand($overlay); - - expect($command)->toContain('play') - ->toContain('--no-tmux') - ->toContain(escapeshellarg($overlay->root)) - ->toContain(escapeshellarg(__FILE__)); + expect(PlaytestLauncher::discover(__FILE__)->buildPlayArguments($launch)) + ->toBe([PHP_BINARY, __FILE__, 'play', '--no-tmux', $launch->buildPlayOption(), '-d', realpath($root)]); } finally { - $overlay->destroy(); + $launch->destroy(); } - - removeDirectoryRecursively($root); }); it('prefers the console the project itself installed', function (): void { $root = makeTemporaryProject(); mkdir($root . '/vendor/bin', 0o777, true); touch($root . '/vendor/bin/ichiloto'); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); - - $overlay = PlaytestOverlay::create($root, 'test-map', 0, 0); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 0, 0); try { // How a game project installs the tooling, and the only copy // guaranteed to match the engine that project depends on. - expect(PlaytestLauncher::discover(projectRoot: $root)->buildCommand($overlay)) - ->toContain(escapeshellarg($root . '/vendor/bin/ichiloto')); + expect(PlaytestLauncher::discover(projectRoot: $root)->buildPlayArguments($launch)[1]) + ->toBe($root . '/vendor/bin/ichiloto'); } finally { - $overlay->destroy(); + $launch->destroy(); removeDirectoryRecursively($root); } }); @@ -167,17 +229,16 @@ function checksumTree(string $directory): array putenv('PATH=' . $binDirectory); $root = makeTemporaryProject(); - file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); - $overlay = PlaytestOverlay::create($root, 'test-map', 0, 0); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 0, 0); try { // `composer global require ichiloto/console`, which is an install // with no vendor directory anywhere near the project. - expect(PlaytestLauncher::discover()->buildCommand($overlay)) - ->toContain(escapeshellarg($binDirectory . '/ichiloto')); + expect(PlaytestLauncher::discover()->buildPlayArguments($launch)[1]) + ->toBe($binDirectory . '/ichiloto'); } finally { putenv('PATH=' . $previousPath); - $overlay->destroy(); + $launch->destroy(); removeDirectoryRecursively($root); @unlink($binDirectory . '/ichiloto'); @rmdir($binDirectory); @@ -185,7 +246,7 @@ function checksumTree(string $directory): array }); it('discovers consoles from installed package locations', function (): void { - $candidates = new ReflectionMethod(PlaytestLauncher::class, 'candidates') + $candidates = new ReflectionMethod(\Ichiloto\Editor\Console\ConsoleBinary::class, 'candidates') ->invoke(null, null, '/srv/my-game'); expect($candidates)->toContain('/srv/my-game/vendor/bin/ichiloto') @@ -211,3 +272,137 @@ function checksumTree(string $directory): array expect($message)->toContain('ICHILOTO_CONSOLE_BIN') ->and($message)->toContain('/nowhere/vendor/bin/ichiloto'); }); + +/** + * A stand-in console: it records its arguments, starts a child that would + * outlive it, and either keeps running or exits with the code given. + */ +function writeFakeConsole(string $directory, ?int $exitCode = null): string +{ + $path = $directory . '/fake-console.php'; + file_put_contents($path, ' ['player' => []]];\n"); + $directory = $root . '/assets/Maps/test-map'; + if (! $layered) { + file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource("####\n# #", 'ICHILOTO_MAP')); + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource(" E\n ", 'ICHILOTO_EVENT_MAP')); + } + file_put_contents($directory . '/test-map.data.php', " 'Test Map', 'region' => '', 'events' => ['E' => " + . "['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\ChestEventTrigger', 'data' => ['lootType' => 'item', 'loot' => 'S-Potion']]]];\n"); + + return $root; +} + +function playtestProject(): string +{ + $root = makeTemporaryProject(); + file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n"); + + return $root; +} + +it('runs a playtest in the background with its renderer and stops the whole game', function (): void { + $root = playtestProject(); + $scratch = playtestScratch(); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 2, 3); + $run = (new PlaytestLauncher(writeFakeConsole($scratch)))->start($launch, 'gpui'); + + try { + $deadline = microtime(true) + 5; + while (! str_contains((string) @file_get_contents($launch->getLogPath()), 'dataRoot') && microtime(true) < $deadline) { + usleep(20000); + } + $pid = $run->getProcessId(); + $children = trim((string) shell_exec('pgrep -P ' . $pid)); + + expect($run->isRunning())->toBeTrue() + ->and($run->readLogTail())->toContain('play --no-tmux --no-interaction --renderer=gpui --terminal-window ' + . $launch->buildPlayOption() . ' -d ' . realpath($root)) + ->and($children)->not->toBe(''); + + $run->stop(); + usleep(100000); + expect($run->isRunning())->toBeFalse() + ->and($run->wasStopped())->toBeTrue() + ->and(is_dir($launch->dataRoot))->toBeFalse() + // The child the play command started went with it. + ->and(trim((string) shell_exec('ps -p ' . (int) $children . ' -o pid=')))->toBe(''); + } finally { + // A failed expectation still stops the game it started and takes its data root with it. + $run->stop(); + $launch->destroy(); + } +}); + +it('reports how a failed playtest ended once its data root is gone', function (): void { + $root = playtestProject(); + $scratch = playtestScratch(); + $launch = PlaytestLaunch::fromCell($root, 'test-map', 0, 0); + $run = (new PlaytestLauncher(writeFakeConsole($scratch, 3)))->start($launch, 'gpui'); + + try { + $deadline = microtime(true) + 5; + while ($run->isRunning() && microtime(true) < $deadline) { + usleep(20000); + } + + expect($run->getExitCode())->toBe(3) + ->and(is_dir($launch->dataRoot))->toBeFalse() + ->and($run->readLogTail())->toContain('renderer missing'); + } finally { + $run->stop(); + $launch->destroy(); + } +}); + +it('takes the troops a battle test kept, even once the game has ended and its data root is gone', function (): void { + $root = playtestProject(); + $scratch = playtestScratch(); + $launch = PlaytestLaunch::fromBattleTest($root, ['troop' => 'Pair']); + $console = $scratch . '/slow-console.php'; + file_put_contents($console, 'start($launch, 'gpui'); + $members = [['enemy' => 'Regular Bat', 'position' => [40, 12]]]; + + try { + // The game hands a kept troop over while it runs, as LaunchTroopHandOver does. + new LaunchTroopHandOver($launch->dataRoot)->keepTroop($members); + expect($run->countKeptTroops())->toBe(1); + $deadline = microtime(true) + 5; + while ($run->isRunning() && microtime(true) < $deadline) { + usleep(20000); + } + + expect(is_dir($launch->dataRoot))->toBeFalse() + ->and($run->countKeptTroops())->toBe(1) + ->and($run->takeKeptTroops())->toBe([$members]) + ->and($run->takeKeptTroops())->toBe([]); + } finally { + $run->stop(); + $launch->destroy(); + } +}); diff --git a/tests/Unit/ProjectConfigTest.php b/tests/Unit/ProjectConfigTest.php new file mode 100644 index 00000000..5f8d4505 --- /dev/null +++ b/tests/Unit/ProjectConfigTest.php @@ -0,0 +1,352 @@ + [/* term */ 'title' => 'Hello'],\n 'messages' => ['bye' => 'Bye'],\n 'opaque' => new \\stdClass(),\n 'computed' => strtoupper('keep'), // Leave expression.\n" . $graphics . "];\n"); + return $root; +} + +/** Sets a setting through the Configuration category, as either editor does. */ +function setConfiguration(ProjectWorkspace $workspace, string $path, string $value): void +{ + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search($path, $database->getEntryLabels(), true); + + if ($index === false) { + throw new RuntimeException("No setting {$path}."); + } + + $database->setField($index, 'value', $value); +} + +/** The field zoom a project's config.php holds, or null when it leaves it to the default. */ +function savedZoom(string $root): mixed +{ + return (require $root . '/config.php')['graphics']['field']['zoom'] ?? null; +} + +it('shares pending Terms and field zoom in one source-preserving atomic config save', function () { + $root = createZoomProject(); + $original = file_get_contents($root . '/config.php'); + $workspace = ProjectWorkspace::fromProject($root); + $config = $workspace->config; + $terms = $workspace->getRecordDatabase('terms'); + $before = $config->getRecord(ProjectConfig::FIELD_ZOOM)->toArray(); + expect($before['default'])->toBe(1.0)->and($terms->isEditable())->toBeTrue(); + // Choosing the default for a setting the file leaves out writes nothing. + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '1'); + expect($workspace->hasUnsavedChanges())->toBeFalse(); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '2.5'); + $terms->setField(0, 'value', 'Welcome'); + expect(file_get_contents($config->path))->toBe($original); + $terms->save(); + expect($config->isDirty())->toBeFalse()->and($terms->isDirty())->toBeFalse() + ->and(file_get_contents($config->path))->toContain("/* term */ 'title' => 'Welcome'", "'opaque' => new \\stdClass()", "strtoupper('keep')", "'zoom' => 2.5"); + $config->getRecord(ProjectConfig::FIELD_ZOOM)->restorePayload($before); + $terms->setField(0, 'value', 'Hello'); + $config->save(); + expect(file_get_contents($config->path))->toBe($original)->and($terms->isDirty())->toBeFalse(); + $mtime = filemtime($config->path); + $config->save(); + expect(filemtime($config->path))->toBe($mtime); +}); + +it('refuses invalid zoom before mutation instead of clamping', function (string $value) { + $root = createZoomProject(); + $workspace = ProjectWorkspace::fromProject($root); + expect(fn() => setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, $value))->toThrow(InvalidArgumentException::class) + ->and($workspace->config->isDirty())->toBeFalse()->and(savedZoom($root))->toBeNull(); +})->with(['0', '0.5', '-1', '8.01', 'INF', 'NAN', '1e309', 'no', '']); + +it('repairs explicit null or string zoom rather than mistaking them for an absent numeric default', function (string $literal) { + $root = createZoomProject(" 'graphics' => ['field' => ['zoom' => " . $literal . "]],\n"); + $workspace = ProjectWorkspace::fromProject($root); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '1'); + expect($workspace->config->isDirty())->toBeTrue(); + $workspace->config->save(); + expect(savedZoom($root))->toBe(1.0); +})->with(['null', "'1'"]); + +it('refuses opaque or ambiguous zoom containers without flattening unrelated config', function (string $graphics) { + $root = createZoomProject($graphics); + $workspace = ProjectWorkspace::fromProject($root); + $original = file_get_contents($workspace->config->path); + expect(fn() => setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '3'))->toThrow(RuntimeException::class) + ->and(file_get_contents($workspace->config->path))->toBe($original)->and($workspace->config->isDirty())->toBeFalse(); +})->with([ + " 'graphics' => array_merge([], ['field' => ['zoom' => 2]]),\n", + " 'graphics' => ['field' => ['zoom' => 1 + 1]],\n", + " 'graphics' => ['field' => ['zoom' => 2, 'zoom' => 2]],\n", + " 'graphics' => [], 'graphics' => [],\n", +]); + +it('refuses concurrent source changes while retaining all pending config edits', function () { + $workspace = ProjectWorkspace::fromProject(createZoomProject()); + $config = $workspace->config; + $workspace->getRecordDatabase('terms')->setField(0, 'value', 'Pending'); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '8'); + $newSource = str_replace('Keep header.', 'New user header.', file_get_contents($config->path)); + file_put_contents($config->path, $newSource); + expect(fn() => $config->save())->toThrow(RuntimeException::class, 'changed on disk') + ->and(file_get_contents($config->path))->toBe($newSource)->and($config->isDirty())->toBeTrue() + ->and(glob($config->path . '.tmp-*'))->toBe([]); +}); + +it('preserves bytes mtimes and both pending config areas when installation fails', function () { + $workspace = ProjectWorkspace::fromProject(createZoomProject()); + $config = $workspace->config; + touch($config->path, 1000000000); + $source = file_get_contents($config->path); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '8'); + $terms = $workspace->getRecordDatabase('terms'); + $terms->setField(0, 'value', 'Pending'); + expect(fn() => $config->save(new FailingFileSetOperations(failures: ['move' => [$config->path]])))->toThrow(RuntimeException::class) + ->and(file_get_contents($config->path))->toBe($source)->and(filemtime($config->path))->toBe(1000000000) + ->and($config->isDirty())->toBeTrue()->and($terms->isDirty())->toBeTrue(); + $config->save(); + expect((require $config->path)['graphics']['field']['zoom'])->toBe(8.0)->and($terms->isDirty())->toBeFalse(); +}); + +it('makes only an opaque term read-only and preserves it through zoom and literal term edits', function () { + $root = createZoomProject(); + $path = $root . '/config.php'; + file_put_contents($path, str_replace("'bye' => 'Bye'", "'bye' => strtoupper('Bye')", file_get_contents($path))); + $workspace = ProjectWorkspace::fromProject($root); + $terms = $workspace->getRecordDatabase('terms'); + expect($terms->getSettingsFields(1)[2]['value'])->toContain('authored expression'); + expect(fn() => $terms->setField(1, 'value', 'Hidden rewrite'))->toThrow(RuntimeException::class); + $terms->setField(0, 'value', 'Literal edit'); + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '2'); + $terms->save(); + expect(file_get_contents($path))->toContain("'bye' => strtoupper('Bye')", "'title' => 'Literal edit'"); +}); + +it('preserves the read-only Terms save refusal for nonliteral returned configuration', function () { + $root = createZoomProject(); + file_put_contents($root . '/config.php', " ['title' => 'Keep']]);\n"); + $terms = loadRecordDatabase($root, 'terms'); + expect($terms->isEditable())->toBeFalse()->and(fn() => $terms->save())->toThrow(RuntimeException::class, 'read-only'); +}); + +it('refuses ambiguous dotted literal term keys without changing their nested neighbors', function () { + $root = createZoomProject(); + file_put_contents($root . '/config.php', " ['a.b' => 'Literal', 'a' => ['b' => 'Nested']]];\n"); + $workspace = ProjectWorkspace::fromProject($root); + $terms = $workspace->getRecordDatabase('terms'); + foreach ([0, 1] as $index) { + expect(fn() => $terms->setField($index, 'value', 'Wrong target'))->toThrow(RuntimeException::class, 'dotted literal key'); + } + setConfiguration($workspace, ProjectConfig::FIELD_ZOOM, '2'); + $workspace->config->save(); + expect((require $root . '/config.php')['vocab'])->toBe(['a.b' => 'Literal', 'a' => ['b' => 'Nested']]); +}); + +it('authors field zoom through the Configuration category and shares Terms undo save and reload', function () { + $root = createZoomProject(); + $editor = createEditorForTesting($root); + $workspace = ProjectWorkspace::fromProject($root); + setEditorProperty($editor, 'workspace', $workspace); + setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]); + setEditorProperty($editor, 'isRunning', true); + callEditorMethod($editor, 'openDatabaseWindow'); + setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('configuration')); + $zoom = array_search(ProjectConfig::FIELD_ZOOM, $workspace->getRecordDatabase('configuration')->getEntryLabels(), true); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['configuration' => $zoom]); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $index => $field) { + if (($field['field'] ?? '') === 'value') { setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); } + } + callEditorMethod($editor, 'dispatchInput', "\r"); + foreach (["\177", '2', '.', '5', "\r"] as $key) { callEditorMethod($editor, 'dispatchInput', $key); } + expect($workspace->config->getRecord(ProjectConfig::FIELD_ZOOM)->get('value'))->toBe(2.5); + $workspace->getRecordDatabase('terms')->setField(0, 'value', 'Concurrent term'); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(savedZoom($root))->toBe(2.5); + callEditorMethod($editor, 'dispatchInput', "\x1a"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + $saved = require $workspace->config->path; + expect($saved)->not->toHaveKey('graphics')->and($saved['vocab']['title'])->toBe('Concurrent term'); + callEditorMethod($editor, 'dispatchInput', "\x19"); + callEditorMethod($editor, 'dispatchInput', "\x13"); + expect(savedZoom($root))->toBe(2.5); +}); + +it('edits an enum setting as a choice of its enum, written back as the enum case', function () { + $root = createZoomProject(" 'ui' => ['battle' => ['selection_color' => \\Ichiloto\\Engine\\IO\\Enumerations\\Color::LIGHT_BLUE]],\n"); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search('ui.battle.selection_color', $database->getEntryLabels(), true); + $value = array_find($database->getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'value'); + + expect($value['options'] ?? [])->toContain('RED')->and($value['value'])->toBe('LIGHT_BLUE'); + + $database->setField($index, 'value', 'RED'); + $workspace->config->save(); + + expect((string) file_get_contents($root . '/config.php'))->toContain("'selection_color' => \\Ichiloto\\Engine\\IO\\Enumerations\\Color::RED") + ->and((require $root . '/config.php')['ui']['battle']['selection_color'])->toBe(\Ichiloto\Engine\IO\Enumerations\Color::RED); +}); + +it('edits a setting authored as a case of an enum with no values, by the case\'s name', function () { + $root = createZoomProject(" 'ui' => ['dialogue' => ['window' => ['position' => \\Ichiloto\\Engine\\UI\\Windows\\Enumerations\\WindowPosition::TOP]]],\n"); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $index = array_search('ui.dialogue.window.position', $database->getEntryLabels(), true); + + expect($index)->not->toBeFalse(); + + $database->setField($index, 'value', 'BOTTOM'); + $workspace->config->save(); + + expect((require $root . '/config.php')['ui']['dialogue']['window']['position']) + ->toBe(\Ichiloto\Engine\UI\Windows\Enumerations\WindowPosition::BOTTOM); +}); + +it('chooses system sounds and music from the project\'s audio folders, offering every sound the engine plays', function () { + $root = createZoomProject(" 'audio' => ['bgm' => ['title' => 'title-theme'], 'sounds' => ['cursor' => 'cursor-tick']],\n"); + foreach (['SFX/cursor-tick.mp3', 'SFX/sweep-hit.mp3', 'BGM/title-theme.mp3'] as $file) { + is_dir(dirname($root . '/assets/Audio/' . $file)) || mkdir(dirname($root . '/assets/Audio/' . $file), 0o777, true); + file_put_contents($root . '/assets/Audio/' . $file, 'audio'); + } + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $valueField = static function (string $path) use ($database): array { + $index = array_search($path, $database->getEntryLabels(), true); + expect($index)->not->toBeFalse(); + return array_find($database->getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'value'); + }; + + // battle_start is not in the project's file, yet it is offered, chosen from the sound folder. + expect($valueField('audio.sounds.cursor')['reference'] ?? null)->toBe('sfx') + ->and($valueField('audio.sounds.battle_start')['reference'] ?? null)->toBe('sfx') + ->and($valueField('audio.bgm.title')['reference'] ?? null)->toBe('bgm'); + + setConfiguration($workspace, 'audio.sounds.battle_start', 'sweep-hit'); + $workspace->config->save(); + + $saved = require $root . '/config.php'; + expect($saved['audio']['sounds'])->toBe(['cursor' => 'cursor-tick', 'battle_start' => 'sweep-hit']) + ->and((string) file_get_contents($root . '/config.php'))->toContain('// Keep header.', "strtoupper('keep')"); +}); + +it('offers the Engine\'s shared terms, such as the level in rows, so a game rewords each once', function () { + $root = createZoomProject(); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('terms'); + $labels = $database->getEntryLabels(); + $index = array_search('vocab.stats.level_short', $labels, true); + + expect($index)->not->toBeFalse()->and($labels)->toContain('vocab.stats.level') + ->and($database->getRecordByIndex($index)->get('default'))->toBe('Lv'); + + $database->setField($index, 'value', 'Lvl'); + $workspace->config->save(); + expect((require $root . '/config.php')['vocab']['stats'])->toBe(['level_short' => 'Lvl']); +}); + +it('offers each engine sound\'s own volume beside its track and refuses one outside its range', function () { + $root = createZoomProject(" 'audio' => ['sounds' => ['counter' => 'tick-loop']],\n"); + is_dir($root . '/assets/Audio/SFX') || mkdir($root . '/assets/Audio/SFX', 0o777, true); + file_put_contents($root . '/assets/Audio/SFX/tick-loop.wav', 'audio'); + $workspace = ProjectWorkspace::fromProject($root); + $database = $workspace->getRecordDatabase('configuration'); + $valueField = static function (string $path) use ($database): array { + $index = array_search($path, $database->getEntryLabels(), true); + expect($index)->not->toBeFalse(); + return array_find($database->getSettingsFields($index), static fn(array $field): bool => ($field['field'] ?? null) === 'value'); + }; + + expect($valueField('audio.sounds.counter')['reference'] ?? null)->toBe('sfx') + ->and($valueField('audio.sound_volumes.counter')['reference'] ?? null)->toBeNull() + ->and($valueField('audio.sound_volumes.cursor'))->not->toBeEmpty(); + + foreach (['101', '-1'] as $outside) { + expect(fn() => setConfiguration($workspace, 'audio.sound_volumes.counter', $outside))->toThrow(InvalidArgumentException::class); + } + expect($workspace->config->isDirty())->toBeFalse(); + + setConfiguration($workspace, 'audio.sound_volumes.counter', '35'); + $workspace->config->save(); + + expect((require $root . '/config.php')['audio'])->toBe(['sounds' => ['counter' => 'tick-loop'], 'sound_volumes' => ['counter' => 35]]); +}); + +it('offers each action category\'s announcement reading time at each pace, writing nothing until one changes', function () { + $root = createZoomProject(); + $original = file_get_contents($root . '/config.php'); + $workspace = ProjectWorkspace::fromProject($root); + $path = BattleActionCategory::SUMMON->getAnnouncementConfigPath(BattlePace::SLOW); + + foreach (BattleActionCategory::cases() as $category) { + foreach (BattlePace::cases() as $pace) { + expect($workspace->config->getRecord($category->getAnnouncementConfigPath($pace))->toArray()['default']) + ->toBe(round($category->getAnnouncementSeconds($pace), 3)); + } + } + setConfiguration($workspace, $path, '4.2'); + expect($workspace->hasUnsavedChanges())->toBeFalse(); + foreach (['0.05', '10.5', 'long'] as $outside) { + expect(fn() => setConfiguration($workspace, $path, $outside))->toThrow(InvalidArgumentException::class); + } + expect($workspace->config->isDirty())->toBeFalse() + ->and(file_get_contents($root . '/config.php'))->toBe($original); + + setConfiguration($workspace, $path, '3.5'); + $workspace->config->save(); + $battleUi = (require $root . '/config.php')['ui']['battle']; + + expect($battleUi)->toBe(['announcement_seconds' => ['summon' => ['slow' => 3.5]]]) + ->and(BattlePacing::fromBattleUiConfig($battleUi)->getAnnouncementSeconds(BattleActionCategory::SUMMON))->toBe(3.5); +}); + +it('offers Always Dash\'s first value as a switch the Engine reads, writing nothing until it changes', function () { + $root = createZoomProject(); + $original = file_get_contents($root . '/config.php'); + $workspace = ProjectWorkspace::fromProject($root); + expect($workspace->config->getRecord(ProjectConfig::ALWAYS_DASH)->toArray()['default'])->toBeFalse(); + setConfiguration($workspace, ProjectConfig::ALWAYS_DASH, 'false'); + expect($workspace->hasUnsavedChanges())->toBeFalse(); + setConfiguration($workspace, ProjectConfig::ALWAYS_DASH, 'true'); + $workspace->config->save(); + expect((require $root . '/config.php')['field']['always_dash'])->toBeTrue() + ->and(file_get_contents($root . '/config.php'))->toContain("strtoupper('keep')"); + expect(fn() => $workspace->config->assertAcceptableValue(ProjectConfig::ALWAYS_DASH, 'often'))->toThrow(InvalidArgumentException::class); + setConfiguration($workspace, ProjectConfig::ALWAYS_DASH, 'false'); + $workspace->config->save(); + expect((require $root . '/config.php')['field']['always_dash'] ?? false)->toBeFalse() + ->and($original)->not->toContain('always_dash'); +}); + +it('offers the master volume and each bus volume a game starts at, writing nothing until one changes', function () { + $root = createZoomProject(" 'audio' => ['master_volume' => 75],\n"); + $original = file_get_contents($root . '/config.php'); + $workspace = ProjectWorkspace::fromProject($root); + $labels = $workspace->getRecordDatabase('configuration')->getEntryLabels(); + + expect($labels)->toContain('audio.master_volume', 'audio.music_volume', 'audio.sfx_volume', 'audio.voice_volume') + ->and($workspace->config->getRecord('audio.music_volume')->toArray()['default'])->toBe(100) + ->and($workspace->config->getRecord('audio.master_volume')->get('value'))->toBe(75); + + setConfiguration($workspace, 'audio.sfx_volume', '100'); + expect($workspace->hasUnsavedChanges())->toBeFalse(); + foreach (['audio.music_volume' => '101', 'audio.voice_volume' => '-1', 'audio.master_volume' => '120'] as $path => $outside) { + expect(fn() => setConfiguration($workspace, $path, $outside))->toThrow(InvalidArgumentException::class); + } + expect($workspace->config->isDirty())->toBeFalse()->and(file_get_contents($root . '/config.php'))->toBe($original); + + setConfiguration($workspace, 'audio.music_volume', '80'); + setConfiguration($workspace, 'audio.master_volume', '60'); + $workspace->config->save(); + + expect((require $root . '/config.php')['audio'])->toBe(['master_volume' => 60, 'music_volume' => 80]); +}); diff --git a/tests/Unit/ProjectFormatTest.php b/tests/Unit/ProjectFormatTest.php new file mode 100644 index 00000000..39c0da72 --- /dev/null +++ b/tests/Unit/ProjectFormatTest.php @@ -0,0 +1,44 @@ +toBe(ProjectFormat::CURRENT) + ->and(ProjectWorkspace::fromProject($root)->maps)->not->toBe([]); +}); + +it('refuses to open a project in another format with the Engine explanation', function (mixed $format) { + $root = makeTemporaryProject('ichiloto-format-'); + $config = json_decode((string) file_get_contents($root . '/ichiloto.json'), true); + if ($format === null) { + unset($config[ProjectFormat::KEY]); + } else { + $config[ProjectFormat::KEY] = $format; + } + file_put_contents($root . '/ichiloto.json', json_encode($config)); + try { + ProjectFormat::assertSupported($format); + $expected = null; + } catch (UnsupportedProjectFormatException $refusal) { + $expected = $refusal->getMessage(); + } + + expect($expected)->not->toBeNull() + ->and(fn() => ProjectWorkspace::fromProject($root))->toThrow(UnsupportedProjectFormatException::class, $expected); +})->with([ + 'unrecorded' => [null], + 'older' => [ProjectFormat::CURRENT - 1], + 'newer' => [ProjectFormat::CURRENT + 1], +]); diff --git a/tests/Unit/ProjectIntegrityMatrixTest.php b/tests/Unit/ProjectIntegrityMatrixTest.php index a89e4a4a..94f2ea65 100644 --- a/tests/Unit/ProjectIntegrityMatrixTest.php +++ b/tests/Unit/ProjectIntegrityMatrixTest.php @@ -332,7 +332,7 @@ function integrityEditor(string $root): Editor $database->save(); } catch (Throwable $refusal) { // A refused save is allowed; a half-written file is not. - expect($refusal->getMessage())->toContain('Unable to write temporary file'); + expect($refusal->getMessage())->toContain('Unable to write a temporary file'); } expect((string) file_get_contents($path))->toBe($before); diff --git a/tests/Unit/ProjectMapTest.php b/tests/Unit/ProjectMapTest.php index bb20b134..c92511fa 100644 --- a/tests/Unit/ProjectMapTest.php +++ b/tests/Unit/ProjectMapTest.php @@ -14,43 +14,6 @@ function fixtureMap(): ProjectMap return ProjectMap::fromDirectory($mapsRoot, $mapsRoot . '/test-map'); } -/** - * Copies the fixture map into a scratch maps root for save tests. - */ -function scratchMapCopy(): array -{ - $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-editor-test-' . bin2hex(random_bytes(4))); - $mapsRoot = $root . '/assets/Maps'; - $directory = $mapsRoot . '/test-map'; - mkdir($directory, 0777, true); - - foreach (['data', 'map', 'event'] as $part) { - copy( - fixturePath("sample-project/assets/Maps/test-map/test-map.{$part}.php"), - "{$directory}/test-map.{$part}.php", - ); - } - - return [$root, ProjectMap::fromDirectory($mapsRoot, $directory)]; -} - -/** - * Removes a scratch tree created by scratchMapCopy(). - */ -function removeScratchTree(string $root): void -{ - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $item) { - $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); - } - - rmdir($root); -} - it('parses the fixture map dimensions and styled tiles', function () { $map = fixtureMap(); @@ -165,6 +128,45 @@ function removeScratchTree(string $root): void ->and($map->getEventMarkerAt(5, 1))->toBeNull(); }); +/** A four-by-two map whose event layer is the given rows. */ +function eventCellsMap(array $eventLines): ProjectMap +{ + return new ProjectMap( + mapId: 'town', + directory: '/virtual/Maps/town', + dataPath: '/virtual/Maps/town/town.data.php', + mapPath: '/virtual/Maps/town/town.map.php', + eventPath: '/virtual/Maps/town/town.event.php', + data: ['events' => []], + tileLines: ['....', '....'], + eventLines: $eventLines, + ); +} + +it('reads a marker painted in separate places as its exact cells', function () { + $map = eventCellsMap([' D', 'D ']); + + expect($map->getEventArea('D')?->cells)->toBe([[3, 0], [0, 1]]) + ->and($map->getEventArea('D')?->isRectangle)->toBeFalse() + ->and($map->getEventBounds('D'))->toBe(['x' => 0, 'y' => 0, 'width' => 4, 'height' => 2]) + ->and($map->getEventArea('Z'))->toBeNull(); +}); + +it('moves every cell of a marker together and refuses a move off the map or onto another marker', function () { + $map = eventCellsMap(['D D', ' E']); + $before = $map->captureGridSnapshot(); + + expect($map->moveEventCells('D', 0, 1))->toContain('cover marker E at (3, 1)') + ->and($map->moveEventCells('D', 1, 0))->toContain('leave the map at (4, 0)') + ->and($map->captureGridSnapshot())->toBe($before); + + $map = eventCellsMap(['D D ', ' ']); + + expect($map->moveEventCells('D', 1, 1))->toBeNull() + ->and($map->getEventArea('D')?->cells)->toBe([[1, 1], [3, 1]]) + ->and($map->getEventMarkerAt(0, 0))->toBeNull(); +}); + it('never treats metadata as a rename: the loaded path is identity', function () { $map = fixtureMap(); @@ -262,3 +264,106 @@ function removeScratchTree(string $root): void removeScratchTree($root); } }); + +/** + * Replaces the scratch map's tile grid and reloads it. + * + * @param string[] $rows The grid rows, styling tags included. + * @return array{0: string, 1: ProjectMap} The map file path and the reloaded map. + */ +function styledScratchMap(string $root, array $rows): array +{ + $mapsRoot = $root . '/assets/Maps'; + $mapFile = $mapsRoot . '/test-map/test-map.map.php'; + file_put_contents($mapFile, "#### #', + '# #', + '# #', + '############', + ]); + + foreach ([1, 2, 3, 4] as $x) { + expect($map->getTileColor($x, 1))->toBe('gray', "cell {$x} of the run"); + } + + expect($map->getTileColor(5, 1))->toBeNull() + ->and(substr_count($map->renderPreview(12, 5)[1], "\033[90m#\033[0m"))->toBe(4); + } finally { + removeScratchTree($root); + } +}); + +it('rebuilds an edited row as balanced runs and keeps untouched rows byte-identical', function () { + [$root] = scratchMapCopy(); + + try { + $rows = [ + '############', + '##### #', + '# ~~ # #', + '# #', + '############', + ]; + [$mapFile, $map] = styledScratchMap($root, $rows); + + // Recolour a cell inside the run, and erase the run's last cell. + $map->setTileCell(2, 1, '#', '', ''); + $map->setTileCell(4, 1, ' ', '', ''); + $map->save(); + + $saved = explode("\n", (string) file_get_contents($mapFile)); + + expect($saved[4])->toBe('#### #') + ->and($saved[5])->toBe($rows[2]) + ->and($saved[3])->toBe($rows[0]); + + $reloaded = ProjectMap::fromDirectory($root . '/assets/Maps', $root . '/assets/Maps/test-map'); + + expect([$reloaded->getTileColor(1, 1), $reloaded->getTileColor(2, 1), $reloaded->getTileColor(3, 1)]) + ->toBe(['gray', 'red', 'gray']) + ->and($reloaded->getTileColor(4, 1))->toBeNull() + ->and($reloaded->getTileColor(5, 1))->toBeNull(); + } finally { + removeScratchTree($root); + } +}); + +it('writes a row restored to its loaded cells back as its original bytes', function () { + [$root] = scratchMapCopy(); + + try { + $rows = [ + '############', + '##### #', + '# #', + '# #', + '############', + ]; + [$mapFile, $map] = styledScratchMap($root, $rows); + $original = (string) file_get_contents($mapFile); + $style = $map->getTileCellStyle(2, 1); + + $map->setTileCell(2, 1, '#', '', ''); + $map->setTileCell(2, 1, '#', $style['prefix'], $style['suffix']); + $map->setTileCell(9, 3, 'x', '', ''); + $map->save(); + + $saved = explode("\n", (string) file_get_contents($mapFile)); + + expect($saved[4])->toBe($rows[1]) + ->and($saved[6])->toBe('# x #') + ->and(str_replace('# x #', '# #', (string) file_get_contents($mapFile)))->toBe($original); + } finally { + removeScratchTree($root); + } +}); diff --git a/tests/Unit/ProjectOwnedDataTest.php b/tests/Unit/ProjectOwnedDataTest.php index 5ed82804..7c8e064e 100644 --- a/tests/Unit/ProjectOwnedDataTest.php +++ b/tests/Unit/ProjectOwnedDataTest.php @@ -22,14 +22,12 @@ /** * Returns a project whose weapon carries a special property. * - * @return array{0: string, 1: string} The project root and items path. + * @return array{0: string, 1: string} The project root and the weapon's record file. */ function projectOwnedProject(): array { $root = makeTemporaryProject('ichiloto-owned-'); - $path = $root . '/assets/Data/items.php'; - - file_put_contents($path, <<<'PHP' + $path = $root . '/' . writeItemRecords($root, ...itemsFromSource(<<<'PHP' 10, 'tiers' => ['minor', 'major']], ['percent' => 20, 'capPerHit' => 40], - ))->toBe(['percent' => 20, 'capPerHit' => 40, 'tiers' => ['minor', 'major']]); + ))->toBe(['percent' => 20, 'tiers' => ['minor', 'major'], 'capPerHit' => 40]); + + // A hidden value stays where it was authored, so an unchanged line + // leaves the map exactly as stored. + $stored = ['label' => 'x', 'tiers' => ['a'], 'percent' => 10, 'nested' => ['b' => 1]]; + + expect(ParameterMapCodec::merge($stored, ['label' => 'x', 'percent' => 10]))->toBe($stored) + ->and(ParameterMapCodec::merge(['first' => [1], 'percent' => 10], ['percent' => 5]))->toBe(['first' => [1], 'percent' => 5]); }); it('authors special-property parameters without losing the nested ones', function () { @@ -153,7 +158,7 @@ function projectOwnedProject(): array $weapons->setField(0, 'specialProperty.parameters', 'percent=25, capPerHit=40, appliesTo=magical'); $weapons->save(); - $property = (static fn(): mixed => require $path)()[0]->specialProperty; + $property = (static fn(): mixed => require $path)()['data']['specialProperty']; expect($property['type'])->toBe('lifesteal') ->and($property['parameters']['percent'])->toBe(25) @@ -164,7 +169,7 @@ function projectOwnedProject(): array it('reports a special property the runtime carries but nothing can read', function () { $root = makeTemporaryProject('ichiloto-owned-'); - file_put_contents($root . '/assets/Data/items.php', <<<'PHP' + writeItemRecords($root, ...itemsFromSource(<<<'PHP' 'lifesteal', 'parameters' => 'quite a lot'], ), ]; - PHP); + PHP)); $issues = new \Ichiloto\Editor\Validation\ProjectValidator() ->validate(ProjectWorkspace::fromProject($root)); @@ -495,7 +500,7 @@ function sameParameters(array $expected, array $actual): bool $weapons->setField(0, 'specialProperty.parameters', 'percent=10, capPerHit=40, appliesTo=physical, weight=1.0, big=1.0E+20, tag="1e5", " odd key "=-0.0'); $weapons->save(); - $parameters = (static fn(): mixed => require $path)()[0]->specialProperty['parameters']; + $parameters = (static fn(): mixed => require $path)()['data']['specialProperty']['parameters']; expect($parameters['weight'])->toBe(1.0) ->and($parameters['big'])->toBe(1.0E+20) diff --git a/tests/Unit/ProjectSystemDatabaseTest.php b/tests/Unit/ProjectSystemDatabaseTest.php index 386bad4c..0b9e8cc7 100644 --- a/tests/Unit/ProjectSystemDatabaseTest.php +++ b/tests/Unit/ProjectSystemDatabaseTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); -use Ichiloto\Editor\ProjectSystemDatabase; +use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\Session\EditorSession; function scratchSystemProject(): string { @@ -12,10 +14,23 @@ function scratchSystemProject(): string return $root; } -it('round-trips every system field including extended and unknown ATB settings', function (): void { - $root = scratchSystemProject(); - $path = $root . '/assets/Data/system.php'; - $original = [ +function systemRecords(string $root): ProjectRecordDatabase +{ + return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('system')); +} + +/** What the System page shows for a field, as both editors read it. */ +function systemSettingsValue(string $root, string $field): ?string +{ + $row = array_find(systemRecords($root)->getSettingsFields(0), static fn(array $row): bool => ($row['field'] ?? null) === $field); + + return is_array($row) ? (string) $row['value'] : null; +} + +/** @return array */ +function productionSystem(): array +{ + return [ 'title' => 'Production Fixture', 'currency' => ['amount' => 400, 'symbol' => 'G'], 'startingParty' => ['Kaelion', 'Liora', 'Drazek', 'Seraphis'], @@ -36,54 +51,298 @@ function scratchSystemProject(): string 'speedFactorPercent' => 100, 'openingVariance' => 24, 'openingSpeedFactorPercent' => 250, - 'surpriseAttackChancePercent' => 8, - 'backAttackChancePercent' => 6, 'futureTuningKey' => 17, ], ], 'unknownSystemSetting' => ['preserve' => 'yes'], ]; - file_put_contents($path, "setField(0, 'title', 'Changed'); + $database->setField(0, 'title', 'Production Fixture'); + $database->save(); + + expect((string) file_get_contents($path))->toBe($source); +}); + +it('merges an edited system field without removing unedited settings', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, "setField(0, 'battle.activeTime.baseFillRate', '42'); + $database->setField(0, 'startingPositions.player.spawnSprite.0', 'West'); + $database->save(); + $saved = require $path; + $expected = productionSystem(); + $expected['battle']['activeTime']['baseFillRate'] = 42; + $expected['startingPositions']['player']['spawnSprite'] = ['West']; + + expect($saved)->toBe($expected); +}); - $database = ProjectSystemDatabase::fromProject($root); +it('edits the starting party as actor ids and the starting inventory as items with quantities', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, "removeSubItem(0, 1, 'startingParty'); + $stock = $database->addSubItem(0, listKey: 'startingInventory'); + $database->setField(0, "stock{$stock}Item", 'Ether'); + $database->setField(0, "stock{$stock}Quantity", '3'); $database->save(); + $saved = require $path; + + // A party member stays a bare id, as the file authors it. + expect($saved['startingParty'])->toBe(['Kaelion', 'Drazek', 'Seraphis']) + ->and($saved['startingInventory'])->toBe([['item' => 'S-Potion', 'quantity' => 10], ['item' => 'Ether', 'quantity' => 3]]); +}); + +it('keeps the file as the category\'s one record, only ever edited', function (): void { + $root = scratchSystemProject(); + file_put_contents($root . '/assets/Data/system.php', "getEntryLabels())->toBe(['Production Fixture']) + ->and($database->isEditable())->toBeTrue() + ->and($database->supportsRecordCreation())->toBeFalse() + ->and($database->supportsRecordDeletion())->toBeFalse() + ->and($database->duplicateRecordSupported())->toBeFalse() + ->and($database->addRecord())->toBeNull(); +}); + +it('opens System in the GUI session with its lists under their headings', function (): void { + $session = EditorSession::open(makeTemporaryProject()); + $records = $session->listDatabaseRecords('system'); + $rows = $session->readDatabaseRecord('system', 0)['rows']; + $labels = array_column($rows, 'label'); + + expect($records['editable'])->toBeTrue() + ->and($records['canCreate'])->toBeFalse() + ->and($labels)->toContain('Title', 'Starting Gold', 'Start Map', 'Battle Engine', 'Level Up HP/MP', 'Starting Party', 'Starting Inventory'); +}); + +it('keeps a value written as an enum case\'s value written that way, as the new case', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, " 'Fixture',\n 'startingPositions' => ['player' => ['spawnSprite' => [MovementHeading::SOUTH->value]]],\n];\n"); + + $database = systemRecords($root); + new \Ichiloto\Editor\Database\RecordAuthoring()->applyField($database, 0, [], 'startingPositions.player.spawnSprite.0', 'West', 'Start Facing'); + $database->save(); + + expect((string) file_get_contents($path))->toContain('[MovementHeading::WEST->value]') + ->and((require $path)['startingPositions']['player']['spawnSprite'])->toBe(['West']); +}); + +it('refuses an edit to a value the file writes as an expression when it is made, not at save', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, " strtoupper('fixture'), 'currency' => ['amount' => 5]];\n"); + $database = systemRecords($root); + + expect(fn() => new \Ichiloto\Editor\Database\RecordAuthoring()->applyField($database, 0, [], 'title', 'Renamed', 'Title')) + ->toThrow(\Ichiloto\Editor\Database\RecordRefusal::class, 'expression') + ->and($database->getRecordByIndex(0)->get('title'))->toBe('FIXTURE') + ->and($database->isDirty())->toBeFalse(); + + // Values written as plain data stay editable beside it. + new \Ichiloto\Editor\Database\RecordAuthoring()->applyField($database, 0, [], 'currency.amount', '9', 'Starting Gold'); + $database->save(); + + expect((string) file_get_contents($path))->toContain("strtoupper('fixture')", "'amount' => 9"); +}); + +it('saves System and Types, which share system.php, each with only its own keys', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, " ['Fire', 'Ice']], true) . ";\n"); + $system = systemRecords($root); + $types = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('types')); + $types->setField(0, ProjectRecordDatabase::subFieldId('element', 1, 'name'), 'Frost'); + $system->setField(0, 'currency.amount', '900'); + $system->save(); + $types->save(); $saved = require $path; - $reloaded = ProjectSystemDatabase::fromProject($root); - $reloaded->save(); - $reloadedAgain = require $path; - expect($saved)->toBe($original) - ->and($saved['battle']['activeTime'])->toBe($original['battle']['activeTime']) - ->and($saved['battle']['unknownBattleSetting'])->toBe(['future' => true]) + expect($saved['currency']['amount'])->toBe(900) + ->and($saved['elements'])->toBe(['Fire', 'Frost']) ->and($saved['unknownSystemSetting'])->toBe(['preserve' => 'yes']) - ->and($reloadedAgain)->toBe($original); + ->and(array_keys($system->getRecordByIndex(0)?->toArray() ?? []))->not->toContain('elements') + ->and(fn() => new Ichiloto\Editor\Database\RecordSchema( + key: 'stray', + entryNoun: 'stray', + storage: Ichiloto\Editor\Database\RecordStorage::LIST_FILE, + relativePath: 'assets/Data/system.php', + fields: [new Ichiloto\Editor\Database\RecordField('title', 'Title')], + projection: new Ichiloto\Editor\Database\Projections\WholeFileProjection(['elements']), + ))->toThrow(LogicException::class, 'The stray category edits "title", a key its file projection does not own.'); }); -it('merges an edited system field without removing unedited settings', function (): void { +it('chooses what a level up does to current HP and MP, reading an absent rule as the Engine\'s default, and keeps the rest', function (): void { $root = scratchSystemProject(); $path = $root . '/assets/Data/system.php'; - $original = [ - 'title' => 'Production Fixture', - 'battle' => [ - 'engine' => 'active_time', - 'activeTime' => [ - 'mode' => 'wait', - 'baseFillRate' => 35, - 'speedFactorPercent' => 100, - 'openingVariance' => 24, - 'futureTuningKey' => 17, - ], - ], - ]; - file_put_contents($path, "fields, + static fn($field): bool => $field->key === 'progression.levelUpRecovery'); + $read = static fn(array $system): Ichiloto\Engine\Progression\LevelUpRecovery + => Ichiloto\Engine\Progression\ProgressionSettings::fromSystemData($system)->levelUpRecovery; + + expect($field->options)->toBe(['full', 'increase', 'none']) + ->and($field->displayDefault)->toBe('full') + ->and($read(productionSystem()))->toBe(Ichiloto\Engine\Progression\LevelUpRecovery::FULL) + ->and(systemSettingsValue($root, 'progression.levelUpRecovery'))->toBe('full'); + + foreach (['none', 'increase', 'full'] as $mode) { + $database = systemRecords($root); + $database->setField(0, 'progression.levelUpRecovery', $mode); + $database->save(); + $saved = require $path; + + expect($saved)->toBe([...productionSystem(), 'progression' => ['levelUpRecovery' => $mode]]) + ->and($read($saved)->value)->toBe($mode) + ->and(systemSettingsValue($root, 'progression.levelUpRecovery'))->toBe($mode); + } +}); + +it('serves the level up rule to the GUI session as a choice that keeps the file\'s own writing', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/system.php'; + $source = " 'Fixture',\n" + . " // Where the story starts.\n 'startingPositions' => ['player' => ['spawnSprite' => [MovementHeading::SOUTH->value]]],\n];\n"; + file_put_contents($path, $source); + $session = EditorSession::open($root); + $row = static fn(): array => array_find($session->readDatabaseRecord('system', 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'progression.levelUpRecovery') + ?? throw new RuntimeException('Missing Level Up HP/MP row'); + + expect($row())->toMatchArray(['label' => 'Level Up HP/MP', 'kind' => 'options', 'options' => ['full', 'increase', 'none'], 'value' => 'full']); + + $session->applyDatabaseRecord('system', 0, $row()['key'], 'increase'); + $session->saveDatabase('system'); + $written = (string) file_get_contents($path); + + expect($row()['value'])->toBe('increase') + ->and($written)->toContain('// Where the story starts.', '[MovementHeading::SOUTH->value]') + ->and((require $path)['progression'])->toBe(['levelUpRecovery' => 'increase']) + ->and(Ichiloto\Engine\Progression\ProgressionSettings::fromSystemData(require $path)->levelUpRecovery) + ->toBe(Ichiloto\Engine\Progression\LevelUpRecovery::INCREASE); + + $session->undo(); + $session->saveDatabase('system'); + expect((string) file_get_contents($path))->toBe($source); +}); + +it('reports progression rules the Engine would not read as an error', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/system.php'; + $system = is_file($path) ? require $path : []; + $validate = static fn(): array => array_values(array_filter( + new Ichiloto\Editor\Validation\ProgressionValidator()->validate(Ichiloto\Editor\ProjectWorkspace::fromProject($root)), + static fn($issue): bool => str_contains($issue->where, 'progression'))); + + expect($validate())->toBe([]); + + file_put_contents($path, " ['levelUpRecovery' => 'half']], true) . ";\n"); + $issues = $validate(); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Ichiloto\Editor\Validation\Severity::ERROR) + ->and($issues[0]->message)->toContain('must be one of: full, increase, none'); + + file_put_contents($path, " ['restoreVitalsOnLevelUp' => false]], true) . ";\n"); + $issues = $validate(); - $database = ProjectSystemDatabase::fromProject($root); - $database->setField('atbBaseFillRate', 42); + expect($issues)->toHaveCount(1) + ->and($issues[0]->message)->toContain("replaced by levelUpRecovery: write 'full' for true or 'none' for false"); +}); + +it('authors the dash in System as the Engine reads it, an absent dash reading as none, and keeps the rest', function (): void { + $root = scratchSystemProject(); + $path = $root . '/assets/Data/system.php'; + file_put_contents($path, " Ichiloto\Engine\Field\DashSettings::fromSystemData($system); + + expect(systemSettingsValue($root, 'dash.enabled'))->toBe('false') + ->and(systemSettingsValue($root, 'dash.speedMultiplier'))->toBe('2') + ->and($read(productionSystem())->enabled)->toBeFalse(); + + $database = systemRecords($root); + $database->setField(0, 'dash.enabled', 'true'); + $database->setField(0, 'dash.speedMultiplier', '1.5'); $database->save(); $saved = require $path; + expect($saved)->toBe([...productionSystem(), 'dash' => ['enabled' => true, 'speedMultiplier' => 1.5]]) + ->and($read($saved))->toEqual(new Ichiloto\Engine\Field\DashSettings(true, 1.5)) + ->and(systemSettingsValue($root, 'dash.enabled'))->toBe('true'); + + // Turning the dash off leaves no enabled key to read: an absent dash is none. + $database = systemRecords($root); + $database->setField(0, 'dash.enabled', 'false'); + $database->save(); + expect($read(require $path)->enabled)->toBeFalse(); +}); + +it('serves the dash to the GUI session as a switch and a number that keep the file\'s own writing', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/system.php'; + $source = " 'Fixture',\n" + . " // Where the story starts.\n 'startingPositions' => ['player' => ['spawnSprite' => [MovementHeading::SOUTH->value]]],\n];\n"; + file_put_contents($path, $source); + $session = EditorSession::open($root); + $row = static fn(string $field): array => array_find($session->readDatabaseRecord('system', 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) + ?? throw new RuntimeException("Missing $field row"); + + expect($row('dash.enabled'))->toMatchArray(['label' => 'Dash', 'value' => 'false']) + ->and($row('dash.speedMultiplier'))->toMatchArray(['label' => 'Dash Speed Multiplier']); + + $session->applyDatabaseRecord('system', 0, $row('dash.enabled')['key'], 'true'); + $session->applyDatabaseRecord('system', 0, $row('dash.speedMultiplier')['key'], '2'); + $session->saveDatabase('system'); + $written = (string) file_get_contents($path); + + expect($written)->toContain('// Where the story starts.', '[MovementHeading::SOUTH->value]') + ->and(Ichiloto\Engine\Field\DashSettings::fromSystemData(require $path))->toEqual(new Ichiloto\Engine\Field\DashSettings(true, 2.0)); + + $session->undo(); + $session->undo(); + $session->saveDatabase('system'); + expect((string) file_get_contents($path))->toBe($source); +}); + +it('reports a dash or a map Disable Dashing the Engine would not read as an error', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/system.php'; + $system = is_file($path) ? require $path : []; + $validate = static fn(): array => new Ichiloto\Editor\Validation\DashValidator()->validate(Ichiloto\Editor\ProjectWorkspace::fromProject($root)); + + expect($validate())->toBe([]); + + foreach ([[['enabled' => true, 'speedMultiplier' => 1], 'greater than 1'], [['enabled' => 'yes'], 'true or false'], + [['run' => true], 'Unknown system.dash field']] as [$dash, $message]) { + file_put_contents($path, " $dash], true) . ";\n"); + $issues = $validate(); + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Ichiloto\Editor\Validation\Severity::ERROR) + ->and($issues[0]->message)->toContain($message); + } - expect($saved['battle']['activeTime']['baseFillRate'])->toBe(42) - ->and($saved['battle']['activeTime']['openingVariance'])->toBe(24) - ->and($saved['battle']['activeTime']['futureTuningKey'])->toBe(17); + file_put_contents($path, " ['enabled' => true]], true) . ";\n"); + expect($validate())->toBe([]); + $map = Ichiloto\Editor\ProjectWorkspace::fromProject($root)->maps[0]; + file_put_contents($map->dataPath, "dataPath), 'disableDashing' => 'yes'], true) . ";\n"); + $issues = $validate(); + expect($issues)->toHaveCount(1)->and($issues[0]->map)->toBe($map->mapId) + ->and($issues[0]->message)->toContain('disableDashing must be true or false'); }); diff --git a/tests/Unit/ProjectValidatorTest.php b/tests/Unit/ProjectValidatorTest.php index a0453eda..e81a7c95 100644 --- a/tests/Unit/ProjectValidatorTest.php +++ b/tests/Unit/ProjectValidatorTest.php @@ -12,7 +12,8 @@ * * The shared fixture's quests were lifted from a bigger project and reference * its maps and items, which is itself a real finding rather than a reason not - * to have a clean baseline. + * to have a clean baseline. The errand is handed over at the map's chest, and + * its start waymark leads there, so the story guidance check has no gap. * * @param string $root The project root. * @return void @@ -26,6 +27,7 @@ function writeConsistentQuests(string $root): void [ 'id' => 'errand', 'name' => 'An Errand', + 'startWaymarks' => [['map' => 'test-map', 'event' => 'E']], 'objectives' => [ ['type' => 'reach_map', 'target' => 'test-map'], ['type' => 'collect', 'target' => 'S-Potion'], @@ -34,13 +36,20 @@ function writeConsistentQuests(string $root): void ], ]; PHP); + // Inserted in place, so the map's other lines stay as the cases that edit them expect. + editTestMapData($root, static fn(string $source): string => str_replace( + "'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger',", + "'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger',\n 'sets' => [['type' => 'quest', 'name' => 'errand']],", + $source, + )); } /** * Rewrites the temporary project's skit so it only names things it has. * * Same story as the quests: the fixture's skit plays on a map from a bigger - * project and waits on one of its quests. + * project and waits on one of its quests. The map it plays on gets a + * backdrop, so the skit has a scene to show. * * @param string $root The project root. * @return void @@ -59,6 +68,41 @@ function writeConsistentSkits(string $root): void ], ]; PHP); + writeTilesetTestPng($root . '/assets/Graphics/Skits/Test.png', 32, 18); + editTestMapData($root, static fn(string $source): string => str_replace("'region' => '',", + "'region' => '',\n 'skitBackdrop' => 'Graphics/Skits/Test.png',", $source)); +} + +/** + * Defines the enemies the temporary project's troops field. + * + * Same story again: the fixture's troops name a bigger project's enemies, + * and a troop whose enemy is not defined cannot load. + * + * @param string $root The project root. + * @return void + */ +function writeConsistentEnemies(string $root): void +{ + is_dir($root . '/assets/Data/Enemies') || mkdir($root . '/assets/Data/Enemies', 0777, true); + + foreach (['regular-bat' => 'Regular Bat', 'sewer-rat' => 'Sewer Rat'] as $file => $name) { + file_put_contents($root . '/assets/Data/Enemies/' . $file . '.php', << \\Ichiloto\\Engine\\Entities\\Enemies\\Enemy::class, + 'data' => [ + 'name' => '{$name}', + 'level' => 1, + 'imagePath' => '{$file}', + 'stats' => ['maxHp' => 10, 'maxMp' => 0, 'attack' => 3, 'defence' => 2, 'magicAttack' => 1, 'magicDefence' => 1, 'speed' => 4, 'grace' => 1, 'evasion' => 0], + 'rewards' => ['experience' => 1, 'gold' => 1], + ], + ]; + + PHP); + } } /** Writes a save compatibility manifest into a disposable project. */ @@ -100,16 +144,20 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('passes a project with nothing wrong with it', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); expect(validateProject($root))->toBe([]); }); it('accepts conditional event cues from the shared runtime contract', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); editTestMapData( $root, static fn(string $source): string => str_replace( @@ -124,8 +172,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('validates conditional event cues with the shared condition vocabulary', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); editTestMapData( $root, static fn(string $source): string => str_replace( @@ -148,10 +198,12 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $issues = validateProject(fixturePath('sample-project')); // The map is named twice: a quest objective goes there, and the skit - // plays there. + // plays there. The rat is named twice too: a quest objective defeats it, + // and a troop fields it, as another troop fields the bat. expect(issuesMentioning($issues, 'happyville/town-center'))->toHaveCount(2) ->and(issuesMentioning($issues, 'S-Mana'))->toHaveCount(1) - ->and(issuesMentioning($issues, 'Sewer Rat'))->toHaveCount(1) + ->and(issuesMentioning($issues, 'Sewer Rat'))->toHaveCount(2) + ->and(issuesMentioning($issues, 'Regular Bat'))->toHaveCount(2) // The skit's own condition waits on a quest the fixture does define, // and a reference that resolves is not a finding. ->and(issuesMentioning($issues, 'breakfast-duty'))->toHaveCount(0); @@ -214,8 +266,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('validates generic summon availability, policies, identities, and linked actions', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'unsafe-summon', [ 'id' => 'unsafe-summon', 'name' => 'Unsafe Summon', @@ -234,14 +288,16 @@ function writeValidatorActor(string $root, string $fileId, string $name, string expect(issuesMentioning($issues, 'unknown condition type "lunar_phase"'))->toHaveCount(1) ->and(issuesMentioning($issues, 'invalid tenancy "forever"'))->toHaveCount(1) - ->and(issuesMentioning($issues, 'eligible character "No Such Actor"'))->toHaveCount(1) + ->and(issuesMentioning($issues, "Unresolved actor reference 'No Such Actor'"))->toHaveCount(1) ->and(issuesMentioning($issues, 'links to action "Missing Action"'))->toHaveCount(1); }); it('rejects invalid summon starting assignments and duplicate exclusive holders', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'bound-summon', [ 'id' => 'bound-summon', 'name' => 'Bound Summon', @@ -266,8 +322,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('reports malformed availability and actor assignment payloads', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'malformed-summon', [ 'id' => 'malformed-summon', 'name' => 'Malformed Summon', @@ -285,8 +343,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('reports malformed declared summon policy and condition fields without coercing them', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'malformed-fields', [ 'id' => 'malformed-fields', 'name' => 'Malformed Fields', @@ -305,8 +365,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('matches actor summon assignments to definition ids case-insensitively like runtime', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'signature-summon', [ 'id' => 'Signature-Summon', 'name' => 'Signature Summon', @@ -320,8 +382,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('rejects non-list and duplicate actor summon assignments', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeValidatorSummon($root, 'shared-summon', [ 'id' => 'shared-summon', 'name' => 'Shared Summon', @@ -397,23 +461,21 @@ function writeValidatorActor(string $root, string $fileId, string $name, string // Drop a stray marker onto the layer, the way a mis-click would. file_put_contents($path, preg_replace('/\n( +)\n/', "\nZ\n", $source, 1)); - $issues = issuesMentioning(validateProject($root), 'places marker "Z"'); + $issues = array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => $issue->code === 'event.unmapped')); + // The Engine's own rule, the one its map loader refuses the map by, found before running it. expect($issues)->toHaveCount(1) ->and($issues[0]->severity)->toBe(Severity::ERROR) - // This is the crash the engine throws on load, found before running it. - ->and($issues[0]->hint)->toContain('Unmapped event markers'); + ->and($issues[0]->map)->toBe('test-map') + ->and($issues[0]->where)->toBe('test-map event Z') + ->and($issues[0]->message)->toContain('Z'); }); -it('catches a marker that is not one solid rectangle', function () { +it('accepts a marker painted in separate places, as the runtime triggers its exact cells', function () { $root = makeTemporaryProject(); $path = $root . '/assets/Maps/test-map/test-map.event.php'; - file_put_contents($path, <<<'PHP' - str_replace( "'events' => [", @@ -421,14 +483,13 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $source )); - $issues = issuesMentioning(validateProject($root), 'does not occupy one solid rectangle'); + $issues = validateProject($root); - expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::ERROR) - ->and($issues[0]->hint)->toContain('cross-shaped'); + expect(issuesMentioning($issues, 'rectangle'))->toBe([]) + ->and(issuesMentioning($issues, 'could not be read'))->toBe([]); }); -it('catches an event defined but never placed', function () { +it('reports an event defined but never placed as the error the Engine refuses the map for', function () { $root = makeTemporaryProject(); editTestMapData($root, static fn(string $source): string => str_replace( @@ -437,11 +498,12 @@ function writeValidatorActor(string $root, string $fileId, string $name, string $source )); - $issues = issuesMentioning(validateProject($root), 'Marker "Q" is defined but never placed'); + $issues = array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => $issue->code === 'event.area')); - // Harmless, but it is dead content: nothing can ever trigger it. + // The Engine's map loader refuses an event with neither cells nor a subject. expect($issues)->toHaveCount(1) - ->and($issues[0]->severity)->toBe(Severity::WARNING); + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->where)->toBe('test-map event Q'); }); it('catches a door that leads nowhere', function () { @@ -507,6 +569,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('does not mistake a repeated value for a repeated key', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); // Two different keys that happen to share a value. @@ -569,6 +632,7 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('sorts what will crash the game above what is merely dead', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); editTestMapData($root, static fn(string $source): string => str_replace( @@ -584,8 +648,10 @@ function writeValidatorActor(string $root, string $fileId, string $name, string it('accepts a complete sequential compatibility manifest with catalog-backed targets', function () { $root = makeTemporaryProject(); + writeLoadableSampleMap($root); writeConsistentQuests($root); writeConsistentSkits($root); + writeConsistentEnemies($root); writeSaveCompatibilityManifest($root, <<<'PHP' and(issuesMentioning($issues, 'impossible order'))->toHaveCount(2) ->and(issuesMentioning($issues, 'Content migration step 2 to 3 is missing'))->toHaveCount(1); }); + +it('warns that a tiles2d crop table is no longer read', function () { + $root = makeTemporaryProject(); + editTestMapData($root, static fn(string $source): string => str_replace( + "'triggers' => [],", + "'triggers' => [],\n 'tiles2d' => ['asset' => 'Graphics/Tilesets/shared.png', 'symbols' => []],", + $source, + )); + + $issues = issuesMentioning(validateProject($root), 'tiles2d'); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::WARNING) + ->and($issues[0]->message)->toBe('Its tiles2d crop table is no longer read.'); +}); + +/** Writes a catalogue of every kind of skill, one record each. */ +function writeSpreadSkillCatalog(string $root, Ichiloto\Engine\Entities\Skills\Skill ...$extra): void +{ + writeSkillRecords( + $root, + new Ichiloto\Engine\Entities\Skills\BasicSkill('Attack', 'Strikes.', '', 0, 0), + new Ichiloto\Engine\Entities\Skills\MagicSkill('Cleanse', 'Lifts a poison.', '', 3, 0), + new Ichiloto\Engine\Entities\Skills\SpecialSkill('Ward', 'Guards.', '', 2, 0), + new Ichiloto\Engine\Entities\Skills\MagicSkill('Burn I', 'Burns.', '', 5, 0), + ...$extra, + ); +} + +it('checks ability and spell alias targets against their own kind across every skill file', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + writeSpreadSkillCatalog($root); + writeSaveCompatibilityManifest($root, <<<'PHP' + 0, + 'migrations' => [], + 'aliases' => [ + 'spells' => [ + ['from' => 'Fire', 'to' => 'Burn I'], + ['from' => 'Purify', 'to' => 'Cleanse'], + ['from' => 'Guard', 'to' => 'Ward'], + ], + 'abilities' => [ + ['from' => 'Shield', 'to' => 'Ward'], + ['from' => 'Blaze', 'to' => 'Burn I'], + ], + ], + 'tombstones' => [], + ]; + PHP); + + $missing = array_map( + static fn(Issue $issue): string => $issue->message, + issuesMentioning(validateProject($root), 'is not defined in the current'), + ); + + expect($missing)->toEqualCanonicalizing([ + 'Alias target "Ward" is not defined in the current spells catalog.', + 'Alias target "Burn I" is not defined in the current abilities catalog.', + ]); +}); + +it('reports a skill name the catalogue defines twice', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + writeSpreadSkillCatalog($root, new Ichiloto\Engine\Entities\Skills\MagicSkill('Cleanse', 'Again.', '', 3, 0)); + + $issues = issuesMentioning(validateProject($root), '"Cleanse" is already defined by Skills/0002-cleanse.php'); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR); +}); + +it('offers skills from every skill file as references', function () { + $root = makeTemporaryProject(); + writeSpreadSkillCatalog($root); + + expect(ProjectWorkspace::fromProject($root)->getSkillNames())->toBe(['Attack', 'Cleanse', 'Ward', 'Burn I']); +}); + +/** Writes a transitions catalogue whose one treatment draws the given image. */ +function writeTransitionCatalog(string $root, string $asset, string $battle = 'sweep'): void +{ + @mkdir($root . '/assets/Data/Presentation', 0777, true); + file_put_contents($root . '/assets/Data/Presentation/transitions.php', << 0, 'y' => 0, 'width' => 160, 'height' => 90]; + \$brush = ['type' => 'solid', 'color' => PresentationColor::rgb(0, 0, 0)->toArray()]; + \$sweep = new ScreenTransitionTreatment(['id' => 'sweep', 'width' => 160, 'height' => 90, + 'timings' => ['gather' => 0, 'cover' => 100, 'hold' => 0, 'reveal' => 100], + 'coverBrush' => \$brush, 'phases' => [ + 'gather' => [], + 'cover' => [['operation' => ['type' => 'image', 'asset' => '$asset', 'destination' => \$bounds]]], + 'reveal' => [['operation' => ['type' => 'fill', 'destination' => \$bounds, 'brush' => \$brush]]], + ]]); + + return new ScreenTransitionCatalog(['sweep' => \$sweep], battle: '$battle'); + PHP); +} + +it('accepts transitions the Engine can play', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + @mkdir($root . '/assets/Graphics/System', 0777, true); + $image = imagecreatetruecolor(4, 4); + imagepng($image, $root . '/assets/Graphics/System/Sweep.png'); + writeTransitionCatalog($root, 'Graphics/System/Sweep.png'); + + expect(validateProject($root))->toBe([]); +}); + +it('reports transitions the Engine refuses, naming the direct cut it falls back to', function (string $asset, string $battle, ?string $source, string $expected) { + $root = makeTemporaryProject(); + writeTransitionCatalog($root, $asset, $battle); + + if ($source !== null) { + file_put_contents($root . '/assets/Data/Presentation/transitions.php', $source); + } + + $issues = array_values(array_filter( + validateProject($root), + static fn(Issue $issue): bool => $issue->where === 'assets/Data/Presentation/transitions.php', + )); + + expect($issues)->toHaveCount(1) + ->and($issues[0]->severity)->toBe(Severity::ERROR) + ->and($issues[0]->message)->toContain($expected) + ->and($issues[0]->hint)->toContain('direct cut'); +})->with([ + 'a missing image' => ['Graphics/System/Missing.png', 'sweep', null, 'Transition sweep:'], + 'an unknown battle choice' => ['Graphics/System/Missing.png', 'gilded', null, 'Unknown battle transition treatment.'], + 'the wrong return value' => ['Graphics/System/Missing.png', 'sweep', " CollisionType::SOLID, ' ' => CollisionType::NONE, '~' => CollisionType::NONE];\n"); + file_put_contents($root . '/assets/Data/system.php', ' ['player' => [ + 'destinationMap' => 'test-map', 'spawnPoint' => ['x' => $startAt[0], 'y' => $startAt[1]], 'spawnSprite' => ['South'], + ]]], true) . ';'); + file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', " */ +function reachabilityIssueLines(string $root): array +{ + return array_map( + static fn(Issue $issue): string => $issue->severity->value . ': ' . $issue->where . ': ' . $issue->message, + array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => + str_contains($issue->message, 'reach') || str_contains($issue->message, 'arrives') + || str_contains($issue->message, 'brings them onto'))), + ); +} + +it('reports an event the player can never reach, not where content stands', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + // The chest E sits in a sealed pocket. + writeReachableProject($root, + "############\n# ~~~ ### #\n# #E# #\n# ### #\n############", + " \n \n E \n \n "); + + expect(reachabilityIssueLines($root))->toBe([ + 'error: test-map: No cell of event E (ChestEventTrigger) at (8, 2) can be reached, so it never fires.', + ]); + + // Moving the chest anywhere open is simply fine. + writeReachableProject($root, + "############\n# ~~~ ### #\n# # # #\n# ### #\n############", + " \n E \n \n \n "); + expect(reachabilityIssueLines($root))->toBe([]); +}); + +it('notes a map nothing reaches yet as a warning, and checks edge trigger destinations', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + writeReachableProject($root, + "############\n# ~~~ #\n# #\n# #\n############", + " \n E \n \n \n "); + mkdir($root . '/assets/Maps/vestige', 0777, true); + file_put_contents($root . '/assets/Maps/vestige/vestige.map.php', " 'Vestige', 'triggers' => [ + ['destinationMap' => 'nowhere', 'trigger_area' => ['x' => 0, 'y' => 0, 'width' => 1, 'height' => 1], + 'spawn_point' => ['x' => 0, 'y' => 0]], + ]], true) . ';'); + + $issues = validateProject($root); + + expect(reachabilityIssueLines($root))->toBe([ + 'warning: vestige: Nothing the player can reach from the start brings them onto it yet.', + ])->and(issuesMentioning($issues, 'Edge trigger 1 leads to "nowhere", which is not a map in this project.'))->toHaveCount(1); +}); + +it('reports floor the player can reach that no tile covers as an error, and is satisfied once it is painted', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + // The sample map's chest stands on painted floor. + writeReachableProject($root, "#####\n# #\n#####", " \n E \n ", [1, 1]); + writeTestTileset($root); + $data = $root . '/assets/Maps/test-map/test-map.data.php'; + file_put_contents($data, str_replace("'triggers' => [],", "'triggers' => [], 'tileset' => 'home',", (string) file_get_contents($data))); + // The walls and the first two floor cells are painted; the third floor cell is not. + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 5 5 5 5\n5 2816 2816 0 5\n5 5 5 5 5"); + $untiled = static fn(): array => array_values(array_map(static fn(Issue $issue): string => "{$issue->severity->value} {$issue->map}: {$issue->message}", + issuesMentioning(validateProject($root), 'shows the background'))); + + expect($untiled())->toBe(['error test-map: 1 cell the player can reach has no tile, so the field shows the background there: (3, 1).']); + writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 5 5 5 5\n5 2816 2816 2816 5\n5 5 5 5 5"); + expect($untiled())->toBe([]); +}); + +/** Writes a field effect drawing one image cell from a 3 x 4 sheet. */ +function writeFieldEffect(string $root, string $id, string $asset): void +{ + @mkdir($root . "/assets/Animations/{$id}", 0777, true); + file_put_contents($root . "/assets/Animations/{$id}/{$id}.timeline.php", ' 5, 'lengthFrames' => 2, 'playback' => 'loop', 'restFrame' => 0, + 'tracks' => [[ + 'id' => 'cue', 'type' => 'image', 'asset' => $asset, 'sheet' => ['columns' => 3, 'rows' => 4], + 'cells' => ['width' => 1, 'height' => 1], 'depth' => 'front', + 'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 1, 'sourceFrame' => 1]], + ]], + ], true) . ';'); +} + +/** @return list */ +function effectIssueLines(string $root): array +{ + return array_map( + static fn(Issue $issue): string => $issue->where . ': ' . $issue->message, + array_values(array_filter(validateProject($root), static fn(Issue $issue): bool => + str_contains($issue->message, 'Effect ') || str_contains($issue->message, 'fieldEffects') + || str_contains($issue->message, 'field presentation'))), + ); +} + +it('checks every effect a consumer uses, as that consumer plays it, in both presentations', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + @mkdir($root . '/assets/Graphics/System', 0777, true); + $image = imagecreatetruecolor(9, 8); + imagepng($image, $root . '/assets/Graphics/System/Cue.png'); + writeFieldEffect($root, 'cue-ok', 'Graphics/System/Cue.png'); + writeFieldEffect($root, 'cue-missing-art', 'Graphics/System/Missing.png'); + @mkdir($root . '/assets/Data/Presentation', 0777, true); + file_put_contents($root . '/assets/Data/Presentation/field.php', ' ['blue' => ['effect' => 'cue-ok'], 'yellow' => ['effect' => 'cue-missing-art']], + 'actionPrompt' => ['effect' => 'no-such-effect'], + ], true) . ';'); + $animations = require $root . '/assets/Data/animations.php'; + $animations[0]['targetEffect'] = 'cue-ok'; + file_put_contents($root . '/assets/Data/animations.php', ' str_contains($line, 'cue-ok')))->toBe([]) + ->and(implode("\n", $lines)) + ->toContain('assets/Data/Presentation/field.php: yellow cue: Effect cue-missing-art cannot be played in field for the graphical presentation') + ->toContain('assets/Data/Presentation/field.php: action prompt: Effect no-such-effect cannot be played in field for the terminal presentation') + // A field image effect without terminal tracks still plays in the terminal: it keeps its glyph. + ->not->toContain('cue-missing-art cannot be played in field for the terminal presentation'); +}); + +it('reports map field effects it cannot read', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['fieldEffects'] = 'not a list'; + file_put_contents($path, 'toBe([ + 'test-map: Its fieldEffects cannot be read: fieldEffects must be a list of at most 256 map-owned effects.', + ]); +}); + +it('reports a record list entry that names nothing, or a definition the project lacks', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + $path = $root . '/assets/Data/Enemies/sewer-rat.php'; + file_put_contents($path, str_replace( + "'rewards' => ['experience' => 1, 'gold' => 1],", + "'rewards' => ['experience' => 1, 'gold' => 1, 'items' => [['item' => '', 'rate' => 0.5], ['item' => 'Moon Tonic', 'rate' => 0.1], ['item' => 'S-Potion', 'rate' => 0.2]]],", + (string) file_get_contents($path), + )); + + $issues = array_map(static fn(Issue $issue): string => $issue->where . ': ' . $issue->message, validateProject($root)); + + expect($issues)->toBe([ + 'enemy Sewer Rat, drop 1: Its item names no item.', + 'enemy Sewer Rat, drop 2: Its item names the item "Moon Tonic", which the project does not define.', + ]); +}); + +it('checks a summon alias against the summons the project defines', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + writeSpreadSkillCatalog($root); + // One summon, defined as the Engine loads it: a directory holding its data and timeline records. + $summon = $root . '/assets/Cutscenes/Summons/ember'; + mkdir($summon, 0777, true); + file_put_contents($summon . '/ember.data.php', " 'ember', 'name' => 'Ember', 'linkedActionId' => 'Ember Call', + 'availability' => ['conditions' => []], + 'wielders' => ['mode' => 'characters', 'characters' => ['Kaelion'], 'tenancy' => 'exclusive'], + ], true) . ";\n"); + file_put_contents($summon . '/ember.timeline.php', " 12, 'lengthFrames' => 1, 'tracks' => [], 'cues' => []];\n"); + writeSaveCompatibilityManifest($root, <<<'PHP' + 0, + 'migrations' => [], + 'aliases' => [ + 'summons' => [ + ['from' => 'spark', 'to' => 'ember'], + ['from' => 'ash', 'to' => 'cinder'], + ], + ], + 'tombstones' => [], + ]; + PHP); + + $missing = array_map( + static fn(Issue $issue): string => $issue->message, + issuesMentioning(validateProject($root), 'is not defined in the current'), + ); + + expect($missing)->toBe(['Alias target "cinder" is not defined in the current summons catalog.']); +}); + +it('checks a knowledge subject alias against the subjects the project declares', function () { + $root = makeTemporaryProject(); + writeLoadableSampleMap($root); + writeConsistentQuests($root); + writeConsistentSkits($root); + writeConsistentEnemies($root); + file_put_contents($root . '/assets/Data/knowledge.php', " ['creature'], + 'subjects' => [['id' => 'creature.brush-wolf', 'recordType' => 'creature', 'displayName' => 'Brush Wolf', 'quickCard' => 'A wolf.']], + ], true) . ";\n"); + writeSaveCompatibilityManifest($root, <<<'PHP' + 0, + 'migrations' => [], + 'aliases' => [ + 'knowledge_subjects' => [ + ['from' => 'creature.wolf', 'to' => 'creature.brush-wolf'], + ['from' => 'creature.fox', 'to' => 'creature.vixen'], + ], + ], + 'tombstones' => [], + ]; + PHP); + + $missing = array_map( + static fn(Issue $issue): string => $issue->message, + issuesMentioning(validateProject($root), 'is not defined in the current'), + ); + + expect($missing)->toBe(['Alias target "creature.vixen" is not defined in the current knowledge_subjects catalog.']); +}); + +it('reads keys in long array syntax as nested, as var_export writes them, and still finds a real duplicate', function () { + $root = makeTemporaryProject(); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['npcs'] = [['id' => 'a', 'name' => 'A', 'x' => 1, 'y' => 1], ['id' => 'b', 'name' => 'B', 'x' => 2, 'y' => 1]]; + file_put_contents($path, " issuesMentioning(validateProject($root), 'is declared'); + + expect($duplicates())->toBe([]); + + file_put_contents($path, str_replace("'region' =>", "'name' => array ( 0 => 'Shadowed' ),\n 'region' =>", (string) file_get_contents($path))); + expect(array_map(static fn(Issue $issue): string => $issue->message, $duplicates()))->toBe(['"name" is declared 2 times (lines 4, 5).']); +}); diff --git a/tests/Unit/QuestDatabaseTest.php b/tests/Unit/QuestDatabaseTest.php index f8dcce97..6417fdd3 100644 --- a/tests/Unit/QuestDatabaseTest.php +++ b/tests/Unit/QuestDatabaseTest.php @@ -4,7 +4,9 @@ use Ichiloto\Editor\Database\DatabaseCatalog; use Ichiloto\Editor\History\CommandHistory; -use Ichiloto\Editor\ProjectQuestDatabase; +use Ichiloto\Editor\Database\ProjectRecordDatabase; +use Ichiloto\Editor\Database\RecordSchemaCatalog; +use Ichiloto\Editor\ProjectQuest; use Ichiloto\Editor\ProjectWorkspace; use Ichiloto\Engine\Quests\Quest; @@ -22,6 +24,18 @@ function questsEditor(): \Ichiloto\Editor\Editor return $editor; } +/** The quests category of a project, through the shared record service. */ +function questRecords(string $root): ProjectRecordDatabase +{ + return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('quests')); +} + +/** @return list */ +function questViews(ProjectRecordDatabase $database): array +{ + return array_map(static fn($record): ProjectQuest => new ProjectQuest((array) $record->toArray()), $database->getRecords()); +} + /** * Copies the fixture project into a scratch directory so saves never touch * the shared fixture. @@ -37,8 +51,8 @@ function scratchQuestProject(): string } it('loads the quests from the fixture project', function () { - $database = ProjectQuestDatabase::fromProject(fixturePath('sample-project')); - $quests = $database->getQuests(); + $database = questRecords(fixturePath('sample-project')); + $quests = questViews($database); expect($quests)->toHaveCount(2) ->and($quests[0]->getId())->toBe('breakfast-duty') @@ -52,11 +66,11 @@ function scratchQuestProject(): string it('tolerates a missing quests.php and creates it on first save', function () { $root = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-quests-empty-' . uniqid()); mkdir($root, 0777, true); - $database = ProjectQuestDatabase::fromProject($root); + $database = questRecords($root); - expect($database->getQuests())->toBe([]); + expect($database->getRecords())->toBe([]); - $index = $database->addQuest(); + $index = $database->addRecord(); $database->setField($index, 'objective0Target', 'Mom'); $database->save(); @@ -71,16 +85,16 @@ function scratchQuestProject(): string it('round-trips the example file shape losslessly through save', function () { $root = scratchQuestProject(); - $original = require $root . '/assets/Data/quests.php'; + $original = (string) file_get_contents($root . '/assets/Data/quests.php'); - $database = ProjectQuestDatabase::fromProject($root); + $database = questRecords($root); + $database->setField(0, 'giver', 'Grandma'); + $database->setField(0, 'giver', 'Mom'); $database->save(); - $saved = require $root . '/assets/Data/quests.php'; + expect((string) file_get_contents($root . '/assets/Data/quests.php'))->toBe($original); - expect($saved)->toBe($original); - - foreach ($saved as $entry) { + foreach (require $root . '/assets/Data/quests.php' as $entry) { expect(Quest::fromArray($entry))->toBeInstanceOf(Quest::class); } }); @@ -101,35 +115,33 @@ function scratchQuestProject(): string file_put_contents($path, $header . 'return ' . var_export($quests, true) . ";\n"); $original = require $path; - $database = ProjectQuestDatabase::fromProject($root); + $database = questRecords($root); + $database->setField(1, 'giver', 'Noticeboard'); $database->save(); $savedSource = (string) file_get_contents($path); $saved = require $path; - $reloaded = ProjectQuestDatabase::fromProject($root); + $original[1]['giver'] = 'Noticeboard'; expect($saved)->toBe($original) ->and($savedSource)->toStartWith($header) - ->and($savedSource)->toContain('// Production quest definitions. Keep this file-level header.') - ->and($saved[0]['productionMetadata'])->toBe(['owner' => 'narrative', 'revision' => 3]) - ->and(array_map( - static fn(\Ichiloto\Editor\ProjectQuest $quest): array => $quest->toArray(), - $reloaded->getQuests(), - ))->toBe($original); + ->and($saved[0]['productionMetadata'])->toBe(['owner' => 'narrative', 'revision' => 3]); }); it('builds the quest settings fields with the five objective types', function () { - // Regression guard mirroring the AnimationTargetPosition test: the field - // builder must resolve QuestObjectiveType and flatten the objectives. $editor = questsEditor(); $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); $labels = array_column($fields, 'label'); - $typeField = $fields[array_search('Obj 1 Type', $labels, true)]; + $typeField = $fields[array_search('Objective 1 Type', $labels, true)]; - expect($labels)->toContain('Id', 'Name', 'Description', 'Giver', 'Reward Gold', 'Reward EXP', 'Prereqs', 'Obj 1 Type', 'Obj 1 Target', 'Obj 1 Qty', 'Obj 1 Text', 'Obj 1 Revealed', 'Obj 1 Reveal When', 'Obj 2 Type') + expect($labels)->toContain('Id', 'Name', 'Description', 'Giver', 'Optional', 'Reward Gold', 'Reward EXP', 'Prereqs', + 'Objective 1 Type', 'Objective 1 Target', 'Objective 1 Quantity', 'Objective 1 Text', 'Objective 1 Revealed', + 'Objective 1 Reveal When', 'Objective 2 Type', 'Reward Items') ->and($typeField['options'])->toBe(['talk_to', 'collect', 'defeat', 'reach_map', 'flag']) - ->and($typeField['value'])->toBe('reach_map'); + ->and($typeField['value'])->toBe('reach_map') + // The target is picked from what the type asks for: a map to reach. + ->and($fields[array_search('Objective 1 Target', $labels, true)]['reference'] ?? null)->toBe('maps'); }); it('edits, saves, and reloads a quest through the editor', function () { @@ -139,37 +151,42 @@ function scratchQuestProject(): string setEditorProperty($editor, 'lastTerminalSize', ['width' => 120, 'height' => 40]); setEditorProperty($editor, 'databaseCategoryIndex', DatabaseCatalog::indexOf('quests')); - $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $labels = array_column($fields, 'label'); + $field = static function (string $label) use ($editor): array { + $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); + + return $fields[array_search($label, array_column($fields, 'label'), true)]; + }; - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Name', $labels, true)], 'Morning Errand'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Reward Gold', $labels, true)], '350'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Obj 1 Target', $labels, true)], 'happyville/plaza'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Obj 1 Revealed', $labels, true)], 'Return to the east gate'); - callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields[array_search('Obj 1 Reveal When', $labels, true)], 'event:east_gate_identified'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Name'), 'Morning Errand'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Reward Gold'), '350'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Objective 1 Target'), 'happyville/plaza'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Objective 1 Revealed'), 'Return to the east gate'); + callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $field('Objective 1 Reveal When'), 'event:east_gate_identified'); /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); + $database = $workspace->getRecordDatabase('quests'); - expect($workspace->questDatabase->isDirty())->toBeTrue() + expect($database->isDirty())->toBeTrue() ->and(callEditorMethod($editor, 'isDatabaseCategoryDirty', 'quests'))->toBeTrue(); - $workspace->questDatabase->save(); + $database->save(); - expect($workspace->questDatabase->isDirty())->toBeFalse(); + expect($database->isDirty())->toBeFalse(); - $reloaded = ProjectQuestDatabase::fromProject($root); - $quest = $reloaded->getQuestByIndex(0); + $quest = questViews(questRecords($root))[0]; expect($quest->getName())->toBe('Morning Errand') + // Something points at breakfast-duty, so its id stayed. + ->and($quest->getId())->toBe('breakfast-duty') ->and($quest->getRewardGold())->toBe(350) ->and($quest->getObjectives()[0]['target'])->toBe('happyville/plaza') ->and($quest->getObjectives()[0]['description'])->toBe('Visit the Happyville town center') ->and($quest->getObjectives()[0]['revealedDescription'])->toBe('Return to the east gate') ->and($quest->getObjectives()[0]['revealConditions'])->toBe([['type' => 'event', 'name' => 'east_gate_identified']]); - foreach ($reloaded->getQuests() as $entry) { - expect(Quest::fromArray($entry->toArray()))->toBeInstanceOf(Quest::class); + foreach (require $root . '/assets/Data/quests.php' as $entry) { + expect(Quest::fromArray($entry))->toBeInstanceOf(Quest::class); } }); @@ -183,23 +200,23 @@ function scratchQuestProject(): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $quest = $workspace->questDatabase->getQuestByIndex(0); + $record = $workspace->getRecordDatabase('quests')->getRecordByIndex(0); - expect($quest->getGiver())->toBe('Grandma'); + expect($record->get('giver'))->toBe('Grandma'); // Move the selection elsewhere: undo must still hit quest 0. - setEditorProperty($editor, 'databaseSelectedQuestIndex', 1); + setEditorProperty($editor, 'databaseSelectedRecordIndexes', ['quests' => 1]); /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); $history->undo(); - expect($quest->getGiver())->toBe('Mom') - ->and(getEditorProperty($editor, 'databaseSelectedQuestIndex'))->toBe(1); + expect($record->get('giver'))->toBe('Mom') + ->and(getEditorProperty($editor, 'databaseSelectedRecordIndexes')['quests'])->toBe(1); $history->redo(); - expect($quest->getGiver())->toBe('Grandma'); + expect($record->get('giver'))->toBe('Grandma'); }); it('adds and removes objectives with undo support', function () { @@ -207,57 +224,53 @@ function scratchQuestProject(): string /** @var ProjectWorkspace $workspace */ $workspace = getEditorProperty($editor, 'workspace'); - $quest = $workspace->questDatabase->getQuestByIndex(0); + $database = $workspace->getRecordDatabase('quests'); + $objectives = static fn(): array => $database->getSubItems(0); - expect($quest->getObjectives())->toHaveCount(2); + expect($objectives())->toHaveCount(2); // The handlers repaint the Database panes; swallow the ANSI output. ob_start(); - callEditorMethod($editor, 'addDatabaseQuestObjective'); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); - expect($quest->getObjectives())->toHaveCount(3) - ->and($quest->getObjectives()[2]['type'])->toBe('talk_to'); + expect($objectives())->toHaveCount(3) + ->and($objectives()[2]['type'])->toBe('talk_to'); /** @var CommandHistory $history */ $history = getEditorProperty($editor, 'history'); $history->undo(); - expect($quest->getObjectives())->toHaveCount(2); + expect($objectives())->toHaveCount(2); $history->redo(); - expect($quest->getObjectives())->toHaveCount(3); + expect($objectives())->toHaveCount(3); - callEditorMethod($editor, 'removeDatabaseQuestObjective'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); ob_end_clean(); - expect($quest->getObjectives())->toHaveCount(2); + expect($objectives())->toHaveCount(2); $history->undo(); - expect($quest->getObjectives())->toHaveCount(3); + expect($objectives())->toHaveCount(3); }); it('round-trips prerequisites through the one-line editable form', function () { - $editor = questsEditor(); - setEditorProperty($editor, 'databaseSelectedQuestIndex', 1); - - /** @var ProjectWorkspace $workspace */ - $workspace = getEditorProperty($editor, 'workspace'); - $quest = $workspace->questDatabase->getQuestByIndex(1); + $database = questRecords(fixturePath('sample-project')); - expect($quest->getPrerequisitesString())->toBe('quest:breakfast-duty:completed'); + expect(questViews($database)[1]->getPrerequisitesString())->toBe('quest:breakfast-duty:completed'); - $quest->setField('prerequisites', 'quest:breakfast-duty:active; !switch:dark-mode; item:Rusty Key:2'); + $database->setField(1, 'prerequisites', 'quest:breakfast-duty:active; !switch:dark-mode; item:Rusty Key:2'); - expect($quest->getPrerequisites())->toBe([ + expect(questViews($database)[1]->getPrerequisites())->toBe([ ['type' => 'quest', 'name' => 'breakfast-duty', 'status' => 'active'], ['type' => 'switch', 'name' => 'dark-mode', 'negate' => true], ['type' => 'item', 'name' => 'Rusty Key', 'quantity' => 2], ]); - $quest->setField('prerequisites', ''); + $database->setField(1, 'prerequisites', ''); - expect($quest->getPrerequisites())->toBe([]) - ->and(array_key_exists('prerequisites', $quest->toArray()))->toBeFalse(); + expect(questViews($database)[1]->getPrerequisites())->toBe([]) + ->and(array_key_exists('prerequisites', questViews($database)[1]->toArray()))->toBeFalse(); }); diff --git a/tests/Unit/QuestIdentityTest.php b/tests/Unit/QuestIdentityTest.php index f30dc83f..344061f8 100644 --- a/tests/Unit/QuestIdentityTest.php +++ b/tests/Unit/QuestIdentityTest.php @@ -4,7 +4,7 @@ use Ichiloto\Editor\Database\QuestReferences; use Ichiloto\Editor\Database\Slug; -use Ichiloto\Editor\ProjectQuestDatabase; +use Ichiloto\Editor\Database\RecordAuthoring; use Ichiloto\Editor\ProjectWorkspace; it('makes an id out of a name', function () { @@ -23,43 +23,54 @@ }); it('gives a new quest an id from its name', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); + $first = $database->addRecord(); + $second = $database->addRecord(); + $database->setField($first, 'name', 'Feed The Cat'); + $database->setField($second, 'name', 'Breakfast Duty'); - expect($database->getQuestByIndex($database->addQuest('Feed The Cat'))->getId())->toBe('feed-the-cat') + expect($database->getRecordByIndex($first)->get('id'))->toBe('feed-the-cat') // The fixture already has a breakfast-duty, which is the collision // the numbering is for. - ->and($database->getQuestByIndex($database->addQuest('Breakfast Duty'))->getId())->toBe('breakfast-duty-2'); + ->and($database->getRecordByIndex($second)->get('id'))->toBe('breakfast-duty-2'); }); it('keeps the id in step with the name while nothing points at it', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); - $index = $database->addQuest('New Quest'); + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); + $index = $database->addRecord(); - $id = $database->renameQuest($index, 'Feed The Cat', mayChangeId: true); + $change = new RecordAuthoring()->applyField($database, $index, [], 'name', 'Feed The Cat', 'Name'); - expect($id)->toBe('feed-the-cat') - ->and($database->getQuestByIndex($index)->getName())->toBe('Feed The Cat'); + expect($database->getRecordByIndex($index)->get('id'))->toBe('feed-the-cat') + ->and($database->getRecordByIndex($index)->get('name'))->toBe('Feed The Cat') + ->and($change->note)->toBe('Its id is now feed-the-cat.'); + + $change->command->undo(); + + expect($database->getRecordByIndex($index)->get('id'))->toBe('new-quest'); }); it('holds the id still once something points at it', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); - $index = $database->addQuest('New Quest'); + // The fixture's skit waits on breakfast-duty. + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); - $id = $database->renameQuest($index, 'Feed The Cat', mayChangeId: false); + $change = new RecordAuthoring()->applyField($database, 0, [], 'name', 'Feed The Cat', 'Name'); // Renaming does not rewrite the triggers and conditions that name it, so // the id is what stays put. - expect($id)->toBeNull() - ->and($database->getQuestByIndex($index)->getId())->toBe('new-quest') - ->and($database->getQuestByIndex($index)->getName())->toBe('Feed The Cat'); + expect($database->getRecordByIndex(0)->get('id'))->toBe('breakfast-duty') + ->and($database->getRecordByIndex(0)->get('name'))->toBe('Feed The Cat') + ->and($change->note)->toBe('Its id stays breakfast-duty, which other things point at.'); }); it('numbers a renamed quest away from one that has the name already', function () { - $database = ProjectQuestDatabase::fromProject(makeTemporaryProject()); - $database->addQuest('Feed The Cat'); - $second = $database->addQuest('New Quest'); + $database = ProjectWorkspace::fromProject(makeTemporaryProject())->getRecordDatabase('quests'); + $first = $database->addRecord(); + $second = $database->addRecord(); + $database->setField($first, 'name', 'Feed The Cat'); + $database->setField($second, 'name', 'Feed The Cat'); - expect($database->renameQuest($second, 'Feed The Cat', mayChangeId: true))->toBe('feed-the-cat-2'); + expect($database->getRecordByIndex($second)->get('id'))->toBe('feed-the-cat-2'); }); it('finds what points at a quest', function () { diff --git a/tests/Unit/QuestRewardItemsTest.php b/tests/Unit/QuestRewardItemsTest.php index b34291d4..76afd0fc 100644 --- a/tests/Unit/QuestRewardItemsTest.php +++ b/tests/Unit/QuestRewardItemsTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use Ichiloto\Editor\ProjectQuestDatabase; use Ichiloto\Editor\ProjectWorkspace; /** @@ -40,13 +39,21 @@ function selectQuestField(object $editor, string $fieldId): void throw new RuntimeException("No field {$fieldId} in the settings pane."); } -it('shows each reward item as a picked row, not a line to type', function () { - $root = makeTemporaryProject(); - $database = ProjectQuestDatabase::fromProject($root); - $database->getQuestByIndex(0)->setRewardItems(['S-Potion', 'Wooden Sword']); - $database->save(); +/** The open quest's reward items, as the record holds them. */ +function questRewardItems(object $editor): mixed +{ + return getEditorProperty($editor, 'workspace')->getRecordDatabase('quests')->getRecordByIndex(0)->get('rewards.items'); +} - $editor = editorOnQuests($root); +/** Gives the first quest reward items, authored as bare names. */ +function giveQuestRewards(object $editor, array $items): void +{ + getEditorProperty($editor, 'workspace')->getRecordDatabase('quests')->getRecordByIndex(0)->set('rewards.items', $items); +} + +it('shows each reward item as a picked row, not a line to type', function () { + $editor = editorOnQuests(makeTemporaryProject()); + giveQuestRewards($editor, ['S-Potion', 'Wooden Sword']); $byField = []; foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $field) { @@ -55,80 +62,105 @@ function selectQuestField(object $editor, string $fieldId): void // Names resolve through the engine's item store, so a misspelling is a // reward that silently never arrives. Chosen, never spelled. - expect($byField['rewardItem0']['reference'] ?? null)->toBe('inventory') - ->and($byField['rewardItem0']['value'] ?? null)->toBe('S-Potion') - ->and($byField['rewardItem1']['value'] ?? null)->toBe('Wooden Sword') - ->and($byField['rewardItem0'] ?? [])->not->toHaveKey('control') - ->and($byField)->not->toHaveKey('rewardItems'); + expect($byField['reward0Item']['reference'] ?? null)->toBe('inventory') + ->and($byField['reward0Item']['value'] ?? null)->toBe('S-Potion') + ->and($byField['reward1Item']['value'] ?? null)->toBe('Wooden Sword') + ->and($byField['reward0Item'] ?? [])->not->toHaveKey('control') + ->and($byField['rewardList']['value'] ?? null)->toBe('2'); }); -it('adds a reward slot from the reward rows and removes it again', function () { - $root = makeTemporaryProject(); - $editor = editorOnQuests($root); - - // Adding the first slot works from the Reward Gold row, since there is - // no reward row to stand on yet. - selectQuestField($editor, 'rewardGold'); - callEditorMethod($editor, 'addDatabaseQuestObjective'); +it('adds a reward item from the reward heading and removes it again', function () { + $editor = editorOnQuests(makeTemporaryProject()); - $quest = getEditorProperty($editor, 'workspace')->questDatabase->getQuestByIndex(0); + // The heading is where the first one is added, as there is no reward + // row to stand on yet. + selectQuestField($editor, 'rewardList'); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); - expect($quest->getRewardItems())->toHaveCount(1); + expect(questRewardItems($editor))->toHaveCount(1); - selectQuestField($editor, 'rewardItem0'); - callEditorMethod($editor, 'removeDatabaseQuestObjective'); + selectQuestField($editor, 'reward0Item'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); - expect($quest->getRewardItems())->toBe([]); + expect(questRewardItems($editor))->toBeNull(); }); it('still adds an objective when the cursor is not on a reward row', function () { - $root = makeTemporaryProject(); - $editor = editorOnQuests($root); - $quest = getEditorProperty($editor, 'workspace')->questDatabase->getQuestByIndex(0); - $objectivesBefore = count($quest->getObjectives()); + $editor = editorOnQuests(makeTemporaryProject()); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('quests'); + $objectivesBefore = count($database->getSubItems(0)); selectQuestField($editor, 'name'); - callEditorMethod($editor, 'addDatabaseQuestObjective'); + callEditorMethod($editor, 'addDatabaseRecordSubItem'); - expect(count($quest->getObjectives()))->toBe($objectivesBefore + 1) - ->and($quest->getRewardItems())->toBe([]); + expect(count($database->getSubItems(0)))->toBe($objectivesBefore + 1) + ->and(questRewardItems($editor))->toBeNull(); }); it('puts back what undo removed, in the same slot', function () { - $root = makeTemporaryProject(); - $editor = editorOnQuests($root); - $database = getEditorProperty($editor, 'workspace')->questDatabase; - $database->getQuestByIndex(0)->setRewardItems(['S-Potion', 'Wooden Sword', 'S-Mana']); + $editor = editorOnQuests(makeTemporaryProject()); + giveQuestRewards($editor, ['S-Potion', 'Wooden Sword', 'S-Mana']); - selectQuestField($editor, 'rewardItem1'); - callEditorMethod($editor, 'removeDatabaseQuestObjective'); + selectQuestField($editor, 'reward1Item'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); - expect($database->getQuestByIndex(0)->getRewardItems())->toBe(['S-Potion', 'S-Mana']); + expect(questRewardItems($editor))->toBe(['S-Potion', 'S-Mana']); callEditorMethod($editor, 'performUndo'); - expect($database->getQuestByIndex(0)->getRewardItems())->toBe(['S-Potion', 'Wooden Sword', 'S-Mana']); + expect(questRewardItems($editor))->toBe(['S-Potion', 'Wooden Sword', 'S-Mana']); }); -it('round-trips picked rewards through the saved file', function () { +it('round-trips picked rewards through the saved file, a bare name until given a quantity', function () { $root = makeTemporaryProject(); - $database = ProjectQuestDatabase::fromProject($root); - $quest = $database->getQuestByIndex(0); - $quest->addRewardItem(); - $quest->setField('rewardItem0', 'Wooden Sword'); + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('quests'); + $first = $database->addSubItem(0, listKey: 'rewards.items'); + $database->setField(0, 'reward' . $first . 'Item', 'Wooden Sword'); + $second = $database->addSubItem(0, listKey: 'rewards.items'); + $database->setField(0, 'reward' . $second . 'Item', 'S-Potion'); + $database->setField(0, 'reward' . $second . 'Quantity', '3'); $database->save(); - $reloaded = ProjectQuestDatabase::fromProject($root); - - expect($reloaded->getQuestByIndex(0)->getRewardItems())->toBe(['Wooden Sword']); + expect((require $root . '/assets/Data/quests.php')[0]['rewards']['items']) + ->toBe(['Wooden Sword', ['item' => 'S-Potion', 'quantity' => 3]]); }); it('drops the items key entirely when the last reward goes', function () { - $quest = ProjectQuestDatabase::fromProject(makeTemporaryProject())->getQuestByIndex(0); - $quest->setRewardItems(['S-Potion']); - $quest->removeRewardItem(0); + $editor = editorOnQuests(makeTemporaryProject()); + giveQuestRewards($editor, ['S-Potion']); + + selectQuestField($editor, 'reward0Item'); + callEditorMethod($editor, 'removeDatabaseRecordSubItem'); // An empty list would read as "rewards nothing, explicitly"; absence is // what the authored files use. - expect($quest->getRewards())->not->toHaveKey('items'); + expect(getEditorProperty($editor, 'workspace')->getRecordDatabase('quests')->getRecordByIndex(0)->get('rewards')) + ->not->toHaveKey('items'); +}); + +it('lets the GUI add to a list beside the record\'s own from its heading, and rename with a note', function () { + $session = \Ichiloto\Editor\Session\EditorSession::open(makeTemporaryProject()); + $rows = $session->readDatabaseRecord('quests', 0)['rows']; + $heading = array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === 'rewardList'); + + expect($heading)->toMatchArray(['label' => 'Reward Items', 'value' => '0', 'item' => true, 'listHeading' => true, 'childNoun' => 'reward item']); + + $session->addDatabaseItem('quests', 0, $heading['key'], true); + $rows = $session->readDatabaseRecord('quests', 0)['rows']; + $item = array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === 'reward0Item'); + + expect($item)->toMatchArray(['item' => true, 'itemNoun' => 'reward item', 'reference' => 'inventory']) + ->and($item)->not->toHaveKey('listHeading'); + + $name = array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === 'name'); + + expect($session->applyDatabaseRecord('quests', 0, $name['key'], 'Morning Errand')['note'] ?? null) + ->toBe('Its id stays breakfast-duty, which other things point at.'); +}); + +it('names the record\'s own list for an add with no row, not the list beside it', function () { + $session = \Ichiloto\Editor\Session\EditorSession::open(makeTemporaryProject()); + + expect($session->readDatabaseRecord('quests', 0)['listNoun'])->toBe('objective') + ->and($session->readDatabaseRecord('states', 0)['listNoun'] ?? null)->toBeNull(); }); diff --git a/tests/Unit/QuestWaymarkAuthoringTest.php b/tests/Unit/QuestWaymarkAuthoringTest.php new file mode 100644 index 00000000..f7c61617 --- /dev/null +++ b/tests/Unit/QuestWaymarkAuthoringTest.php @@ -0,0 +1,177 @@ +readDatabaseRecord('quests', 0)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === $field); +} + +/** Adds a waymark to the first objective and picks its map. */ +function addWaymarkOnTestMap(EditorSession $session): void +{ + $session->addDatabaseItem('quests', 0, waymarkRow($session, 'objective0Type')['key'], true); + $session->applyDatabaseRecord('quests', 0, waymarkRow($session, 'objective0Waymark0Map')['key'], 'test-map'); +} + +it('authors a waymark as a picked map and a subject chosen from that map, saved in the Engine shape', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + addWaymarkOnTestMap($session); + $event = waymarkRow($session, 'objective0Waymark0Event'); + + expect($event)->toMatchArray(['kind' => 'reference', 'reference' => 'event_markers', 'referenceMap' => 'test-map']) + ->and($session->listReferences($event['referenceMap'], 'event_markers'))->toBe([['value' => 'E', 'label' => 'E · Chest']]); + + $session->applyDatabaseRecord('quests', 0, $event['key'], 'E'); + $session->saveDatabase('quests'); + $quests = require $root . '/assets/Data/quests.php'; + + expect($quests[0]['objectives'][0]['waymarks'])->toBe([['map' => 'test-map', 'event' => 'E']]) + ->and(Quest::fromArray($quests[0])->objectives[0]->waymarks[0]->subjectId)->toBe('E'); +}); + +it('keeps one subject per waymark: picking another kind replaces the first', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + addWaymarkOnTestMap($session); + $session->applyDatabaseRecord('quests', 0, waymarkRow($session, 'objective0Waymark0Event')['key'], 'E'); + $session->applyDatabaseRecord('quests', 0, waymarkRow($session, 'objective0Waymark0Landmark')['key'], 'gate'); + $session->saveDatabase('quests'); + $quests = require $root . '/assets/Data/quests.php'; + + expect($quests[0]['objectives'][0]['waymarks'][0])->toBe(['map' => 'test-map', 'landmark' => 'gate']); +}); + +it('asks for the map before offering a waymark subject in the terminal editor', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + $session->addDatabaseItem('quests', 0, waymarkRow($session, 'objective0Type')['key'], true); + $session->saveDatabase('quests'); + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); + callEditorMethod($editor, 'openDatabaseAtCategory', 'quests'); + $field = array_find(callEditorMethod($editor, 'getDatabaseSettingsFields'), + static fn(array $row): bool => ($row['field'] ?? null) === 'objective0Waymark0Event'); + + expect($field['referenceMap'])->toBe('') + ->and(callEditorMethod($editor, 'openReferencePicker', $field))->toBeTrue() + ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Choose a map for this entry first'); +}); + +it('reports waymarks that name a missing map, a subject the map lacks, or a malformed shape', function () { + $root = makeTemporaryProject(); + $file = $root . '/assets/Data/quests.php'; + $quests = require $file; + $quests[0]['objectives'][0]['waymarks'] = [ + ['map' => 'no-such-map'], + ['map' => 'test-map', 'event' => 'Z'], + ['map' => 'test-map', 'event' => 'E', 'npc' => 'twice'], + ['map' => 'test-map', 'event' => 'E', 'conditions' => [['type' => 'event', 'name' => 'chest_seen', 'negate' => true]]], + ]; + file_put_contents($file, ' $issue->message, + array_filter((new ProjectValidator())->validate(ProjectWorkspace::fromProject($root)), + static fn($issue): bool => str_contains($issue->where, 'waymark'))); + + expect($messages)->toHaveCount(3) + ->and(implode("\n", $messages))->toContain('no-such-map')->toContain('event "Z"')->toContain('one subject at most'); +}); + +it('authors the game waymark style in field.php with the Engine defaults and refusals', function () { + $root = makeTemporaryProject(); + mkdir($root . '/assets/Data/Presentation', 0777, true); + file_put_contents($root . '/assets/Data/Presentation/field.php', " [],\n];\n"); + $session = EditorSession::open($root); + $records = $session->listDatabaseRecords('field_cues')['records']; + $symbol = array_search('waymarks.symbol', $records, true); + $value = array_find($session->readDatabaseRecord('field_cues', $symbol)['rows'], + static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value'); + + expect(array_values(array_filter($records, static fn(string $path): bool => str_starts_with($path, 'waymarks.')))) + ->toBe(['waymarks.color', 'waymarks.symbol', 'waymarks.inset'])->and($value['value'])->toBe('!'); + expect(fn() => $session->applyDatabaseRecord('field_cues', $symbol, $value['key'], '!!')) + ->toThrow('The waymark symbol must occupy exactly one terminal cell.'); + + $session->applyDatabaseRecord('field_cues', $symbol, $value['key'], '*'); + $session->saveDatabase('field_cues'); + $source = file_get_contents($root . '/assets/Data/Presentation/field.php'); + + expect($source)->toContain('// Field bindings.') + ->and((require $root . '/assets/Data/Presentation/field.php')['waymarks'])->toBe(['symbol' => '*']) + ->and(Ichiloto\Engine\Animations\Field\FieldPresentationCatalog::fromArray(require $root . '/assets/Data/Presentation/field.php', + $root . '/assets')->waymarks->symbol)->toBe('*'); +}); + +it('keeps Shift+O on an objective adding an objective, and adds a waymark from its Waymarks or Start Waymarks heading', function () { + $root = makeTemporaryProject(); + $editor = createEditorForTesting($root); + setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); + callEditorMethod($editor, 'openDatabaseAtCategory', 'quests'); + setEditorProperty($editor, 'databaseFocus', 'database_settings'); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('quests'); + $select = static function (string $id) use ($editor): void { + foreach (callEditorMethod($editor, 'getDatabaseSettingsFields') as $index => $field) { + if (($field['field'] ?? null) === $id) { setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); return; } + } + throw new RuntimeException("No row {$id}."); + }; + + ob_start(); + $select('objective0Type'); + callEditorMethod($editor, 'dispatchInput', 'O'); + $select('objective0WaymarkList'); + callEditorMethod($editor, 'dispatchInput', 'O'); + $select('startWaymarkList'); + callEditorMethod($editor, 'dispatchInput', 'O'); + ob_end_clean(); + $objectives = $database->getSubItems(0); + + expect($objectives)->toHaveCount(3) + ->and($objectives[0]['waymarks'] ?? [])->toHaveCount(1) + ->and($objectives[1]['waymarks'] ?? [])->toBe([]) + ->and($database->getSubItems(0, 'startWaymarks'))->toHaveCount(1); +}); + +it('authors a quest\'s start waymarks from their heading, as a picked map and subject saved in the Engine shape', function () { + $root = makeTemporaryProject(); + $session = EditorSession::open($root); + $heading = waymarkRow($session, 'startWaymarkList'); + + expect($heading)->not->toBeNull(); + $session->addDatabaseItem('quests', 0, $heading['key']); + $session->applyDatabaseRecord('quests', 0, waymarkRow($session, 'startWaymark0Map')['key'], 'test-map'); + $event = waymarkRow($session, 'startWaymark0Event'); + + expect($event)->toMatchArray(['kind' => 'reference', 'reference' => 'event_markers', 'referenceMap' => 'test-map']); + $session->applyDatabaseRecord('quests', 0, $event['key'], 'E'); + $session->saveDatabase('quests'); + $quests = require $root . '/assets/Data/quests.php'; + + expect($quests[0]['startWaymarks'])->toBe([['map' => 'test-map', 'event' => 'E']]) + ->and(Quest::fromArray($quests[0])->startWaymarks[0]->subjectId)->toBe('E') + // The objectives keep their own waymarks. + ->and($quests[0]['objectives'][0]['waymarks'] ?? [])->toBe([]); +}); + +it('reports start waymarks that name a missing map or a subject the map lacks', function () { + $root = makeTemporaryProject(); + $file = $root . '/assets/Data/quests.php'; + $quests = require $file; + $quests[0]['startWaymarks'] = [['map' => 'no-such-map'], ['map' => 'test-map', 'event' => 'Z'], ['map' => 'test-map', 'event' => 'E']]; + file_put_contents($file, 'validate(ProjectWorkspace::fromProject($root)), + static fn($issue): bool => str_contains($issue->where, 'start waymark'))); + + expect(array_map(static fn($issue): string => $issue->where, $issues))->toBe([ + sprintf('quest %s start waymark 1', $quests[0]['id']), sprintf('quest %s start waymark 2', $quests[0]['id'])]) + ->and($issues[0]->message)->toContain('no-such-map') + ->and($issues[1]->message)->toContain('event "Z"'); +}); diff --git a/tests/Unit/RecordAuthoringTest.php b/tests/Unit/RecordAuthoringTest.php new file mode 100644 index 00000000..e77eca45 --- /dev/null +++ b/tests/Unit/RecordAuthoringTest.php @@ -0,0 +1,223 @@ +getEntryLabels()); +} + +/** Undoes and redoes a change's command, checking each lands where it should. */ +function authoringRoundTrip(RecordChange $change, Closure $read, mixed $before, mixed $after): void +{ + expect($change->command)->not->toBeNull() + ->and($read())->toBe($after); + $change->command?->undo(); + expect($read())->toBe($before); + $change->command?->execute(); + expect($read())->toBe($after); +} + +/** A script with a choice first and a movement route second. */ +function authoringScript(ProjectRecordDatabase $events, RecordAuthoring $authoring): void +{ + $authoring->applyField($events, 0, [], 'command0Type', 'choice', 'Command 1 Type'); + $authoring->applyField($events, 0, [], 'command1Type', 'move_route', 'Command 2 Type'); +} + +it('applies a row and undoes it to the exact record, a same-value edit leaving no step', function () { + $states = loadRecordDatabase(makeTemporaryProject(), 'states'); + $authoring = new RecordAuthoring(); + $before = $states->getRecordByIndex(0)?->toArray(); + + $change = $authoring->applyField($states, 0, [], 'name', 'Venom', 'Name'); + + expect($change->command?->label)->toBe('Name edit') + ->and(authoringLabels($states)[0])->toBe('Venom'); + $change->command?->undo(); + expect($states->getRecordByIndex(0)?->toArray())->toBe($before) + ->and($states->isDirty())->toBeFalse(); + $change->command?->execute(); + expect(authoringLabels($states)[0])->toBe('Venom') + ->and($authoring->applyField($states, 0, [], 'name', 'Venom', 'Name')->command)->toBeNull(); +}); + +it('refuses a parameter line or a value its codec cannot read, leaving the record as it was', function () { + $root = makeTemporaryProject(); + file_put_contents($root . '/' . PermanentGrowthCatalog::RELATIVE_PATH, <<<'PHP' + 'growth.synthetic', 'stat' => 'maxHp', 'amount' => 1, 'sourceType' => 'event', 'sourceId' => 'test', 'metadata' => ['label' => 'Synthetic']], + ]; + PHP); + $growth = loadRecordDatabase($root, 'permanent_growth'); + $events = loadRecordDatabase($root, 'common_events'); + $authoring = new RecordAuthoring(); + $growthBefore = $growth->getRecordByIndex(0)?->toArray(); + $eventsBefore = $events->getRecordByIndex(0)?->toArray(); + + expect(fn() => $authoring->applyField($growth, 0, [], 'metadata', 'tier=1, tier=2', 'Metadata')) + ->toThrow(RecordRefusal::class, 'named twice') + ->and(fn() => $authoring->applyField($events, 0, [], 'command3Conditions', 'nonsense', 'Command 4 Conditions')) + ->toThrow(RecordRefusal::class, 'Condition "nonsense" cannot be read') + ->and($growth->getRecordByIndex(0)?->toArray())->toBe($growthBefore) + ->and($events->getRecordByIndex(0)?->toArray())->toBe($eventsBefore) + ->and($growth->isDirty())->toBeFalse() + ->and($events->isDirty())->toBeFalse(); +}); + +it('adds and removes a sub-list entry after the row\'s entry or at the end, each undone exactly', function () { + $troops = loadRecordDatabase(makeTemporaryProject(), 'troops'); + $authoring = new RecordAuthoring(); + $members = static fn(): array => array_column($troops->getRecordByIndex(0)?->getSubList('enemies') ?? [], 'position'); + $before = $members(); + + $after = $authoring->addItem($troops, 0, [], 'member0Enemy'); + expect($after->index)->toBe(1) + ->and($after->command?->label)->toBe('Member add'); + authoringRoundTrip($after, $members, $before, [$before[0], [15, 7], $before[1]]); + $after->command?->undo(); + + $appended = $authoring->addItem($troops, 0, [], null); + expect($appended->index)->toBe(count($before)); + $appended->command?->undo(); + + $removed = $authoring->removeItem($troops, 0, [], 'member1Position1'); + expect($removed->removed)->toMatchArray(['position' => $before[1]]); + authoringRoundTrip($removed, $members, $before, [$before[0]]); + + // The record's own rows belong to no item: nothing to add after, nothing to remove. + expect(fn() => $authoring->addItem($troops, 0, [], 'name'))->toThrow(RecordRefusal::class, 'belongs to no item') + ->and($authoring->removeItem($troops, 0, [], 'name')->command)->toBeNull(); +}); + +it('adds and removes commands inside a frame, and refuses a frame that is gone', function () { + $events = loadRecordDatabase(makeTemporaryProject(), 'common_events'); + $authoring = new RecordAuthoring(); + $then = [3, 'then']; + $types = static fn(): array => array_column($events->getFrameCommands(0, $then) ?? [], 'type'); + $before = $types(); + + $appended = $authoring->addItem($events, 0, $then, null); + expect($appended->index)->toBe(count($before)) + ->and($appended->command?->label)->toBe('Command add'); + authoringRoundTrip($appended, $types, $before, [...$before, 'text']); + + $removed = $authoring->removeItem($events, 0, $then, 'command0Text'); + expect($removed->command?->label)->toBe('Command remove'); + authoringRoundTrip($removed, $types, [...$before, 'text'], ['text']); + + expect(fn() => $authoring->addItem($events, 0, [9, 'then'], null))->toThrow(RecordRefusal::class, 'is no longer there') + ->and(fn() => $authoring->applyField($events, 0, [9, 'then'], 'command0Text', 'x', 'Text')) + ->toThrow(RecordRefusal::class, 'is no longer there'); +}); + +it('locates the item a row belongs to and adds beneath an entry that holds a list', function () { + $events = loadRecordDatabase(makeTemporaryProject(), 'common_events'); + $authoring = new RecordAuthoring(); + authoringScript($events, $authoring); + + $choice = $events->locateItem(0, [], 'command0Prompt'); + $route = $events->locateItem(0, [], 'command1Subject'); + expect([$choice?->kind, $choice?->childKind, $choice?->childNoun])->toBe([RecordItem::ENTRY, RecordItem::OPTION, 'option']) + ->and([$route?->kind, $route?->childKind, $route?->childNoun])->toBe([RecordItem::ENTRY, RecordItem::NESTED, 'route step']) + ->and($events->locateItem(0, [], '__scriptId'))->toBeNull() + ->and(fn() => $authoring->addItem($events, 0, [], 'command2Amount', child: true)) + ->toThrow(RecordRefusal::class, 'holds nothing that can be added beneath it'); + + $options = static fn(): array => array_column($events->getFrameCommands(0, [])[0]['options'] ?? [], 'text'); + $firstOption = $authoring->addItem($events, 0, [], 'command0Prompt', child: true); + expect($firstOption->command?->label)->toBe('Option add'); + authoringRoundTrip($firstOption, $options, [], ['New option']); + $authoring->applyField($events, 0, [], 'command0Option0Text', 'Read it', 'Option 1 Text'); + $authoring->addItem($events, 0, [], 'command0Option0Text'); + expect($options())->toBe(['Read it', 'New option']) + ->and($events->locateItem(0, [], 'command0Option1Then'))->toEqual(new RecordItem(RecordItem::OPTION, [], 0, 1, 'option')); + authoringRoundTrip($authoring->removeItem($events, 0, [], 'command0Option0Text'), $options, ['Read it', 'New option'], ['New option']); + + $steps = static fn(): array => array_column($events->getFrameCommands(0, [])[1]['steps'] ?? [], 'direction'); + $firstStep = $authoring->addItem($events, 0, [], 'command1Subject', child: true); + expect($firstStep->command?->label)->toBe('Route step add'); + authoringRoundTrip($firstStep, $steps, [], ['down']); + $authoring->applyField($events, 0, [], 'command1Step0Direction', 'up', 'Step 1 Direction'); + $authoring->addItem($events, 0, [], 'command1Step0Count'); + expect($steps())->toBe(['up', 'down']) + ->and($events->locateItem(0, [], 'command1Step1Count')?->kind)->toBe(RecordItem::NESTED); + authoringRoundTrip($authoring->removeItem($events, 0, [], 'command1Step0Direction'), $steps, ['up', 'down'], ['down']); +}); + +it('creates, duplicates and deletes records, each one undo step', function () { + $states = loadRecordDatabase(makeTemporaryProject(), 'states'); + $authoring = new RecordAuthoring(); + $labels = static fn(): array => authoringLabels($states); + $before = $labels(); + + $created = $authoring->createRecord($states); + expect($created->index)->toBe(2) + ->and($created->command?->label)->toBe('State create'); + authoringRoundTrip($created, $labels, $before, [...$before, 'New State']); + $created->command?->undo(); + + $copy = $authoring->duplicateRecord($states, 0); + expect($copy->index)->toBe(1); + authoringRoundTrip($copy, $labels, $before, [$before[0], $before[0], $before[1]]); + $copy->command?->undo(); + + $deleted = $authoring->deleteRecord($states, 1); + expect($deleted->index)->toBe(0) + ->and($deleted->command?->label)->toBe(sprintf('Delete state %s', $before[1])); + authoringRoundTrip($deleted, $labels, $before, [$before[0]]); + expect($authoring->deleteRecord($states, 0)->index)->toBeNull(); +}); + +it('moves a record only where its file keeps the order, saying why elsewhere', function () { + $root = makeTemporaryProject(); + $rules = loadRecordDatabase($root, 'battle_entry_rules'); + + foreach (['alpha', 'beta'] as $id) { + $index = $rules->addRecord(); + $rules->setField((int) $index, 'id', $id); + } + + $authoring = new RecordAuthoring(); + $labels = static fn(): array => authoringLabels($rules); + $before = $labels(); + $moved = $authoring->moveRecord($rules, 0, 1); + + expect($moved->index)->toBe(1) + ->and($authoring->moveRecord($rules, 1, 1)->command)->toBeNull(); + authoringRoundTrip($moved, $labels, $before, array_reverse($before)); + + $states = loadRecordDatabase($root, 'states'); + expect(fn() => $authoring->moveRecord($states, 0, 1))->toThrow(RecordRefusal::class, (string) $states->reorderRefusalReason()) + ->and($states->isDirty())->toBeFalse(); +}); + +it('refuses every change to a read-only category with its reason', function () { + $root = makeTemporaryProject(); + $types = loadRecordDatabase($root, makeUnwritableCategory($root)); + $authoring = new RecordAuthoring(); + $reason = sprintf('Read-only: %s.', $types->getReadOnlyReason()); + + expect(fn() => $authoring->applyField($types, 0, [], 'title', 'x', 'Title'))->toThrow(RecordRefusal::class, $reason) + ->and(fn() => $authoring->createRecord($types))->toThrow(RecordRefusal::class, $reason) + ->and(fn() => $authoring->deleteRecord($types, 0))->toThrow(RecordRefusal::class, $reason) + ->and(fn() => $authoring->addItem($types, 0, [], null))->toThrow(RecordRefusal::class, $reason); +}); diff --git a/tests/Unit/RecordCategoryEditorTest.php b/tests/Unit/RecordCategoryEditorTest.php index fc4d76b0..31806ba1 100644 --- a/tests/Unit/RecordCategoryEditorTest.php +++ b/tests/Unit/RecordCategoryEditorTest.php @@ -103,6 +103,8 @@ callEditorMethod($editor, 'dispatchInput', 'O'); expect($database->countSubItems(0))->toBe(3); + expect(getEditorProperty($editor, 'referencePicker')->isOpen())->toBeTrue(); + callEditorMethod($editor, 'dispatchInput', "\x1b"); callEditorMethod($editor, 'dispatchInput', "\x1a"); expect($database->countSubItems(0))->toBe(2); @@ -119,23 +121,23 @@ it('warns instead of editing when the category is read-only', function (): void { $root = makeTemporaryProject(); - $typesPath = $root . '/assets/Data/Types'; + $category = makeUnwritableCategory($root); + $typesPath = $root . '/assets/Data'; $before = md5(implode('', array_map( static fn(string $file): string => (string) file_get_contents($file), - glob($typesPath . '/*.php') ?: [] + glob($typesPath . '/system.php') ?: [] ))); try { $editor = deletionEditor($root); - // Element and weapon types are PHP enum declarations, not data. - openDatabaseCategory($editor, 'types'); + openDatabaseCategory($editor, $category); callEditorMethod($editor, 'saveActiveDatabase'); $after = md5(implode('', array_map( static fn(string $file): string => (string) file_get_contents($file), - glob($typesPath . '/*.php') ?: [] + glob($typesPath . '/system.php') ?: [] ))); expect(getEditorProperty($editor, 'statusMessage'))->toContain('read-only') @@ -147,13 +149,15 @@ it('enrols editable record categories in Save All and leaves read-only ones out', function (): void { $root = makeTemporaryProject(); + // system.php, which System and Types share, written so the editor cannot rewrite it. + makeUnwritableCategory($root); try { $editor = deletionEditor($root); - $saveable = callEditorMethod($editor, 'getSaveableDatabases'); + $saveable = getEditorProperty($editor, 'workspace')->listSaveableDatabases(); - expect(array_keys($saveable))->toContain('States', 'Troops', 'Skits', 'Common Events', 'Terms'); - expect(array_keys($saveable))->not->toContain('Items', 'Weapons', 'Armors', 'Enemies', 'Types', 'Tilesets'); + expect(array_keys($saveable))->toContain('States', 'Troops', 'Skits', 'Common Events', 'Terms', 'Tilesets'); + expect(array_keys($saveable))->not->toContain('Items', 'Weapons', 'Armors', 'Enemies', 'System', 'Types'); foreach (['States', 'Troops', 'Skits'] as $label) { expect($saveable[$label])->toBeInstanceOf(ProjectRecordDatabase::class); @@ -188,7 +192,7 @@ $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('troops'); $database->setField(0, 'name', 'Bat Swarm'); - expect(callEditorMethod($editor, 'getDatabaseBackupPaths', $database)) + expect($database->getBackupPaths()) ->toBe([$root . '/assets/Data/troops.php']); callEditorMethod($editor, 'saveActiveDatabase'); @@ -201,3 +205,74 @@ removeDirectoryRecursively($root); } }); + +it("chooses a state's disposition from the Engine's, and writes nothing while it is the default", function (): void { + $root = makeTemporaryProject(); + + try { + $database = ProjectWorkspace::fromProject($root)->getRecordDatabase('states'); + $field = array_values(array_filter($database->schema->fields, + static fn($field): bool => $field->key === 'disposition'))[0] ?? null; + + expect($field?->options)->toBe(['harmful', 'beneficial', 'neutral']) + ->and($field?->displayDefault)->toBe('harmful'); + + $database->setField(1, 'disposition', 'neutral'); + $database->save(); + $saved = require $root . '/assets/Data/states.php'; + + expect($saved[1]['disposition'])->toBe('neutral') + ->and($saved[0])->not->toHaveKey('disposition') + ->and(\Ichiloto\Engine\Entities\States\State::fromArray($saved[1])->disposition) + ->toBe(\Ichiloto\Engine\Entities\States\StateDisposition::NEUTRAL); + } finally { + removeDirectoryRecursively($root); + } +}); + +it('creates a record through the shared record rules as one undo step', function (): void { + $editor = deletionEditor(makeTemporaryProject()); + openDatabaseCategory($editor, 'states'); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('states'); + $before = $database->getEntryLabels(); + + callEditorMethod($editor, 'createDatabaseRecord'); + // The new record opens its first row for editing; leave it unedited. + setEditorProperty($editor, 'isDatabaseEditing', false); + + expect($database->getEntryLabels())->toHaveCount(count($before) + 1) + ->and(callEditorMethod($editor, 'getSelectedRecordIndex'))->toBe(count($before)); + + callEditorMethod($editor, 'performUndo'); + + expect($database->getEntryLabels())->toBe($before) + ->and($database->isDirty())->toBeFalse(); +}); + +it('reports a parameter line it cannot read and leaves the record and history as they were', function (): void { + $root = makeTemporaryProject(); + file_put_contents($root . '/' . \Ichiloto\Editor\PermanentGrowthCatalog::RELATIVE_PATH, <<<'SOURCE' + 'growth.synthetic', 'stat' => 'maxHp', 'amount' => 1, 'sourceType' => 'event', 'sourceId' => 'test', 'metadata' => ['label' => 'Synthetic']], + ]; + SOURCE); + $editor = deletionEditor($root); + openDatabaseCategory($editor, 'permanent_growth'); + $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('permanent_growth'); + $before = $database->getRecordByIndex(0)?->toArray(); + $rows = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), 'field'); + setEditorProperty($editor, 'databaseSelectedSettingIndex', array_search('metadata', $rows, true)); + setEditorProperty($editor, 'isDatabaseEditing', true); + setEditorProperty($editor, 'databaseEditBuffer', 'tier=1, tier=2'); + getEditorProperty($editor, 'toasts')->clear(); + + callEditorMethod($editor, 'commitDatabaseEdit'); + + // The refusal is what the author reads, not an edit reported as made. + expect(getEditorProperty($editor, 'statusMessage'))->toContain('named twice') + ->and(getEditorProperty($editor, 'statusMessage'))->not->toContain('updated') + ->and($database->getRecordByIndex(0)?->toArray())->toBe($before) + ->and(getEditorProperty($editor, 'history')->canUndo())->toBeFalse(); +}); diff --git a/tests/Unit/RecordDatabaseTest.php b/tests/Unit/RecordDatabaseTest.php index f789daa3..00e26e39 100644 --- a/tests/Unit/RecordDatabaseTest.php +++ b/tests/Unit/RecordDatabaseTest.php @@ -24,7 +24,7 @@ expect(PhpValueExporter::findUnexportableClass(['bad' => $carriesState]))->toBe('stdClass'); }); -it('keeps a data file header verbatim and regenerates only the returned value', function (): void { +it('keeps a data file header verbatim and edits only the returned value', function (): void { $root = makeTemporaryProject(); $path = $root . '/assets/Events/dresser-note.php'; @@ -36,7 +36,6 @@ $contents = (string) file_get_contents($path); expect($contents)->toContain('// A small demo cutscene: reading the note on the dresser.'); - expect($contents)->toContain("return [\n [\n 'type' => 'text',"); expect(require $path)->toBe([['type' => 'text', 'text' => 'Rewritten.']]); removeDirectoryRecursively($root); @@ -123,15 +122,68 @@ } }); -it('refuses to rewrite a file whose data carries comments', function (): void { +it('edits a data file whose data carries comments in its own source, keeping every comment', function (): void { $root = makeTemporaryProject(); $path = $root . '/assets/Data/states.php'; - file_put_contents($path, " 'poison'],\n];\n"); + $source = " 'poison', 'turns' => 3], // and me\n /* and this */ ['id' => 'sleep'],\n];\n"; + file_put_contents($path, $source); $file = PhpDataFile::load($path); + expect($file->isEditable())->toBeTrue(); + + $file->save([['id' => 'poison', 'turns' => 4], ['id' => 'sleep'], ['id' => 'burn']]); + $written = (string) file_get_contents($path); + expect($written)->toContain('// keep me', '// and me', '/* and this */', "'turns' => 4") + ->and(require $path)->toBe([['id' => 'poison', 'turns' => 4], ['id' => 'sleep'], ['id' => 'burn']]); + + // A second save edits from what the first wrote. + $file->save([['id' => 'poison', 'turns' => 4], ['id' => 'sleep']]); + expect(require $path)->toBe([['id' => 'poison', 'turns' => 4], ['id' => 'sleep']]) + ->and((string) file_get_contents($path))->toContain('// keep me', '// and me', '/* and this */'); + + // Another hand changed the file since it was read: the save refuses rather than overwrite it. + file_put_contents($path, (string) file_get_contents($path) . "// outside edit\n"); + expect(fn() => $file->save([['id' => 'sleep']]))->toThrow(RuntimeException::class, 'changed outside the editor') + ->and((string) file_get_contents($path))->toEndWith("// outside edit\n"); + + removeDirectoryRecursively($root); +}); + +it('removes the enum read-only restriction while preserving its literal comments and refusing computed replacements', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/states.php'; + $source = " \\" . Color::class . "::RED],\n];\n"; + file_put_contents($path, $source); + $file = PhpDataFile::load($path); + expect($file->isEditable())->toBeTrue(); + $file->save([['color' => Color::YELLOW]]); + expect(file_get_contents($path))->toBe(str_replace('::RED', '::YELLOW', $source)); + $file->save([['color' => null]]); + expect(require $path)->toBe([['color' => null]]) + ->and(file_get_contents($path))->toContain('// keep me'); + + $computed = str_replace('\\' . Color::class . '::RED', '(static fn() => \\' . Color::class . '::RED)()', $source); + file_put_contents($path, $computed); + $file = PhpDataFile::load($path); + expect($file->payload)->toBe([['color' => Color::RED]]) + ->and(fn() => $file->composeContents([['color' => Color::YELLOW]]))->toThrow(RuntimeException::class) + ->and(file_get_contents($path))->toBe($computed); + removeDirectoryRecursively($root); +}); + +it('still refuses to regenerate commented data that is not an array literal', function (): void { + $root = makeTemporaryProject(); + $path = $root . '/assets/Data/states.php'; - expect($file->isEditable())->toBeFalse(); - expect($file->readOnlyReason)->toContain('comments inside its data'); + foreach ([ + " 'poison']],\n);\n", + ] as $source) { + file_put_contents($path, $source); + $file = PhpDataFile::load($path); + + expect($file->isEditable())->toBeFalse() + ->and($file->readOnlyReason)->toContain('comments inside its data'); + } removeDirectoryRecursively($root); }); @@ -246,7 +298,7 @@ expect($database->countSubItems(1))->toBe(2); $removed = $database->removeSubItem(1, 1); - expect($removed)->toBe(['enemy' => 'Regular Bat', 'position' => [15, 7]]); + expect($removed)->toBe(['enemy' => '', 'position' => [15, 7]]); expect($database->countSubItems(1))->toBe(1); $database->insertSubItem(1, 1, $removed); @@ -255,15 +307,12 @@ removeDirectoryRecursively($root); }); -it('edits object-backed inventory categories by rebuilding the entry', function (): void { +it('edits inventory categories one record file each', function (): void { $root = makeTemporaryProject(); $items = loadRecordDatabase($root, 'items'); $weapons = loadRecordDatabase($root, 'weapons'); - // Entries authored as `new Item(...)` are rebuilt from the arguments they - // were built with, so they can be edited without rewriting the file into - // something else. expect($items->isEditable())->toBeTrue(); expect($items->getEntryLabels())->toBe(['S-Potion', 'Antidote']); expect($weapons->getEntryLabels())->toBe(['Wooden Sword']); @@ -274,17 +323,19 @@ removeDirectoryRecursively($root); }); -it('writes an edited object entry back as the constructor call it was', function (): void { +it('writes an edited inventory record back into its own file', function (): void { $root = makeTemporaryProject(); + $antidote = (string) file_get_contents($root . '/assets/Data/Items/0002-antidote.php'); $database = loadRecordDatabase($root, 'items'); $database->setField(0, 'price', '75'); $database->save(); - $written = (string) file_get_contents($root . '/assets/Data/items.php'); + $written = (string) file_get_contents($root . '/assets/Data/Items/0001-s-potion.php'); $reloaded = loadRecordDatabase($root, 'items'); - expect($written)->toContain('price: 75') + expect($written)->toContain("'price' => 75,") + ->and((string) file_get_contents($root . '/assets/Data/Items/0002-antidote.php'))->toBe($antidote) ->and($reloaded->getRecordByIndex(0)?->get('price'))->toBe(75) // Everything else survives the rewrite. ->and($reloaded->getEntryLabels())->toBe(['S-Potion', 'Antidote']); @@ -294,12 +345,11 @@ it('refuses every write path on a category it cannot rewrite', function (): void { $root = makeTemporaryProject(); - $database = loadRecordDatabase($root, 'types'); + $database = loadRecordDatabase($root, makeUnwritableCategory($root)); - // Element and weapon types are PHP enum declarations, not data. expect($database->isEditable())->toBeFalse(); - $database->setField(0, 'value', 'Tampered'); + $database->setField(0, 'title', 'Tampered'); expect($database->addRecord())->toBeNull(); expect($database->removeRecord(0))->toBeNull(); @@ -313,14 +363,14 @@ $database = loadRecordDatabase($root, 'terms'); expect($database->isEditable())->toBeTrue(); - expect($database->getEntryLabels())->toBe([ - 'vocab.game.new_game', - 'vocab.game.continue', - 'vocab.currency.name', - 'vocab.currency.symbol', - 'messages.file', - 'messages.confirm.quit', - ]); + // The project's own terms in file order, then every other shared term and message the Engine says, offered with its default. + $own = ['vocab.game.new_game', 'vocab.game.continue', 'vocab.currency.name', 'vocab.currency.symbol', 'messages.file', 'messages.confirm.quit']; + $labels = $database->getEntryLabels(); + expect(array_slice($labels, 0, count($own)))->toBe($own) + ->and(array_slice($labels, count($own)))->toEqualCanonicalizing(array_values(array_diff([ + ...array_map(static fn(string $path): string => 'vocab.' . $path, array_keys(Ichiloto\Engine\Localization\Vocabulary::DEFAULTS)), + ...array_map(static fn(string $path): string => 'messages.' . $path, array_keys(Ichiloto\Engine\Localization\EngineMessageTerms::listTerms())), + ], $own))); $database->setField(0, 'value', 'Begin'); $database->setField(2, 'value', 'Zenny'); @@ -350,7 +400,7 @@ removeDirectoryRecursively($root); }); -it('turns terms read-only when the config carries inline comments', function (): void { +it('edits literal terms while preserving inline config comments byte for byte', function (): void { $root = makeTemporaryProject(); $path = $root . '/config.php'; $contents = (string) file_get_contents($path); @@ -358,31 +408,88 @@ $database = loadRecordDatabase($root, 'terms'); - expect($database->isEditable())->toBeFalse(); - expect($database->getReadOnlyReason())->toContain('comments inside its data'); - // Rows still render, just without controls. + expect($database->isEditable())->toBeTrue(); + expect($database->getReadOnlyReason())->toBeNull(); expect($database->getEntryLabels())->not->toBeEmpty(); - - foreach ($database->getSettingsFields(0) as $field) { - expect($field)->not->toHaveKey('control'); - } + $before = file_get_contents($path); + $old = $database->getRecordByIndex(0)->get('value'); + $database->setField(0, 'value', 'New title'); + $database->save(); + expect(file_get_contents($path))->toBe(str_replace(var_export($old, true), "'New title'", $before)); removeDirectoryRecursively($root); }); -it('lists type tables without evaluating them and explains tilesets', function (): void { +it('authors how a state behaves outside battle and refuses a state the Engine could not read', function (): void { $root = makeTemporaryProject(); + $database = loadRecordDatabase($root, 'states'); + $fields = array_column($database->getSettingsFields(0), null, 'field'); - $types = loadRecordDatabase($root, 'types'); - $tilesets = loadRecordDatabase($root, 'tilesets'); + expect($fields)->toHaveKeys(['label', 'fieldTickSteps', 'fieldTickKnocksOut', 'curedByRest']) + ->and($fields['curedByRest']['value'] ?? null)->not->toBe('false'); - expect($types->isEditable())->toBeFalse(); - expect($types->getReadOnlyReason())->toContain('PHP enum declarations'); - expect($types->getEntryLabels())->toBe(['equipment.php']); + $database->setField(0, 'label', 'PSN'); + $database->setField(0, 'fieldTickSteps', '8'); + $database->setField(1, 'curedByRest', 'false'); + $database->save(); + $payload = require $root . '/assets/Data/states.php'; - expect($tilesets->isEditable())->toBeFalse(); - expect($tilesets->getReadOnlyReason())->toContain('no tileset system'); - expect($tilesets->getEntryLabels())->toBe([]); + expect($payload[0])->toMatchArray(['label' => 'PSN', 'fieldTickSteps' => 8]) + ->and($payload[0])->not->toHaveKeys(['fieldTickKnocksOut', 'curedByRest']) + ->and($payload[1]['curedByRest'])->toBeFalse(); - removeDirectoryRecursively($root); + $database->setField(0, 'fieldTickSteps', '-3'); + expect(fn() => $database->save())->toThrow('fieldTickSteps must be a whole number of steps'); + expect((require $root . '/assets/Data/states.php')[0]['fieldTickSteps'])->toBe(8); +}); + +it('authors a protective state and cover, and refuses a protection the Engine could not read', function (): void { + $root = makeTemporaryProject(); + $database = loadRecordDatabase($root, 'states'); + + expect(array_column($database->getSettingsFields(0), 'field'))->toContain('damageTakenRate', 'coversAllies'); + + $database->setField(0, 'damageTakenRate', '0.5'); + $database->setField(1, 'coversAllies', 'true'); + $database->save(); + $payload = require $root . '/assets/Data/states.php'; + + expect($payload[0]['damageTakenRate'])->toBe(0.5) + ->and($payload[1]['coversAllies'])->toBeTrue() + ->and($payload[1])->not->toHaveKey('damageTakenRate'); + + $database->setField(0, 'damageTakenRate', '-1'); + expect(fn() => $database->save())->toThrow('damageTakenRate cannot be negative'); +}); + +it('authors an item cure through the states it removes and keeps value effects apart', function (): void { + $root = makeTemporaryProject(); + $database = loadRecordDatabase($root, 'items'); + $entry = $database->addSubItem(1); + + expect(array_column($database->getSettingsFields(1), 'field'))->toContain('effect' . $entry . 'Value') + ->not->toContain('effect' . $entry . 'StateIds'); + + $database->setField(1, 'effect' . $entry . 'Type', 'remove_state'); + $fields = array_column($database->getSettingsFields(1), 'field'); + + expect($fields)->toContain('effect' . $entry . 'StateIds')->not->toContain('effect' . $entry . 'Value'); + + $database->setField(1, 'effect' . $entry . 'Name', 'Cure Poison'); + $database->setField(1, 'effect' . $entry . 'StateIds', 'poison'); + $database->save(); + $antidote = (require $root . '/assets/Data/Items/0002-antidote.php')['data']; + + expect($antidote['effects'])->toBe([['type' => 'remove_state', 'name' => 'Cure Poison', 'description' => 'Recovers HP', 'stateIds' => ['poison']]]); +}); + +it('writes a new state the way the file writes its others, one field to a line', function (): void { + $root = makeTemporaryProject(); + $database = loadRecordDatabase($root, 'states'); + (new \Ichiloto\Editor\Database\RecordAuthoring())->createRecord($database); + $database->save(); + $lines = file($root . '/assets/Data/states.php', FILE_IGNORE_NEW_LINES) ?: []; + + expect(array_filter($lines, static fn(string $line): bool => str_contains($line, "'id' =>") && str_contains($line, "'name' =>")))->toBe([]) + ->and(array_column(require $root . '/assets/Data/states.php', 'id'))->toContain('new-state'); }); diff --git a/tests/Unit/RecordReorderDurabilityTest.php b/tests/Unit/RecordReorderDurabilityTest.php index d9716ed7..c11e73f8 100644 --- a/tests/Unit/RecordReorderDurabilityTest.php +++ b/tests/Unit/RecordReorderDurabilityTest.php @@ -219,10 +219,12 @@ static function (): void { expect($help)->toContain('[ / ] move the entry') ->and($help)->toContain('Shift+D duplicate the entry'); + // Numbered record files: a copy is numbered last, and the order is + // the files' own, so nothing moves. openDatabaseCategory($editor, 'items'); $help = implode("\n", callEditorMethod($editor, 'getHelpLines')); - expect($help)->not->toContain('move the entry up or down') - ->and($help)->not->toContain('Shift+D duplicate the entry'); + expect($help)->toContain('Shift+D duplicate the entry') + ->and($help)->not->toContain('move the entry'); openDatabaseCategory($editor, 'states'); $help = implode("\n", callEditorMethod($editor, 'getHelpLines')); diff --git a/tests/Unit/ReferencePickerTest.php b/tests/Unit/ReferencePickerTest.php index b9c4593a..b9fea4de 100644 --- a/tests/Unit/ReferencePickerTest.php +++ b/tests/Unit/ReferencePickerTest.php @@ -146,37 +146,3 @@ ->and($memberField)->not->toHaveKey('control'); }); -it('chooses and clears animation frame sounds from project SFX', function () { - $root = makeTemporaryProject(); - mkdir($root . '/assets/Audio/SFX', 0o777, true); - touch($root . '/assets/Audio/SFX/chime.wav'); - $editor = createEditorForTesting($root); - setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root)); - setEditorProperty($editor, 'lastTerminalSize', ['width' => 120, 'height' => 40]); - setEditorProperty($editor, 'isRunning', true); - openDatabaseCategory($editor, 'animations'); - - $fields = callEditorMethod($editor, 'getDatabaseSettingsFields'); - $index = array_search('frameSound', array_column($fields, 'field'), true); - expect($index)->toBeInt(); - $field = $fields[$index]; - expect($field['reference'] ?? null)->toBe('sfx') - ->and($field['allowsNone'] ?? false)->toBeTrue() - ->and($field)->not->toHaveKey('control'); - - setEditorProperty($editor, 'databaseSelectedSettingIndex', $index); - setEditorProperty($editor, 'databaseFocus', 'database_settings'); - callEditorMethod($editor, 'dispatchInput', "\n"); - $picker = getEditorProperty($editor, 'referencePicker'); - expect($picker->matches())->toBe(['(No sound)', 'chime']); - - callEditorMethod($editor, 'dispatchInput', "\033[B"); - callEditorMethod($editor, 'dispatchInput', "\n"); - $animation = getEditorProperty($editor, 'workspace')->animationDatabase->getAnimations()[0]; - expect($animation->getCue(1)?->soundEffect)->toBe('chime'); - - callEditorMethod($editor, 'dispatchInput', "\n"); - callEditorMethod($editor, 'dispatchInput', "\033[A"); - callEditorMethod($editor, 'dispatchInput', "\n"); - expect($animation->getCue(1))->toBeNull(); -}); diff --git a/tests/Unit/RelativePositionAuthoringTest.php b/tests/Unit/RelativePositionAuthoringTest.php new file mode 100644 index 00000000..a31a4868 --- /dev/null +++ b/tests/Unit/RelativePositionAuthoringTest.php @@ -0,0 +1,542 @@ +readDatabaseRecord($category, $index, $frame)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === $entry && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException(sprintf('Missing row %s at %s', $name, json_encode($entry))); +} + +/** A synthetic cinematic on the harbour map whose script is the given commands, with a comment to preserve. */ +function writeRelativeScript(string $root, array $commands): string +{ + $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'; + file_put_contents($path, " 'move_route', 'subject' => 'player', 'secondsPerStep' => 0.2, + 'waypoints' => [['x' => 2], ['y' => 3]]]]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + + $kind = relativeRow($session, $frame, [0, 1], 'Relative To'); + expect($kind['value'])->toBe('(map cell)') + ->and($kind['options'])->toBe(['', ...CinematicCommandSchema::export()['relativePosition']['subjectKinds']]); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $kind['key'], 'npc'); + $id = relativeRow($session, $frame, [0, 1], 'NPC'); + expect($id['reference'])->toBe('map_npcs') + ->and(array_column($session->listReferences(null, 'map_npcs', ['category' => 'cutscenes/cinematic', 'index' => 0]), 'value'))->toBe(['keeper']); + // A subject still waiting for its id is a draft the save completes, never written. + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'requires an id') + ->and(file_get_contents($path))->toBe($original); + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $id['key'], 'keeper'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Offset X')['key'], '1'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Offset Y')['key'], '-2'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $path)[0]['waypoints'])->toBe([['x' => 2], ['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => -2]]]) + ->and(file_get_contents($path))->toContain('// Staging authored by hand.'); + $reopened = EditorSession::open($root); + expect(relativeRow($reopened, $frame, [0, 1], 'NPC')['value'])->toBe('keeper') + ->and(relativeRow($reopened, $frame, [0, 1], 'Offset Y')['value'])->toBe('-2') + ->and(array_filter($reopened->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [0, 1] && in_array($row['name'] ?? '', ['X', 'Y'], true)))->toBe([]); + + // A zero offset is the default: clearing both axes leaves the subject alone, never an empty offset. + foreach (['Offset X', 'Offset Y'] as $axis) { + $reopened->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($reopened, $frame, [0, 1], $axis)['key'], '0'); + } + $reopened->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'][1])->toBe(['at' => ['kind' => 'npc', 'id' => 'keeper']]); + + // Undo returns through every step to the authored absolute route. + foreach (range(1, 2) as $ignored) { $reopened->undo(); } + $reopened->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'][1])->toBe(['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => -2]]); +}); + +it('refuses relative waypoints the Engine would refuse and clears coordinates or ids a subject replaces', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'move_route', 'subject' => 'player', + 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'keeper']], ['x' => 3, 'y' => 1]]]]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Offset X')['key'], '16385')) + ->toThrow(SessionRefusal::class, '-16384..16384') + // Clearing the subject would leave a waypoint with no coordinate; it is refused, not invented. + ->and(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Relative To')['key'], '')) + ->toThrow(SessionRefusal::class, 'x and/or y') + ->and($session->undo()['label'])->toBeNull(); + + // Choosing a subject for an absolute waypoint drops its independent coordinates; the player needs no id. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Relative To')['key'], 'player'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 1], 'Offset Y')['key'], '2'); + // Turning the first point toward the player drops the id the player does not take. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Relative To')['key'], 'player'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'])->toBe([['at' => ['kind' => 'player']], ['at' => ['kind' => 'player'], 'offset' => ['y' => 2]]]); + $session->undo(); + $session->undo(); + $session->undo(); + $session->saveDatabase('cutscenes/cinematic'); + expect(require $path)->toBe(eval('?>' . $original)); +}); + +it('authors a waypoint end facing through the session, one kind at a time', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'move_route', 'subject' => 'player', + 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -2]]]]]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + + expect(relativeRow($session, $frame, [0, 0], 'Facing')['value'])->toBe('(last step)') + ->and(relativeRow($session, $frame, [0, 0], 'Face Toward')['options']) + ->toBe(['', ...CinematicCommandSchema::export()['endFacing']['subjectKinds']]); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Face Toward')['key'], 'npc'); + // A subject still waiting for its id is a draft the save completes, never written. + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(InvalidArgumentException::class, 'requires an id') + ->and(file_get_contents($path))->toBe($original); + $id = relativeRow($session, $frame, [0, 0], 'Face Toward NPC'); + expect($id['reference'])->toBe('map_npcs'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $id['key'], 'keeper'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'][0])->toBe(['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -2], + 'faceToward' => ['kind' => 'npc', 'id' => 'keeper']]) + ->and(file_get_contents($path))->toContain('// Staging authored by hand.'); + + // A fixed facing replaces the subject, and choosing a subject again replaces the facing. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Facing')['key'], 'west'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'][0])->toBe(['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -2], 'facing' => 'west']); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0, 0], 'Face Toward')['key'], 'player'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'][0])->toBe(['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -2], + 'faceToward' => ['kind' => 'player']]); + + foreach (range(1, 4) as $ignored) { $session->undo(); } + $session->saveDatabase('cutscenes/cinematic'); + expect(require $path)->toBe(eval('?>' . $original)); +}); + +it('authors a staged actor end facing and offers none on a bound visual', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 6, 'y' => 3, + 'facing' => 'north']]); + $session = EditorSession::open($root); + $frame = ['commands']; + + expect(relativeRow($session, $frame, [0], 'Facing')['value'])->toBe('north'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Face Toward')['key'], 'npc'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Face Toward NPC')['key'], 'keeper'); + $session->saveDatabase('cutscenes/cinematic'); + expect(require $path)->toBe([['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 6, 'y' => 3, + 'faceToward' => ['kind' => 'npc', 'id' => 'keeper']]]); + + $keys = static fn(array $entry, bool $graphical): array => array_map(static fn($field): string => $field->key, + CutsceneSchemas::stagedActorFields($graphical, $entry)); + expect($keys(['id' => 'a', 'x' => 1, 'y' => 1], false))->toContain('facing', 'faceToward.kind') + ->and($keys(['id' => 'a', 'subject' => ['kind' => 'player']], true))->not->toContain('facing', 'faceToward.kind'); +}); + +it('shows a replacement keeping its current facing and writes a facing only when the author picks one', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 6, 'y' => 3, 'facing' => 'east'], + ['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'replace' => true]]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + + expect(relativeRow($session, $frame, [1], 'Facing')['value'])->toBe('(keep current facing)') + ->and(relativeRow($session, $frame, [0], 'Facing')['value'])->toBe('east'); + // Editing another row of the replacement leaves its facing undeclared. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [1], 'Sprite')['key'], 'H'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[1])->toBe(['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['H'], 'replace' => true]) + ->and(file_get_contents($path))->not->toBe($original); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [1], 'Facing')['key'], 'west'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[1]['facing'])->toBe('west'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [1], 'Facing')['key'], ''); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[1])->not->toHaveKey('facing') + ->and(relativeRow($session, $frame, [1], 'Facing')['value'])->toBe('(keep current facing)'); + // The Terminal editor reads the same rows. + $library = ProjectWorkspace::fromProject($root)->cutscenes ?? throw new RuntimeException('The Terminal workspace has no cutscenes.'); + $terminal = array_find($library->records(CutsceneType::CINEMATIC)->getFrameSettingsFields(0, $frame), + static fn(array $row): bool => ($row['entry'] ?? []) === [1] && ($row['name'] ?? '') === 'Facing'); + expect($terminal['value'] ?? null)->toBe('(keep current facing)'); + + // A new visual still starts facing south, as the Engine stages it. + $labels = static fn(array $entry): array => array_column(array_map(static fn($field): array => get_object_vars($field), + CutsceneSchemas::stagedActorFields(false, $entry)), 'displayDefault', 'key'); + expect($labels(['id' => 'a', 'x' => 1, 'y' => 1])['facing'])->toBe('south') + ->and($labels(['id' => 'a', 'replace' => true])['facing'])->toBe('(keep current facing)'); +}); + +it('offers only the subject kinds each owner can resolve, from the Engine contract', function () { + $exported = CinematicCommandSchema::export()['relativePosition']; + expect($exported['fields'])->toBe(['at', 'offset']) + ->and(RelativePositionFields::getSubjectKinds(cinematic: true, graphical: true))->toBe($exported['subjectKinds']) + ->and(RelativePositionFields::getSubjectKinds(cinematic: false, graphical: true))->not->toContain('staged_actor') + ->and(RelativePositionFields::getSubjectKinds(cinematic: true, graphical: false))->not->toContain('world_object'); + $fields = static fn(array $entry, bool $graphical): array => array_map(static fn($field): string => $field->key, + CutsceneSchemas::stagedActorFields($graphical, $entry)); + $independent = ['id' => 'a', 'at' => ['kind' => 'npc', 'id' => 'keeper']]; + $bound = ['id' => 'a', 'subject' => ['kind' => 'player']]; + expect($fields($independent, false))->toContain('at.kind', 'at.id', 'offset.x', 'offset.y')->not->toContain('x', 'y') + ->and($fields(['id' => 'a', 'x' => 1, 'y' => 1], false))->toContain('at.kind', 'x', 'y')->not->toContain('offset.x') + ->and($fields($bound, true))->not->toContain('at.kind', 'offset.x', 'x', 'y') + ->and($fields($bound, false))->not->toContain('at.kind', 'offset.x'); +}); + +it('previews relative points at their subject on the cinematic start map and refuses placing them by click', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [ + ['type' => 'move_route', 'subject' => 'player', + 'waypoints' => [['x' => 2], ['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => 1]], ['y' => 6]]], + ['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -1, 'y' => 1]], + ['type' => 'stage_actor', 'id' => 'porter', 'sprite' => ['P'], 'x' => 6, 'y' => 3], + ['type' => 'stage_actor', 'id' => 'echo', 'sprite' => ['E'], 'at' => ['kind' => 'player']], + ]); + $original = file_get_contents($path); + $session = EditorSession::open($root); + $frame = ['commands']; + $rows = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows']; + $placements = array_values(array_filter($rows, static fn(array $row): bool => isset($row['mapPlacement']))); + // An absolute staged actor keeps its typed coordinates; only relative points are previewed. + [$route, $guide, $echo] = array_map(static fn(array $row): array => $row['mapPlacement'], $placements); + + expect($route['map'])->toBe('harbour') + ->and(array_column($route['points'], 'point'))->toBe([[2, null], [5, 2], [5, 6]]) + ->and($route['points'][1]['fields'])->toBe([]) + ->and($route['points'][1]['label'])->toContain('+1, +1 from NPC keeper') + ->and($guide)->toMatchArray(['kind' => 'stage', 'map' => 'harbour', 'issue' => null]) + ->and($guide['points'][0]['point'])->toBe([3, 2]) + ->and($echo['points'][0]['point'])->toBe([null, null]) + ->and($echo['points'][0]['issue'])->toContain('only while it runs'); + + $context = ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0]; + $revision = $session->readMap('harbour')['revision']; + expect(fn() => $session->applyMapPlacement($context, $placements[1]['key'], $guide, 'harbour', 0, 7, 3, null, $revision)) + ->toThrow(SessionRefusal::class, 'follows its subject') + ->and(fn() => $session->applyMapPlacement($context, $placements[0]['key'], $route, 'harbour', 1, 7, 3, [1, 1], $revision)) + ->toThrow(SessionRefusal::class, 'follows its subject'); + // Absolute waypoints around them still place as before. + $session->applyMapPlacement($context, $placements[0]['key'], $route, 'harbour', 0, 7, 3, [1, 1], $revision); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['waypoints'])->toBe([['x' => 7], ['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1, 'y' => 1]], ['y' => 6]]) + ->and(array_slice(require $path, 1))->toBe(array_slice(eval('?>' . $original), 1)); +}); + +it('says why a reusable script cannot show its relative points', function () { + $root = makeTemporaryProject('ichiloto-relative-common-'); + file_put_contents($root . '/assets/Events/relative-walk.php', " 'move_route', 'subject' => 'player', + 'waypoints' => [['at' => ['kind' => 'marker', 'id' => 'E'], 'offset' => ['y' => 1]]]]], true) . ";\n"); + $session = EditorSession::open($root); + $index = array_search('relative-walk', $session->listDatabaseRecords('common_events')['records'], true); + $placement = array_find($session->readDatabaseRecord('common_events', $index)['rows'], static fn(array $row): bool => isset($row['mapPlacement']))['mapPlacement']; + expect($placement['points'][0]['point'])->toBe([null, null]) + ->and($placement['points'][0]['issue'])->toContain('whichever map calls it') + ->and($placement['map'] ?? null)->toBeNull(); +}); + +it('authors an independent staged actor and a cast member relative to a subject, refusing incomplete ones at save', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 2, 'facing' => 'north']]); + $dataPath = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php'; + $session = EditorSession::open($root); + $frame = ['commands']; + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Relative To')['key'], 'npc'); + expect(array_column(array_filter($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [0]), 'name'))->not->toContain('X', 'Y'); + $saved = sourceHashTree($root); + expect(fn() => $session->saveDatabase('cutscenes/cinematic'))->toThrow(RuntimeException::class, 'requires an id') + ->and(sourceHashTree($root))->toBe($saved); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'NPC')['key'], 'keeper'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Offset X')['key'], '-1'); + + // The cast is staged the same way: its members are independent actors too. + $cast = static fn(string $name): array => array_find($session->readDatabaseRecord('cutscenes/cinematic', 0)['rows'], + static fn(array $row): bool => ($row['entry'] ?? []) === [1] && ($row['name'] ?? '') === $name); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $cast('Relative To')['key'], 'staged_actor'); + expect($cast('Staged Actor')['reference'])->toBe('cinematic_cast'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $cast('Staged Actor')['key'], 'boat-east'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $cast('Offset Y')['key'], '2'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $path)[0])->toBe(['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'facing' => 'north', + 'at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => -1]]) + ->and(file_get_contents($path))->toContain('// Staging authored by hand.') + ->and((require $dataPath)['cast'][1])->toBe(['kind' => 'staged_actor', 'id' => 'boat-west', 'sprite' => ['~^~'], + 'at' => ['kind' => 'staged_actor', 'id' => 'boat-east'], 'offset' => ['y' => 2]]) + ->and(file_get_contents($dataPath))->toContain('// Harbour Lanterns: an original test cinematic.'); + + // Binding the visual to a real subject drops the position it would inherit, never keeping both. + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Bind Visual To')['key'], 'player'); + $bound = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows']; + expect(array_column(array_filter($bound, static fn(array $row): bool => ($row['entry'] ?? []) === [0]), 'name')) + ->not->toContain('Relative To', 'Offset X', 'NPC'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0])->not->toHaveKeys(['at', 'offset', 'x', 'y']); +}); + +it('authors relative staging in the Terminal editor with its list choices and no world objects', function () { + $root = cutsceneProject(); + $path = writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 2], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 3]]]]); + $library = ProjectWorkspace::fromProject($root)->cutscenes ?? throw new RuntimeException('The Terminal workspace has no cutscenes.'); + $frame = ['commands']; + $field = static fn(array $entry, string $name): array => array_find($library->records(CutsceneType::CINEMATIC)->getFrameSettingsFields(0, $frame), + static fn(array $row): bool => ($row['entry'] ?? []) === $entry && ($row['name'] ?? '') === $name) + ?? throw new RuntimeException('Missing Terminal row ' . $name); + // Every Terminal edit goes through the library's one change path. + $apply = static fn(array $entry, string $name, string $value) => $library->changeAsset(CutsceneType::CINEMATIC, 0, $name, + static fn($records, int $index) => $records->setFrameField($index, $frame, $field($entry, $name)['field'], $value)); + + expect($field([0], 'Relative To')['options'] ?? [])->not->toContain('world_object') + ->and($field([1, 0], 'Relative To')['options'] ?? [])->toContain('marker', 'staged_actor')->not->toContain('world_object'); + $apply([0], 'Relative To', 'party_actor'); + $apply([0], 'Offset Y', '1'); + $apply([1, 0], 'Relative To', 'staged_actor'); + $apply([1, 0], 'Staged Actor', 'guide'); + expect($field([0], 'Party Actor')['value'])->toBe('(leading party actor)'); + $library->save(CutsceneType::CINEMATIC, 'harbour-lanterns'); + + expect((require $path)[0])->toBe(['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'at' => ['kind' => 'party_actor'], 'offset' => ['y' => 1]]) + ->and((require $path)[1]['waypoints'])->toBe([['at' => ['kind' => 'staged_actor', 'id' => 'guide']]]); +}); + +it('leaves relative points to follow their subjects when map lines are inserted, reporting offsets the lines cross', function () { + $root = mapLineProject(); + $directory = $root . '/assets/Cutscenes/Cinematics/arrival'; + mkdir($directory, 0o777, true); + $cat = ['kind' => 'npc', 'id' => 'cat']; + file_put_contents($directory . '/arrival.data.php', " 'arrival', 'name' => 'Arrival', 'startMap' => 'home', + 'cast' => [['id' => 'visitor', 'sprite' => 'v', 'at' => $cat, 'offset' => ['y' => -2]]], + ], true) . ";\n"); + $script = [ + ['type' => 'stage_actor', 'id' => 'extra', 'sprite' => 'e', 'at' => $cat, 'offset' => ['x' => 1]], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => $cat, 'offset' => ['y' => 1]], ['y' => 4]]], + ['type' => 'stage_actor', 'id' => 'echo', 'sprite' => 'o', 'at' => ['kind' => 'player'], 'offset' => ['y' => 1]], + ]; + file_put_contents($directory . '/arrival.script.php', "toContain( + 'assets/Cutscenes/Cinematics/arrival/arrival.data.php cast.0.offset.y', + 'assets/Cutscenes/Cinematics/arrival/arrival.script.php 2.offset.y', + )->and(describeMapLineHandEdits($plan))->not->toContain( + 'assets/Cutscenes/Cinematics/arrival/arrival.script.php 0.offset.x', + 'assets/Cutscenes/Cinematics/arrival/arrival.script.php 1.waypoints.0.offset.y', + ); + $plan->apply(); + + $saved = require $directory . '/arrival.script.php'; + expect((require $directory . '/arrival.data.php')['cast'][0])->toMatchArray(['at' => $cat, 'offset' => ['y' => -2]]) + ->and((require $directory . '/arrival.data.php')['cast'][0])->not->toHaveKeys(['x', 'y']) + ->and($saved[0])->toBe($script[0]) + ->and($saved[1]['waypoints'])->toBe([['at' => $cat, 'offset' => ['y' => 1]], ['y' => 5]]) + ->and($saved[2])->toBe($script[2]) + // The subject itself moved, so the points that follow it moved with it. + ->and((require $root . '/assets/Maps/home/home.data.php')['npcs'][0])->toMatchArray(['x' => 2, 'y' => 4]); +}); + +it('reports relative subjects that are not on the scene map or its stage', function () { + $root = cutsceneProject(); + writeRelativeScript($root, [ + ['type' => 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'at' => ['kind' => 'npc', 'id' => 'stranger']], + ['type' => 'stage_actor', 'id' => 'shadow', 'sprite' => ['S'], 'at' => ['kind' => 'staged_actor', 'id' => 'shadow']], + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'keeper'], 'offset' => ['x' => 1]]]], + ]); + $messages = array_map(static fn($issue): string => $issue->message, validateProject($root)); + + expect($messages)->toContain('It addresses NPC "stranger", which is not on harbour.') + ->and($messages)->toContain('It addresses staged actor "shadow", which neither the cast nor an earlier stage_actor command defines.') + ->and(array_filter($messages, static fn(string $message): bool => str_contains($message, 'keeper')))->toBe([]); +}); + +/** A synthetic map with an event marker, a world object and an NPC whose scripts walk relative to them. */ +function writeRelativeMapProject(): array +{ + $root = makeTemporaryProject('ichiloto-relative-map-'); + $path = $root . '/assets/Maps/test-map/test-map.data.php'; + $data = require $path; + $data['worldObjects'] = [['id' => 'lamp', 'anchor' => ['x' => 3, 'y' => 2], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]]; + $data['events']['S'] = ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => [ + ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [ + ['at' => ['kind' => 'marker', 'id' => 'E'], 'offset' => ['y' => 1]], + ['at' => ['kind' => 'world_object', 'id' => 'lamp'], 'offset' => ['x' => -1]], + ]], + ]]]; + $data['npcs'] = [['id' => 'sage', 'name' => 'Sage', 'sprite' => 's', 'x' => 8, 'y' => 3, + 'script' => [['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2]]]]]]; + file_put_contents($path, "readInspector('test-map', 'S'); + $placement = array_find($read['rows'], static fn(array $row): bool => isset($row['mapPlacement']))['mapPlacement']; + expect($placement['map'])->toBe('test-map') + ->and(array_column($placement['points'], 'point'))->toBe([[5, 2], [2, 2]]); + $offset = array_find($read['rows'], static fn(array $row): bool + => ($row['key']['path'] ?? []) === ['data', 'script', '0', 'waypoints', '1', 'offset', 'y']); + $session->applyInspector('test-map', $read['revision'], $offset['key'], '3'); + + $npc = $session->readNpc('test-map', 0, ['script']); + $kind = array_find($npc['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'Relative To'); + $session->applyNpc('test-map', $npc['revision'], 0, $kind['key'], 'world_object'); + $npc = $session->readNpc('test-map', 0, ['script']); + $object = array_find($npc['rows'], static fn(array $row): bool => ($row['entry'] ?? []) === [0, 0] && ($row['name'] ?? '') === 'World Object'); + expect($object['reference'])->toBe('map_world_objects') + ->and(array_column($session->listReferences('test-map', 'map_world_objects'), 'value'))->toBe(['lamp']); + $session->applyNpc('test-map', $npc['revision'], 0, $object['key'], 'lamp'); + $session->saveMap('test-map'); + + $saved = require $path; + expect($saved['events']['S']['data']['script'][0]['waypoints'][1])->toBe(['at' => ['kind' => 'world_object', 'id' => 'lamp'], 'offset' => ['x' => -1, 'y' => 3]]) + ->and($saved['npcs'][0]['script'][0]['waypoints'])->toBe([['at' => ['kind' => 'world_object', 'id' => 'lamp']]]) + ->and(file_get_contents($path))->toContain('// Map staging by hand.'); + $preview = array_find(EditorSession::open($root)->readNpc('test-map', 0, ['script'])['rows'], static fn(array $row): bool => isset($row['mapPlacement'])); + expect($preview['mapPlacement']['points'][0]['point'])->toBe([3, 2]); +}); + +it('chooses world objects on the map a cinematic starts on, and refuses ids that are not there', function () { + $root = cutsceneProject(); + $mapPath = $root . '/assets/Maps/harbour/harbour.data.php'; + $map = require $mapPath; + $map['worldObjects'] = [['id' => 'beacon', 'anchor' => ['x' => 6, 'y' => 3], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]]; + file_put_contents($mapPath, " 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 1]]); + $session = EditorSession::open($root); + $frame = ['commands']; + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Relative To')['key'], 'world_object'); + $object = relativeRow($session, $frame, [0], 'World Object'); + expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $object['key'], 'elsewhere')) + ->toThrow(SessionRefusal::class, 'Unknown map_world_objects'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $object['key'], 'beacon'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Offset Y')['key'], '1'); + $session->saveDatabase('cutscenes/cinematic'); + + expect((require $path)[0])->toMatchArray(['at' => ['kind' => 'world_object', 'id' => 'beacon'], 'offset' => ['y' => 1]]) + ->and(array_find($session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows'], + static fn(array $row): bool => isset($row['mapPlacement']))['mapPlacement']['points'][0]['point'])->toBe([6, 4]); +}); + +it('places a staged visual on an attachment its world object resource declares, offered by that object alone', function () { + $root = cutsceneProject(); + $mapPath = $root . '/assets/Maps/harbour/harbour.data.php'; + $map = require $mapPath; + $map['worldObjects'] = [['id' => 'relay', 'resource' => 'relay-tower', 'anchor' => ['x' => 6, 'y' => 3]], + ['id' => 'beacon', 'anchor' => ['x' => 2, 'y' => 3], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null]]; + file_put_contents($mapPath, " ['relay-tower' => [ + 'name' => 'Synthetic relay', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/relay.png', 'cells' => ['width' => 3, 'height' => 4]], + 'attachments' => [['id' => 'aerial', 'cell' => ['x' => 1, 'y' => -2], 'point' => ['x' => 0.8, 'y' => 0.05]], + ['id' => 'lamp', 'cell' => ['x' => -1, 'y' => 0], 'point' => ['x' => 0.1, 'y' => 0.9]]], + ]]], true) . ";\n"); + $path = writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'pulse', 'sprite' => ['*'], 'x' => 1, 'y' => 1]]); + $session = EditorSession::open($root); + $frame = ['commands']; + + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Relative To')['key'], 'world_object'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'World Object')['key'], 'beacon'); + // An inline object declares no attachments. + expect(relativeRow($session, $frame, [0], 'Attachment')['options'])->toBe(['']); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'Offset X')['key'], '2'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'World Object')['key'], 'relay'); + $attachment = relativeRow($session, $frame, [0], 'Attachment'); + expect($attachment['options'])->toBe(['', 'aerial', 'lamp']) + ->and(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $attachment['key'], 'chimney')) + ->toThrow(SessionRefusal::class, 'not one this entry offers'); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, $attachment['key'], 'aerial'); + $rows = $session->readDatabaseRecord('cutscenes/cinematic', 0, $frame)['rows']; + // The attachment owns the visual's place: the offset is dropped and no longer offered. + expect(array_filter($rows, static fn(array $row): bool => ($row['entry'] ?? []) === [0] && str_starts_with((string) ($row['name'] ?? ''), 'Offset')))->toBe([]) + ->and(array_find($rows, static fn(array $row): bool => isset($row['mapPlacement']))['mapPlacement']['points'][0]['point'])->toBe([7, 1]); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0])->toBe(['type' => 'stage_actor', 'id' => 'pulse', 'sprite' => ['*'], + 'at' => ['kind' => 'world_object', 'id' => 'relay', 'attachment' => 'aerial']]) + ->and(file_get_contents($path))->toContain('// Staging authored by hand.'); + + // Validation reports a visual whose attachment the resource no longer declares. + expect(array_map(static fn($issue): string => $issue->message, validateProject($root))) + ->not->toContain('It sits on attachment "aerial" of world object "relay", which harbour does not offer.'); + writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'pulse', 'sprite' => ['*'], + 'at' => ['kind' => 'world_object', 'id' => 'relay', 'attachment' => 'chimney']]]); + expect(array_map(static fn($issue): string => $issue->message, validateProject($root))) + ->toContain('It sits on attachment "chimney" of world object "relay", which harbour does not offer.'); + writeRelativeScript($root, [['type' => 'stage_actor', 'id' => 'pulse', 'sprite' => ['*'], + 'at' => ['kind' => 'world_object', 'id' => 'relay', 'attachment' => 'aerial']]]); + + // Choosing another object drops an attachment that belonged to the first. + $session = EditorSession::open($root); + $session->applyDatabaseRecord('cutscenes/cinematic', 0, relativeRow($session, $frame, [0], 'World Object')['key'], 'beacon'); + $session->saveDatabase('cutscenes/cinematic'); + expect((require $path)[0]['at'])->toBe(['kind' => 'world_object', 'id' => 'beacon']); +}); + +it('stages a visual on an attachment in a reusable script, choosing on the map its callers run it on', function () { + $root = cutsceneProject(); + $mapPath = $root . '/assets/Maps/harbour/harbour.data.php'; + $map = require $mapPath; + $map['worldObjects'] = [['id' => 'relay', 'resource' => 'relay-tower', 'anchor' => ['x' => 6, 'y' => 3]]]; + file_put_contents($mapPath, " ['relay-tower' => [ + 'name' => 'Synthetic relay', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/relay.png', 'cells' => ['width' => 3, 'height' => 4]], + 'attachments' => [['id' => 'aerial', 'cell' => ['x' => 1, 'y' => -2], 'point' => ['x' => 0.8, 'y' => 0.05]]], + ]]], true) . ";\n"); + $script = $root . '/assets/Events/relay-staging.php'; + file_put_contents($script, " 'stage_actor', 'id' => 'pulse', 'sprite' => '*', 'x' => 7, 'y' => 1],\n];\n"); + $session = EditorSession::open($root); + $index = array_search('relay-staging', $session->listDatabaseRecords('common_events')['records'], true); + $frame = []; + + // Nothing calls it yet, so it runs on no known map and offers no world object. + $session->applyDatabaseRecord('common_events', $index, relativeRow($session, $frame, [0], 'Relative To', 'common_events', $index)['key'], 'world_object'); + $object = relativeRow($session, $frame, [0], 'World Object', 'common_events', $index); + expect(fn() => $session->applyDatabaseRecord('common_events', $index, $object['key'], 'relay'))->toThrow(SessionRefusal::class); + + // The cinematic starting on the harbour calls it, so it chooses on the harbour. + writeRelativeScript($root, [['type' => 'common_event', 'id' => 'relay-staging']]); + $session = EditorSession::open($root); + $session->applyDatabaseRecord('common_events', $index, relativeRow($session, $frame, [0], 'Relative To', 'common_events', $index)['key'], 'world_object'); + $session->applyDatabaseRecord('common_events', $index, relativeRow($session, $frame, [0], 'World Object', 'common_events', $index)['key'], 'relay'); + $attachment = relativeRow($session, $frame, [0], 'Attachment', 'common_events', $index); + expect($attachment['options'])->toBe(['', 'aerial']); + $session->applyDatabaseRecord('common_events', $index, $attachment['key'], 'aerial'); + $session->saveDatabase('common_events'); + + expect(require $script)->toBe([['type' => 'stage_actor', 'id' => 'pulse', 'sprite' => '*', + 'at' => ['kind' => 'world_object', 'id' => 'relay', 'attachment' => 'aerial']]]) + ->and(file_get_contents($script))->toContain('// Staged by hand.'); +}); diff --git a/tests/Unit/ResumableStoryEventEditorTest.php b/tests/Unit/ResumableStoryEventEditorTest.php index 29f5c89e..3a9d34a1 100644 --- a/tests/Unit/ResumableStoryEventEditorTest.php +++ b/tests/Unit/ResumableStoryEventEditorTest.php @@ -36,15 +36,16 @@ function writePhase7ArrayFile(string $path, array $payload): void $definition = $workspace->maps[0]->getEventDefinition('E'); expect($definition['class'] ?? null)->toBe(ScriptEventTrigger::class) + // An empty script id is no script: it is offered, and written once chosen. ->and($definition['data'] ?? null)->toBe([ - 'scriptId' => '', 'mode' => 'action', 'reusable' => false, ]) - ->and($definition)->toHaveKeys(['conditions', 'sets', 'whenBlocked', 'cue']) + // Conditions, writes and the blocked message are every trigger's, written only once set. + ->and($definition)->not->toHaveKeys(['conditions', 'sets', 'whenBlocked']) ->and($definition['cue'])->toBe(['symbol' => '', 'color' => 'bright-yellow']); - $fields = callEditorMethod($editor, 'buildEventDataFields', 'E', $definition); + $fields = callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('E', $definition); $byPath = []; foreach ($fields as $field) { @@ -74,7 +75,7 @@ function writePhase7ArrayFile(string $path, array $payload): void 'class' => ScriptEventTrigger::class, 'data' => ['scriptId' => 'dresser-note', 'mode' => 'action', 'reusable' => false], ]; - $fields = callEditorMethod($editor, 'buildEventDataFields', 'E', $definition); + $fields = callEditorMethod($editor, 'createMapInspector')->buildEventDataFields('E', $definition); $paths = array_map(static fn(array $field): string => implode('.', (array) ($field['path'] ?? [])), $fields); expect($paths)->toContain('cue.symbol', 'cue.color') @@ -94,9 +95,18 @@ function writePhase7ArrayFile(string $path, array $payload): void expect(RecordSchemaCatalog::EVENT_COMMAND_TYPES)->toBe(EventInterpreter::COMMAND_TYPES) ->and($battleFields['troop']->reference)->toBe('troops') - ->and($battleFields)->toHaveKeys(['resultVariable', 'defeatPolicy', 'escapePolicy']) + ->and($battleFields)->toHaveKeys(['resultVariable', 'defeatPolicy', 'escapePolicy', 'reservePolicy']) ->and($battleFields['defeatPolicy']->options)->toBe(['game_over', 'continue']) - ->and($battleFields['escapePolicy']->options)->toBe(['allowed', 'forbidden']); + ->and($battleFields['escapePolicy']->options)->toBe(['allowed', 'forbidden']) + // Absent, the encounter loses when its frontline is wiped out. + ->and($battleFields['reservePolicy']->removeWhenEmpty)->toBeTrue() + ->and($battleFields['reservePolicy']->displayDefault)->toBe('none'); + + // The choices are exactly the Engine's reserve policies, when the Engine has them. + if (enum_exists(\Ichiloto\Engine\Battle\ReservePolicy::class)) { + expect($battleFields['reservePolicy']->options) + ->toBe(array_map(static fn(\Ichiloto\Engine\Battle\ReservePolicy $policy): string => $policy->value, \Ichiloto\Engine\Battle\ReservePolicy::cases())); + } }); it('accepts every runtime command type and still rejects vocabulary drift', function (): void { @@ -247,7 +257,7 @@ function writePhase7ArrayFile(string $path, array $payload): void ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'missing-npc', 'wait' => 'true', 'speed' => 0, 'steps' => [ ['direction' => 'diagonal', 'count' => -1, 'faceOnly' => 'false'], ]], - ['type' => 'start_battle', 'troop' => '', 'resultVariable' => '', 'defeatPolicy' => 'always_win', 'escapePolicy' => 'sometimes'], + ['type' => 'start_battle', 'troop' => '', 'resultVariable' => '', 'defeatPolicy' => 'always_win', 'escapePolicy' => 'sometimes', 'reservePolicy' => 'everyone'], ['type' => 'parallel_cutscene'], ]); writePhase7ArrayFile($root . '/assets/Data/troops.php', [ @@ -268,6 +278,7 @@ function writePhase7ArrayFile(string $path, array $payload): void ->and($joined)->toContain('invalid wait value') ->and($joined)->toContain('speed must be greater than zero') ->and($joined)->toContain('invalid escapePolicy "sometimes"') + ->and($joined)->toContain('invalid reservePolicy "everyone"') ->and($joined)->toContain('troop Malformed Policy Troop') ->and($joined)->toContain('unsupported direction "diagonal"') ->and($joined)->toContain('invalid count') diff --git a/tests/Unit/Review/MapReviewTest.php b/tests/Unit/Review/MapReviewTest.php new file mode 100644 index 00000000..70edad49 --- /dev/null +++ b/tests/Unit/Review/MapReviewTest.php @@ -0,0 +1,188 @@ +~~~.....#\n#..........#\n#..........#\n############", 'AUTHORED')); + // A door at (9, 2) cues the player in; one at (10, 2) waits on a world state a new game has not reached. + file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource( + " \n \n AB \n \n ", 'EVENTS')); + $door = static fn(array $more): array => ['class' => 'Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger', + 'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['South']], + 'cue' => ['symbol' => '!', 'color' => 'bright-yellow'], ...$more]; + file_put_contents($directory . '/test-map.data.php', " 'Test Map', 'region' => '', + 'events' => ['A' => $door([]), 'B' => $door(['conditions' => [['type' => 'event', 'name' => 'never_reached']]])], + 'npcs' => [['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 2, 'y' => 1]]], true) . ";\n"); + $sheet = imagecreatetruecolor(144, 192); + imagefill($sheet, 0, 0, imagecolorallocate($sheet, 255, 0, 0)); + mkdir($root . '/assets/Graphics/Characters', 0o777, true); + imagepng($sheet, $root . '/assets/Graphics/Characters/$Hero.png'); + $player = $root . '/assets/Data/Entities/player.php'; + file_put_contents($player, str_replace("return [\n", "return [\n 'sprites2d' => ['sheet' => 'Graphics/Characters/\$Hero.png'],\n", + (string) file_get_contents($player))); + + return $root; +} + +/** The 4 x 2 layered map with tiles ({@see mapGraphicsProject()}), its event marker left out: it names no event. */ +function mapReviewGraphicsProject(): string +{ + $root = mapGraphicsProject(); + file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', MapGridSource::buildSource(" \n ", 'EVENTS')); + + return $root; +} + +/** The rgb of an image pixel as [r, g, b]. */ +function readReviewPixel(GdImage $image, int $x, int $y): array +{ + $color = imagecolorsforindex($image, imagecolorat($image, $x, $y)); + + return [$color['red'], $color['green'], $color['blue']]; +} + +it('draws the Terminal field exactly as the field\'s camera draws it, with its NPCs and the player', function () { + $root = mapReviewProject(); + $session = EditorSession::open($root); + $rendering = $session->renderMapReview(new MapRenderView('test-map', subject: new MapRenderSubject(5, 3, MovementHeading::SOUTH))); + + // The same field opened from the map's files, as a cinematic preview opens it, captured by its own camera. + $field = PreviewField::open($root, 'test-map', new Vector2(5, 3), 20, 8, static fn(): float => 0.0); + $field->holdCameraAt(0, 0); + $origin = $field->getCameraOrigin(); + $screen = TerminalPicture::readRows($field->frame()); + $field->dispose(); + $expected = []; + for ($y = 0; $y < 5; $y++) { + for ($x = 0; $x < 12; $x++) { + $expected[$y][$x] = $screen[$y - $origin['y']][$x - $origin['x']]; + } + } + + expect($rendering->terminal->cells)->toBe($expected) + ->and(explode("\n", $rendering->terminal->renderPlainText()))->toBe([ + '############', '#.@~~~.....#', '#........!.#', '#....v.....#', '############', '', + ]) + ->and(TerminalText::stripAnsi($rendering->terminal->cells[1][2]))->toBe('@') + ->and($rendering->terminal->renderAnsi())->toContain("\e[34m") + ->and($rendering->terminal->renderHtml())->toContain('color:#61afef'); +}); + +it('crops a region to exactly the cells the whole map shows there, both ways', function () { + $root = mapReviewGraphicsProject(); + $session = EditorSession::open($root); + $whole = $session->renderMapReview(new MapRenderView('test-map')); + $region = $session->renderMapReview(new MapRenderView('test-map', ['x' => 1, 'y' => 1, 'width' => 3, 'height' => 1])); + $raster = new FieldStillRaster($root . '/assets'); + $wholeImage = $raster->rasterize($whole->still); + $regionImage = $raster->rasterize($region->still); + + expect($region->terminal->cells)->toBe([array_slice($whole->terminal->cells[1], 1, 3)]) + ->and([imagesx($regionImage), imagesy($regionImage)])->toBe([3 * 48, 48]); + foreach ([[0, 0], [47, 47], [100, 20], [143, 47]] as [$x, $y]) { + expect(readReviewPixel($regionImage, $x, $y))->toBe(readReviewPixel($wholeImage, $x + 48, $y + 48)); + } +}); + +it('draws the graphical still at native size with its tiles, and the player on their cell', function () { + $root = mapReviewProject(); + $map = $root . '/assets/Maps/test-map'; + writeTestTileset($root); + file_put_contents($map . '/test-map.data.php', str_replace("'region' => '',", "'region' => '', 'tileset' => 'home',", + (string) file_get_contents($map . '/test-map.data.php'))); + // A floor tile on the wall cell at (0, 0) only; every other cell shows its glyph. + writeTileLayer($map, '01.floor.tiles.php', "2816" . str_repeat(' 0', 11) . "\n" . implode("\n", array_fill(0, 4, trim(str_repeat('0 ', 12))))); + $session = EditorSession::open($root); + $still = $session->renderMapReview(new MapRenderView('test-map', subject: new MapRenderSubject(5, 3)))->still; + $raster = new FieldStillRaster($root . '/assets'); + $image = $raster->rasterize($still); + $overview = $raster->rasterize($still, 0.25); + + expect([imagesx($image), imagesy($image)])->toBe([12 * 48, 5 * 48]) + ->and([imagesx($overview), imagesy($overview)])->toBe([3 * 48, 5 * 12]) + // The tile's colour fills its cell and hides the wall glyph. + ->and(readReviewPixel($image, 24, 24))->toBe([0x70, 0x90, 0xB0]) + // The player's frame stands on its cell's ground. + ->and(readReviewPixel($image, 5 * 48 + 24, 3 * 48 + 24))->toBe([255, 0, 0]) + // An open floor cell no tile covers shows the field's background. + ->and(readReviewPixel($image, 8 * 48 + 2, 2 * 48 + 2))->toBe([0x11, 0x18, 0x20]) + ->and($raster->diagnostics)->toBe([]); +}); + +it('renders a batch\'s changes before they are written, and writes nothing', function () { + $root = mapReviewProject(); + $before = sourceHashTree($root . '/assets'); + $layer = EditorSession::open($root)->readMap('test-map')['baseLayer']; + $outcome = ChangeBatch::preview($root, [['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $layer, + 'cells' => [[8, 2]], 'symbol' => 'T']]], static fn(EditorSession $session) => $session->renderMapReview(new MapRenderView('test-map'))); + + expect($outcome['status'])->toBe('previewed') + ->and(TerminalText::stripAnsi($outcome['result']->terminal->cells[2][8]))->toBe('T') + ->and(sourceHashTree($root . '/assets'))->toBe($before) + ->and(TerminalText::stripAnsi(EditorSession::open($root)->renderMapReview(new MapRenderView('test-map'))->terminal->cells[2][8])) + ->toBe('.'); +}); + +it('writes each view\'s pictures and a review page that sets every view\'s Terminal picture beside its still', function () { + $root = mapReviewGraphicsProject(); + $session = EditorSession::open($root); + $out = sys_get_temp_dir() . '/' . uniqid('ichiloto-map-review-', true); + rememberTemporaryProject($out); + $writer = new MapReviewWriter($out, new FieldStillRaster($root . '/assets'), 0.5); + $views = [new MapRenderView('test-map', label: 'The whole map'), + new MapRenderView('test-map', ['x' => 0, 'y' => 0, 'width' => 2, 'height' => 2], label: 'Its west half')]; + foreach ($views as $view) { + $written = $writer->writeRendering($session->renderMapReview($view)); + expect(array_keys($written['files']))->toBe(['terminal-ans', 'terminal-html', 'png', 'scaled-png']); + foreach ($written['files'] as $file) { + expect(is_file($file))->toBeTrue(); + } + } + $page = (string) file_get_contents($writer->writeReviewPage('Review')); + + expect($page)->toContain('The whole map')->toContain('Its west half') + // The page carries its stills, so it shows them when opened or sent on its own. + ->toContain('src="data:image/png;base64,' . base64_encode((string) file_get_contents($out . '/test-map@0.5x.png')) . '"') + ->toContain('src="data:image/png;base64,' . base64_encode((string) file_get_contents($out . '/test-map@0,0,2,2@0.5x.png')) . '"') + ->toContain('href="test-map.png"') + ->and(substr_count($page, '
'))->toBe(2)
+        ->and(getimagesize($out . '/test-map@0,0,2,2.png'))->toMatchArray([0 => 96, 1 => 96])
+        ->and((string) file_get_contents($out . '/test-map.terminal.html'))->toStartWith('')->toContain('
');
+});
+
+it('refuses a region or subject off the map, and an unknown actor', function () {
+    $session = EditorSession::open(mapReviewProject());
+
+    expect(fn() => $session->renderMapReview(new MapRenderView('test-map', ['x' => 10, 'y' => 0, 'width' => 4, 'height' => 1])))
+        ->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'runs off map test-map')
+        ->and(fn() => $session->renderMapReview(new MapRenderView('test-map', subject: new MapRenderSubject(12, 0))))
+        ->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'is off map test-map')
+        ->and(fn() => $session->renderMapReview(new MapRenderView('test-map', subject: new MapRenderSubject(1, 1, actorId: 'nobody'))))
+        ->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'There is no actor nobody')
+        ->and(fn() => $session->renderMapReview(new MapRenderView('nowhere')))
+        ->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'There is no map nowhere');
+});
diff --git a/tests/Unit/SaveIntegrityTest.php b/tests/Unit/SaveIntegrityTest.php
index bc314a68..41f145fb 100644
--- a/tests/Unit/SaveIntegrityTest.php
+++ b/tests/Unit/SaveIntegrityTest.php
@@ -319,10 +319,6 @@ function hashTree(string $directory): array
     }
 
     $workspace->actorDatabase->save();
-    $workspace->classDatabase->save();
-    $workspace->skillDatabase->save();
-    $workspace->questDatabase->save();
-    $workspace->systemDatabase->save();
 
     foreach ($workspace->recordDatabases as $database) {
         if ($database->isEditable()) {
diff --git a/tests/Unit/SaveSetTest.php b/tests/Unit/SaveSetTest.php
new file mode 100644
index 00000000..cf6b5072
--- /dev/null
+++ b/tests/Unit/SaveSetTest.php
@@ -0,0 +1,141 @@
+} */
+function saveSetFolder(): array
+{
+    $folder = sys_get_temp_dir() . '/ichiloto-save-set-' . bin2hex(random_bytes(4));
+    mkdir($folder);
+    file_put_contents($folder . '/a.txt', "a\n");
+    file_put_contents($folder . '/b.txt', "b\n");
+
+    return [$folder, ['a' => $folder . '/a.txt', 'b' => $folder . '/b.txt', 'c' => $folder . '/c.txt']];
+}
+
+/**
+ * A document writing one file, noting when it verifies and adopts.
+ *
+ * @param list $log
+ */
+function saveSetDocument(string $label, string $path, string $contents, array &$log, ?string $refusal = null): PreparedSave
+{
+    return new PreparedSave(
+        $label,
+        static fn(FileSetTransaction $transaction) => $transaction->write($path, $contents),
+        static function (array $staged) use ($label, $path, $contents, $refusal, &$log): void {
+            $log[] = 'verify ' . $label;
+            expect(file_get_contents($staged[$path]))->toBe($contents);
+            if ($refusal !== null) {
+                throw new RuntimeException($refusal);
+            }
+        },
+        static function () use ($label, $path, $contents, &$log): void {
+            // Every document of the set is on disk before any adopts.
+            expect(file_get_contents($path))->toBe($contents);
+            $log[] = 'adopt ' . $label;
+        },
+    );
+}
+
+it('installs every document of a set, verifying all before any file and adopting each once all are written', function () {
+    [$folder, $paths] = saveSetFolder();
+    $log = [];
+
+    $written = SaveSet::commit($folder, [
+        saveSetDocument('first', $paths['a'], "A\n", $log),
+        saveSetDocument('second', $paths['c'], "C\n", $log),
+        PreparedSave::adoptOnly('unchanged', static function () use (&$log): void {
+            $log[] = 'adopt unchanged';
+        }),
+    ]);
+
+    expect($written)->toBe(['first' => [$paths['a']], 'second' => [$paths['c']], 'unchanged' => []])
+        ->and($log)->toBe(['verify first', 'verify second', 'adopt first', 'adopt second', 'adopt unchanged'])
+        ->and(file_get_contents($paths['a']))->toBe("A\n")
+        ->and(file_get_contents($paths['c']))->toBe("C\n");
+});
+
+it('writes nothing when the last document\'s staged bytes do not read back, and names it', function () {
+    [$folder, $paths] = saveSetFolder();
+    $log = [];
+    $before = sourceHashTree($folder);
+
+    expect(function () use ($folder, $paths, &$log): void {
+        SaveSet::commit($folder, [
+            saveSetDocument('first', $paths['a'], "A\n", $log),
+            saveSetDocument('last', $paths['b'], "B\n", $log, 'b.txt would not read back as the edited document'),
+        ]);
+    })->toThrow(SaveSetRefusal::class, 'last: b.txt would not read back as the edited document')
+        ->and(sourceHashTree($folder))->toBe($before)
+        ->and($log)->toBe(['verify first', 'verify last']);
+});
+
+it('puts back every file already installed when a later one cannot be, and adopts nothing', function () {
+    [$folder, $paths] = saveSetFolder();
+    $log = [];
+    $before = sourceHashTree($folder);
+    $files = new FailingFileSetOperations(failures: ['move' => [$paths['b']]]);
+
+    try {
+        SaveSet::commit($folder, [
+            saveSetDocument('first', $paths['a'], "A\n", $log),
+            saveSetDocument('second', $paths['c'], "C\n", $log),
+            saveSetDocument('third', $paths['b'], "B\n", $log),
+        ], files: $files);
+        $refusal = null;
+    } catch (SaveSetRefusal $caught) {
+        $refusal = $caught;
+    }
+
+    expect($refusal)->toBeInstanceOf(SaveSetRefusal::class)
+        ->and($refusal?->wasRolledBack)->toBeTrue()
+        ->and($refusal?->unrestored)->toBe([])
+        ->and($refusal?->getMessage())->toContain('Unable to replace b.txt')
+        ->and(sourceHashTree($folder))->toBe($before)
+        ->and(array_filter($log, static fn(string $entry): bool => str_starts_with($entry, 'adopt')))->toBe([]);
+});
+
+it('refuses two documents writing one file before anything is staged', function () {
+    [$folder, $paths] = saveSetFolder();
+    $log = [];
+    $before = sourceHashTree($folder);
+
+    expect(function () use ($folder, $paths, &$log): void {
+        SaveSet::commit($folder, [
+            saveSetDocument('first', $paths['a'], "A\n", $log),
+            saveSetDocument('second', $paths['a'], "A2\n", $log),
+        ]);
+    })->toThrow(SaveSetRefusal::class, 'second: a.txt is written twice in one transaction')
+        ->and(sourceHashTree($folder))->toBe($before)
+        ->and($log)->toBe([]);
+});
+
+it('says every file was written when a document cannot take up its own, still adopting the rest', function () {
+    [$folder, $paths] = saveSetFolder();
+    $log = [];
+    $stubborn = new PreparedSave('stubborn', static fn(FileSetTransaction $transaction) => $transaction->write($paths['c'], "C\n"),
+        adopt: static fn() => throw new RuntimeException('its index could not be reread'));
+
+    try {
+        SaveSet::commit($folder, [$stubborn, saveSetDocument('other', $paths['a'], "A\n", $log)]);
+        $failure = null;
+    } catch (Ichiloto\Editor\Storage\SaveSetAdoptionFailure $caught) {
+        $failure = $caught;
+    }
+
+    expect($failure?->written)->toBe(['stubborn' => [$paths['c']], 'other' => [$paths['a']]])
+        ->and($failure?->getMessage())->toStartWith('Every file was written, but stubborn could not take up what was written (its index could not be reread)')
+        ->and($log)->toBe(['verify other', 'adopt other'])
+        ->and(file_get_contents($paths['c']))->toBe("C\n");
+});
diff --git a/tests/Unit/ScriptCommandAuthoringTest.php b/tests/Unit/ScriptCommandAuthoringTest.php
new file mode 100644
index 00000000..9aca2a2f
--- /dev/null
+++ b/tests/Unit/ScriptCommandAuthoringTest.php
@@ -0,0 +1,209 @@
+ 'hire_carriage',
+        'class' => HireCarriage::class,
+        'label' => 'Hire Carriage',
+        'fields' => [
+            ['key' => 'destination', 'label' => 'Destination', 'kind' => 'reference', 'reference' => 'map', 'required' => true],
+            ['key' => 'arrival', 'label' => 'Arrival', 'kind' => 'position'],
+            ['key' => 'speed', 'label' => 'Speed', 'kind' => 'option', 'options' => ['slow', 'fast']],
+            ['key' => 'stops', 'label' => 'Stops', 'kind' => 'list', 'fields' => [
+                ['key' => 'map', 'label' => 'Map', 'kind' => 'reference', 'reference' => 'map', 'required' => true],
+            ]],
+            ['key' => 'tolls', 'label' => 'Tolls', 'kind' => 'list', 'fields' => [
+                ['key' => 'amount', 'label' => 'Amount', 'kind' => 'integer'],
+            ]],
+        ],
+    ]];
+    PHP);
+}
+
+/** Writes an authored PHP data file. */
+function writeScriptCommandScript(string $path, array $payload): void
+{
+    file_put_contents($path, " */
+function registeredVariantFields(string $type): array
+{
+    $fields = [];
+    $variant = RecordSchemaCatalog::forKey('common_events')?->subList?->variants[$type] ?? [];
+    if ($variant instanceof Closure) { $variant = $variant(['type' => $type]); }
+    foreach ($variant as $field) {
+        $fields[$field->key] = $field;
+    }
+
+    return $fields;
+}
+
+afterEach(function (): void {
+    ScriptCommandRegistry::reset();
+});
+
+it('maps every Engine command reference to an Editor resource category', function (): void {
+    foreach (ScriptCommandReference::cases() as $reference) {
+        if ($reference === ScriptCommandReference::TROOP_MEMBER) {
+            // A troop member belongs to the troop that owns the page, never to the project's resources.
+            expect(ProjectScriptCommands::getReferenceCategory($reference))->toBeNull();
+            continue;
+        }
+        expect(ProjectScriptCommands::getReferenceCategory($reference))->toBeString()->not->toBeEmpty();
+    }
+
+    expect(ProjectScriptCommands::getReferenceCategory(ScriptCommandReference::STAGE_TIMELINE))
+        ->toBe('stage_timelines');
+});
+
+it('reads a project\'s declared commands without loading their handlers', function (): void {
+    $root = makeTemporaryProject();
+
+    expect(ProjectScriptCommands::fromProject($root)->catalog->types)->toBe(['shop', 'inn', 'payday', 'collect_income', 'adjust_income', 'end_battle', 'concede']);
+
+    writeCarriageDeclarations($root);
+    $commands = ProjectScriptCommands::fromProject($root);
+
+    expect($commands->catalog->types)->toBe(['shop', 'inn', 'payday', 'collect_income', 'adjust_income', 'end_battle', 'concede', 'hire_carriage'])
+        ->and($commands->catalog->findDefinition('hire_carriage')?->handlerClass)->toBe('Project\\Commands\\HireCarriage')
+        ->and(class_exists('Project\\Commands\\HireCarriage', false))->toBeFalse()
+        ->and($commands->declarationProblem)->toBeNull();
+});
+
+it('offers the open project\'s registered commands with their declared fields', function (): void {
+    $root = makeTemporaryProject();
+    writeCarriageDeclarations($root);
+    ProjectWorkspace::fromProject($root);
+    $list = RecordSchemaCatalog::eventCommandList('script');
+    $shop = registeredVariantFields('shop');
+    $inn = registeredVariantFields('inn');
+    $carriage = registeredVariantFields('hire_carriage');
+
+    expect($list->fields[0]->options)->toBe([...RecordSchemaCatalog::EVENT_COMMAND_TYPES, 'shop', 'inn', 'payday', 'collect_income', 'adjust_income', 'hire_carriage'])
+        ->and(array_keys($shop))->toBe(['buyRate', 'sellRate'])
+        ->and($shop['sellRate']->type)->toBe(InputControlType::FLOAT)
+        ->and(array_keys($inn))->toBe(['confirmDialogue.text', 'confirmDialogue.name', 'cost', 'spawnPoint.x', 'spawnPoint.y', 'bgm', 'presentation', 'resultVariable'])
+        ->and($inn['bgm']->reference)->toBe('bgm')
+        ->and($inn['bgm']->allowsNone)->toBeTrue()
+        ->and($inn['presentation']->reference)->toBe('stage_timelines')
+        ->and($inn['presentation']->allowsNone)->toBeTrue()
+        ->and($inn['cost']->type)->toBe(InputControlType::INTEGER)
+        ->and($carriage['destination']->reference)->toBe('maps')
+        ->and($carriage['destination']->allowsNone)->toBeFalse()
+        ->and($carriage['speed']->options)->toBe(['slow', 'fast'])
+        // The first list is edited as the command's entries; a second is kept as written.
+        ->and($carriage)->not->toHaveKey('stops')
+        ->and($carriage['tolls']->isReadOnly)->toBeTrue();
+
+    $merchandise = $list->nestedListFor(['type' => 'shop']);
+    $stops = $list->nestedListFor(['type' => 'hire_carriage']);
+
+    expect($merchandise?->key)->toBe('items')
+        ->and(array_map(static fn($field): string => $field->key, $merchandise->fields))->toBe(['item', 'price'])
+        ->and($merchandise->fields[0]->reference)->toBe('inventory')
+        ->and($merchandise->blank)->toBe(['item' => ''])
+        ->and($stops?->key)->toBe('stops');
+});
+
+it('forgets a previous project\'s commands when another project opens', function (): void {
+    $root = makeTemporaryProject();
+    writeCarriageDeclarations($root);
+    ProjectWorkspace::fromProject($root);
+
+    ProjectWorkspace::fromProject(makeTemporaryProject());
+
+    expect(RecordSchemaCatalog::getEventCommandTypes())->not->toContain('hire_carriage')
+        ->and(RecordSchemaCatalog::getEventCommandTypes())->toContain('shop', 'inn');
+});
+
+it('reports registered commands the runtime would refuse and resources they name that do not exist', function (): void {
+    $root = makeTemporaryProject();
+    writeCarriageDeclarations($root);
+    writeScriptCommandScript($root . '/assets/Events/services.php', [
+        ['type' => 'shop', 'items' => [['item' => 'S-Potion', 'price' => 40], ['item' => 'Elixir of Nowhere']], 'sellRate' => -1],
+        ['type' => 'inn', 'cost' => 30],
+        ['type' => 'hire_carriage', 'destination' => 'test-map', 'stops' => [['map' => 'atlantis']]],
+        ['type' => 'hire_carriage', 'destination' => 'test-map', 'speed' => 'warp'],
+    ]);
+
+    $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root));
+    $messages = array_values(array_map(
+        static fn($issue): string => $issue->message,
+        array_filter($issues, static fn($issue): bool => str_contains($issue->where, 'services')),
+    ));
+
+    expect($messages)->toContain(
+        'Its shop command cannot run: "sellRate" must be at least 0.',
+        'It names the item "Elixir of Nowhere", which does not exist.',
+        'Its inn command cannot run: "confirmDialogue.text" is required.',
+        'Its hire_carriage command cannot run: "speed" must be one of slow, fast.',
+    )
+        ->and(implode("\n", $messages))->toContain('"atlantis"')
+        ->and(implode("\n", $messages))->not->toContain('unknown event command type')
+        ->and(implode("\n", $messages))->not->toContain('"S-Potion"');
+});
+
+it('reports declarations the game would refuse to start with', function (): void {
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/' . ScriptCommandRegistry::PROJECT_FILE, " 'transfer', 'class' => 'X', 'label' => 'Teleport']];\n");
+    writeScriptCommandScript($root . '/assets/Events/teleport.php', [['type' => 'shop', 'items' => [['item' => 'S-Potion']]]]);
+
+    $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root));
+    $declaration = array_values(array_filter($issues, static fn($issue): bool => $issue->where === 'assets/Data/script-commands.php'));
+
+    expect($declaration)->toHaveCount(1)
+        ->and($declaration[0]->message)->toContain('"transfer", a built-in command type')
+        // The Engine's own commands stay authorable meanwhile.
+        ->and(array_filter($issues, static fn($issue): bool => str_contains($issue->where, 'teleport')))->toBe([]);
+});
+
+it('edits a shop command\'s merchandise and keeps what it does not edit', function (): void {
+    $root = makeTemporaryProject();
+    ProjectWorkspace::fromProject($root);
+    $database = loadRecordDatabase($root, 'common_events');
+    // The fixture's first Common Event.
+    $path = $root . '/assets/Events/dresser-note.php';
+    $commandIndex = $database->addSubItem(0, [
+        'type' => 'shop',
+        'items' => [['item' => 'S-Potion']],
+        'technicalMetadata' => ['preserve' => true],
+    ]);
+    $fieldIds = static fn(): array => array_column($database->getSettingsFields(0), 'field');
+    $find = static fn(string $suffix): string => array_values(array_filter(
+        $fieldIds(),
+        static fn(string $id): bool => str_starts_with($id, "command{$commandIndex}") && str_ends_with($id, $suffix),
+    ))[0] ?? throw new RuntimeException("No field ending {$suffix}: " . implode(', ', $fieldIds()));
+
+    $database->setField(0, $find('0Price'), '40');
+    expect($database->addNestedSubItem(0, $commandIndex))->toBe(1);
+    $database->setField(0, $find('1Item'), 'Antidote');
+    $database->setField(0, $find('SellRate'), '0.25');
+    $database->save();
+
+    $payload = require $path;
+
+    expect($payload[$commandIndex])->toBe([
+        'type' => 'shop',
+        'items' => [['item' => 'S-Potion', 'price' => 40], ['item' => 'Antidote']],
+        'technicalMetadata' => ['preserve' => true],
+        'sellRate' => 0.25,
+    ]);
+});
diff --git a/tests/Unit/Session/ActorSessionTest.php b/tests/Unit/Session/ActorSessionTest.php
new file mode 100644
index 00000000..ab50e93b
--- /dev/null
+++ b/tests/Unit/Session/ActorSessionTest.php
@@ -0,0 +1,237 @@
+ ($row['key']['field'] ?? null) === $fieldId)
+        ?? throw new RuntimeException("No actor row {$fieldId}.");
+}
+
+/** Writes a legacy actor authored without an id into a fixture project. */
+function writeLegacyActor(string $root, string $name): string
+{
+    $path = $root . '/assets/Data/Actors/' . $name . '.php';
+    file_put_contents($path, << Character::class,
+          'data' => [
+            'name' => '{$name}',
+            'level' => 1,
+          ]
+        ];
+
+        PHP);
+
+    return $path;
+}
+
+it('lists actors with what the category can do, and reads an actor\'s rows and previews', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $actors = $session->listDatabaseRecords('actors');
+    $read = $session->readDatabaseRecord('actors', 0);
+
+    expect($actors)->toMatchArray([
+        'editable' => true, 'dirty' => false, 'canCreate' => true, 'canDuplicate' => false, 'canDelete' => true, 'canReorder' => false,
+        'records' => ['Kaelion'],
+    ])
+        ->and($read)->toMatchArray(['frame' => [], 'frameLabel' => null, 'editable' => true, 'listNoun' => null])
+        ->and(actorSessionRow($read, 'name'))->toMatchArray(['kind' => 'text', 'value' => 'Kaelion'])
+        ->and(actorSessionRow($read, 'class')['kind'])->toBe('options')
+        ->and(actorSessionRow($read, 'attackSkill'))->toMatchArray(['kind' => 'reference', 'reference' => 'attack_skills', 'noneLabel' => '(Built-in attack)'])
+        ->and(actorSessionRow($read, 'id')['kind'])->toBe('info')
+        ->and(array_column($read['rows'], 'label'))->toContain('Resolved Stats', 'Optimize Preview')
+        ->and(fn() => $session->readDatabaseRecord('actors', 4))->toThrow(SessionRefusal::class, 'actors has no record 4.')
+        ->and(fn() => $session->readDatabaseRecord('actors', 0, [0]))->toThrow(SessionRefusal::class, 'Actors have no command frames.');
+});
+
+it('applies an actor row as one undo step that names the actors, and saves only what changed', function () {
+    $root = makeTemporaryProject();
+    $path = $root . '/assets/Data/Actors/Kaelion.php';
+    $original = (string) file_get_contents($path);
+    $session = EditorSession::open($root);
+    $name = actorSessionRow($session->readDatabaseRecord('actors', 0), 'name');
+
+    expect($session->applyDatabaseRecord('actors', 0, $name['key'], 'Kaelion the Bold'))->toBe(['changed' => true, 'records' => ['Kaelion the Bold']])
+        ->and($session->applyDatabaseRecord('actors', 0, $name['key'], 'Kaelion the Bold')['changed'])->toBeFalse()
+        ->and($session->listDatabaseRecords('actors')['dirty'])->toBeTrue();
+
+    $undone = $session->undo();
+    expect($undone['databases'])->toBe(['actors'])
+        ->and($session->listDatabaseRecords('actors')['records'])->toBe(['Kaelion'])
+        ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse();
+
+    $session->redo();
+    expect($session->saveDatabase('actors')['saved'])->toBeTrue()
+        ->and(file_get_contents($path))->toBe(str_replace("'name' => 'Kaelion',", "'name' => 'Kaelion the Bold',", $original))
+        ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse()
+        ->and($session->saveDatabase('actors')['saved'])->toBeFalse();
+});
+
+it('keeps choices about the actor panes out of the project and out of the history', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $slot = actorSessionRow($session->readDatabaseRecord('actors', 0), '__actor_optimize_slot');
+    $other = array_values(array_filter($slot['options'], static fn(string $option): bool => $option !== $slot['value']))[0];
+
+    expect($session->applyDatabaseRecord('actors', 0, $slot['key'], $other)['changed'])->toBeFalse()
+        ->and(actorSessionRow($session->readDatabaseRecord('actors', 0), '__actor_optimize_slot')['value'])->toBe($other)
+        ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse()
+        ->and($session->undo()['label'])->toBeNull();
+});
+
+it('creates and deletes actors as undo steps, and refuses the operations a file per actor cannot keep', function () {
+    $root = makeTemporaryProject();
+    $session = EditorSession::open($root);
+
+    $created = $session->createDatabaseRecord('actors');
+    expect($created)->toBe(['index' => 1, 'records' => ['Kaelion', 'New Actor']]);
+
+    $deleted = $session->deleteDatabaseRecord('actors', 0);
+    expect($deleted)->toBe(['index' => 0, 'records' => ['New Actor']]);
+
+    $session->undo();
+    expect($session->listDatabaseRecords('actors')['records'])->toBe(['Kaelion', 'New Actor'])
+        ->and(fn() => $session->duplicateDatabaseRecord('actors', 0))->toThrow(SessionRefusal::class, 'Actors cannot be duplicated')
+        ->and(fn() => $session->moveDatabaseRecord('actors', 0, 'down'))->toThrow(SessionRefusal::class, 'file order')
+        ->and(fn() => $session->addDatabaseItem('actors', 0, ['frame' => []]))->toThrow(SessionRefusal::class, 'no list');
+
+    $session->saveDatabase('actors');
+    expect(is_file($root . '/assets/Data/Actors/NewActor.php'))->toBeTrue()
+        ->and(is_file($root . '/assets/Data/Actors/Kaelion.php'))->toBeTrue();
+});
+
+it('offers the one-time identity freeze of an actor authored without an id as the row\'s action', function () {
+    $root = makeTemporaryProject();
+    // A project whose only identity repair is this actor's: no skit names
+    // actors by the names a repair would rewrite.
+    removeDirectoryRecursively($root . '/assets/Data/Skits');
+    $path = writeLegacyActor($root, 'Mira');
+    $session = EditorSession::open($root);
+    $index = array_search('Mira', $session->listDatabaseRecords('actors')['records'], true);
+    $id = actorSessionRow($session->readDatabaseRecord('actors', $index), 'id');
+
+    expect($id)->toMatchArray(['kind' => 'action', 'action' => 'Freeze "Mira" as the permanent id'])
+        ->and($session->applyDatabaseRecord('actors', $index, $id['key'], '')['changed'])->toBeTrue()
+        ->and(actorSessionRow($session->readDatabaseRecord('actors', $index), 'id'))->toMatchArray(['kind' => 'info', 'value' => 'Mira']);
+
+    $session->saveDatabase('actors');
+    expect(file_get_contents($path))->toContain("'id' => 'Mira'");
+});
+
+it('asks before an identity freeze that repairs other files at once, then writes it as one undo step', function () {
+    $root = makeTemporaryProject();
+    $mira = writeLegacyActor($root, 'Mira');
+    writeLegacyActor($root, 'Tobin');
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    [$map] = $session->describeMaps();
+    $index = array_search('Mira', $session->listDatabaseRecords('actors')['records'], true);
+    $id = actorSessionRow($session->readDatabaseRecord('actors', $index), 'id');
+    $question = $session->applyDatabaseRecord('actors', $index, $id['key'], '');
+
+    expect($question['status'])->toBe('question')
+        ->and($question['question'])->toContain('Freeze "Mira"')
+        ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'write'])
+        ->and($question['answers'][1]['description'])->toContain('assets/Data/Actors/Mira.php', 'Undo restores the files')
+        ->and($session->applyDatabaseRecord('actors', $index, $id['key'], '', 'cancel')['changed'])->toBeFalse()
+        ->and(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'maybe'))->toThrow(SessionRefusal::class, 'Answer write or cancel')
+        ->and(sourceHashTree($root))->toBe($before);
+
+    // Pending edits are saved or undone first: the set is written at once.
+    $description = actorSessionRow($session->readDatabaseRecord('actors', 0), 'description')['key'];
+    $session->applyDatabaseRecord('actors', 0, $description, 'Pending.');
+    expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write', $question['confirm']))
+        ->toThrow(SessionRefusal::class, 'Save or undo pending edits');
+    $session->undo();
+
+    // Writing names the plan that was shown; without it, or with another, nothing is written.
+    expect(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write'))->toThrow(SessionRefusal::class, 'Review it again')
+        ->and(fn() => $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write', str_repeat('0', 64)))->toThrow(SessionRefusal::class, 'Review it again')
+        ->and(sourceHashTree($root))->toBe($before);
+
+    $written = $session->applyDatabaseRecord('actors', $index, $id['key'], '', 'write', $question['confirm']);
+    [$reread] = $session->describeMaps();
+
+    expect($written)->toMatchArray(['changed' => true, 'reloaded' => true])
+        ->and((string) file_get_contents($mira))->toContain("'id' => 'Mira'")
+        ->and(sourceHashTree($root))->not->toBe($before)
+        ->and(actorSessionRow($session->readDatabaseRecord('actors', $index), 'id'))->toMatchArray(['kind' => 'info', 'value' => 'Mira'])
+        ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse()
+        // The project was read again; the map the set never wrote holds what it did, so its revision stands.
+        ->and($reread['revision'])->toBe($map['revision']);
+
+    $undone = $session->undo();
+
+    expect($undone['label'])->toBe('Migrate actor identities and references')
+        ->and($undone['databases'])->toContain('actors')
+        ->and($undone['maps'])->toBe([])
+        ->and($session->describeMaps()[0]['revision'])->toBe($map['revision'])
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and(actorSessionRow($session->readDatabaseRecord('actors', $index), 'id')['kind'])->toBe('action');
+});
+
+it('serves actors over the line protocol', function () {
+    $root = makeTemporaryProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+
+    $read = $request(2, 'database.record', ['category' => 'actors', 'index' => 0, 'frame' => []])['result'];
+    $applied = $request(3, 'database.apply', ['category' => 'actors', 'index' => 0, 'key' => actorSessionRow($read, 'description')['key'], 'value' => 'A swordsman.'])['result'];
+    $undone = $request(4, 'history.undo')['result'];
+
+    expect($request(5, 'database.records', ['category' => 'actors'])['result']['records'])->toBe(['Kaelion'])
+        ->and($applied['changed'])->toBeTrue()
+        ->and($undone['databases'])->toBe(['actors']);
+});
+
+it('asks and answers a file set written at once over the line protocol', function () {
+    $root = makeTemporaryProject();
+    writeLegacyActor($root, 'Mira');
+    writeLegacyActor($root, 'Tobin');
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $index = array_search('Mira', $request(2, 'database.records', ['category' => 'actors'])['result']['records'], true);
+    $key = actorSessionRow($request(3, 'database.record', ['category' => 'actors', 'index' => $index, 'frame' => []])['result'], 'id')['key'];
+    $apply = ['category' => 'actors', 'index' => $index, 'key' => $key, 'value' => ''];
+
+    $asked = $request(4, 'database.apply', $apply)['result'];
+
+    expect($asked['status'])->toBe('question')
+        ->and($request(5, 'database.apply', [...$apply, 'answer' => 'write', 'confirm' => $asked['confirm']])['result'])->toMatchArray(['changed' => true, 'reloaded' => true])
+        ->and($request(6, 'history.undo')['result']['label'])->toBe('Migrate actor identities and references');
+});
+
+it('names an unsaved actor edit among the unsaved changes, and Save All writes it, beside the battle art categories', function () {
+    $root = makeTemporaryProject();
+    $path = $root . '/assets/Data/Actors/Kaelion.php';
+    $session = EditorSession::open($root);
+    $name = actorSessionRow($session->readDatabaseRecord('actors', 0), 'name');
+    $session->applyDatabaseRecord('actors', 0, $name['key'], 'Kaelion the Bold');
+
+    expect($session->listUnsavedChanges())->toBe(['Actors database']);
+
+    $session->saveAll();
+
+    expect(file_get_contents($path))->toContain('Kaelion the Bold')
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
diff --git a/tests/Unit/Session/AffinitySessionTest.php b/tests/Unit/Session/AffinitySessionTest.php
new file mode 100644
index 00000000..62d0d1d0
--- /dev/null
+++ b/tests/Unit/Session/AffinitySessionTest.php
@@ -0,0 +1,47 @@
+readDatabaseRecord('enemies', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'elementAffinities');
+}
+
+it('offers an affinity row as entries, built from the game\'s elements and the named effects', function () {
+    $session = EditorSession::open(enemyPreviewProject());
+    $vocabulary = $session->describeAffinityVocabulary();
+
+    expect(readAffinityRow($session))->toMatchArray(['kind' => 'affinities', 'entries' => []])
+        ->and($vocabulary['elements'])->toContain('Fire', 'Water')
+        ->and($vocabulary['effects'])->toBe([
+            ['label' => 'Weak', 'multiplier' => 2.0],
+            ['label' => 'Resist', 'multiplier' => 0.5],
+            ['label' => 'Null', 'multiplier' => 0.0],
+            ['label' => 'Absorb', 'multiplier' => -1.0],
+        ]);
+});
+
+it('writes any multiplier, named or between, and the row reads it back as entries', function () {
+    $session = EditorSession::open(enemyPreviewProject());
+    $encoded = $session->encodeAffinities([['element' => 'Water', 'multiplier' => -1], ['element' => 'Fire', 'multiplier' => 2.5]]);
+
+    expect($encoded)->toBe(['line' => 'Water: -1; Fire: 2.5', 'descriptions' => ['Water: Absorb ×-1', 'Fire: ×2.5']]);
+
+    $session->applyDatabaseRecord('enemies', 0, readAffinityRow($session)['key'], $encoded['line']);
+
+    expect(readAffinityRow($session)['entries'])->toBe([['element' => 'Water', 'multiplier' => -1.0], ['element' => 'Fire', 'multiplier' => 2.5]]);
+});
+
+it('refuses an element the game does not know, one listed twice, or a multiplier that is not a number', function () {
+    $session = EditorSession::open(enemyPreviewProject());
+
+    expect(fn() => $session->encodeAffinities([['element' => 'Plasma', 'multiplier' => 2]]))->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'an element the game knows')
+        ->and(fn() => $session->encodeAffinities([['element' => 'Fire', 'multiplier' => 2], ['element' => 'Fire', 'multiplier' => 0.5]]))->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'listed twice')
+        ->and(fn() => $session->encodeAffinities([['element' => 'Fire', 'multiplier' => 'hot']]))->toThrow(Ichiloto\Editor\Session\SessionRefusal::class, 'needs a multiplier')
+        ->and($session->encodeAffinities([])['line'])->toBe('');
+});
diff --git a/tests/Unit/Session/AnimationConversionSessionTest.php b/tests/Unit/Session/AnimationConversionSessionTest.php
new file mode 100644
index 00000000..c8f2732f
--- /dev/null
+++ b/tests/Unit/Session/AnimationConversionSessionTest.php
@@ -0,0 +1,133 @@
+ 1,
+        'name' => 'Old Spark',
+        'position' => 'center',
+        'maxFrames' => 3,
+        'frames' => [
+            ['index' => 1, 'cells' => [['x' => 0, 'y' => 0, 'symbol' => '*', 'color' => 'yellow']]],
+            ['index' => 2, 'cells' => [['x' => 1, 'y' => 0, 'symbol' => '+']]],
+        ],
+        'cues' => [['frame' => 1, 'soundEffect' => 'spark', 'flashColor' => 'white', 'flashDurationFrames' => 2]],
+    ],
+    ['id' => 2, 'name' => 'Already Timed', 'targetEffect' => 'shared-burst'],
+];
+PHP;
+
+/** A project whose animations hold one legacy record and one already on a timeline. */
+function legacyAnimationProject(): string
+{
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/Data/animations.php', LEGACY_ANIMATIONS);
+
+    return $root;
+}
+
+function legacyAnimationIndex(EditorSession $session, string $name): int
+{
+    return array_search($name, $session->listDatabaseRecords('animations')['records'], true);
+}
+
+it('describes what a conversion touches: the legacy frames and cues, its bindings and who plays it', function () {
+    $session = EditorSession::open(legacyAnimationProject());
+
+    expect($session->describeAnimationConversion(legacyAnimationIndex($session, 'Old Spark')))->toMatchArray([
+        'id' => 1, 'name' => 'Old Spark', 'frames' => 2, 'maxFrames' => 3, 'position' => 'center', 'cues' => 1, 'flashes' => 1,
+        'sourceEffect' => null, 'targetEffect' => null, 'field' => [],
+    ])->and(fn() => $session->describeAnimationConversion(legacyAnimationIndex($session, 'Already Timed')))
+        ->toThrow(SessionRefusal::class, 'has no legacy frames or cues to convert');
+});
+
+it('asks first, then writes the timeline and the battle binding as one undo step, keeping the legacy frames', function () {
+    $root = legacyAnimationProject();
+    $session = EditorSession::open($root);
+    $index = legacyAnimationIndex($session, 'Old Spark');
+    $convert = static fn(?string $answer, ?string $confirm = null) => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', $answer, $confirm);
+    $timeline = $root . '/assets/Animations/old-spark/old-spark.timeline.php';
+
+    $asked = $convert(null);
+
+    expect($asked['status'])->toBe('question')
+        ->and(array_keys($asked['preview']))->toBe(['assets/Data/animations.php', 'assets/Animations/old-spark/old-spark.timeline.php'])
+        ->and($asked['preview']['assets/Data/animations.php'])->toContain("// A spark in the old cell frames.", "'targetEffect' => 'old-spark'")
+        ->and(file_exists($timeline))->toBeFalse();
+
+    expect($convert('write', $asked['confirm']))->toMatchArray(['changed' => true, 'reloaded' => true])
+        ->and(file_get_contents($root . '/assets/Data/animations.php'))->toBe($asked['preview']['assets/Data/animations.php'])
+        ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain("'frames' => [", "'cues' => [")
+        ->and(new EffectTimelineLibrary($root . '/assets')->load('old-spark', forBattle: true))->not->toBeNull();
+
+    $session->undo();
+
+    expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(LEGACY_ANIMATIONS)
+        ->and(file_exists($timeline))->toBeFalse()
+        ->and(is_dir(dirname($timeline)))->toBeFalse();
+});
+
+it('converts for a field consumer at an explicit fixed rate, leaving the record as it is', function () {
+    $root = legacyAnimationProject();
+    $session = EditorSession::open($root);
+
+    $index = legacyAnimationIndex($session, 'Old Spark');
+    $asked = $session->convertAnimation($index, 'field-old-spark', 'fixed', 25, 3, 0, false, null);
+    $session->convertAnimation($index, 'field-old-spark', 'fixed', 25, 3, 0, false, null, 'write', $asked['confirm']);
+    $written = require $root . '/assets/Animations/field-old-spark/field-old-spark.timeline.php';
+
+    expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(LEGACY_ANIMATIONS)
+        ->and($written)->toMatchArray(['fps' => 25, 'lengthFrames' => 9, 'restFrame' => 0])
+        ->and(new EffectTimelineLibrary($root . '/assets')->load('field-old-spark'))->not->toBeNull();
+});
+
+it('refuses, before writing, timing its consumer cannot use, a taken name, and pending edits', function () {
+    $root = legacyAnimationProject();
+    $session = EditorSession::open($root);
+    $index = legacyAnimationIndex($session, 'Old Spark');
+    mkdir($root . '/assets/Animations/taken', 0o777, true);
+
+    expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, null))->toThrow(SessionRefusal::class, 'Only a battle paces')
+        ->and(fn() => $session->convertAnimation($index, 'old-spark', 'fixed', null, 1, 0, true, null))->toThrow(SessionRefusal::class, 'explicit consumer cadence')
+        ->and(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 9, true, 'targetEffect'))->toThrow(SessionRefusal::class, 'rest frame')
+        ->and(fn() => $session->convertAnimation($index, 'taken', 'battle_phase', null, 1, 0, true, 'targetEffect'))->toThrow(SessionRefusal::class, 'already exists')
+        ->and(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'sideEffect'))->toThrow(SessionRefusal::class, 'sourceEffect or targetEffect');
+
+    $name = array_find($session->readDatabaseRecord('animations', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'name');
+    $session->applyDatabaseRecord('animations', $index, $name['key'], 'Renamed Spark');
+
+    $asked = $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect');
+    expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', 'write', $asked['confirm']))
+        ->toThrow(SessionRefusal::class, 'Save or undo pending edits')
+        ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse();
+});
+
+it('writes only the plan that was previewed: other choices, or files changed since, are asked again', function () {
+    $root = legacyAnimationProject();
+    $session = EditorSession::open($root);
+    $index = legacyAnimationIndex($session, 'Old Spark');
+    $asked = $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect');
+
+    // The author changed a choice after the preview.
+    expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 2, 0, true, 'targetEffect', 'write', $asked['confirm']))
+        ->toThrow(SessionRefusal::class, 'Review it again');
+
+    // The animations file changed on disk after the preview.
+    file_put_contents($root . '/assets/Data/animations.php', str_replace('// A spark in the old cell frames.', '// Edited elsewhere.', LEGACY_ANIMATIONS));
+
+    expect(fn() => $session->convertAnimation($index, 'old-spark', 'battle_phase', null, 1, 0, true, 'targetEffect', 'write', $asked['confirm']))
+        ->toThrow(SessionRefusal::class, 'Review it again')
+        ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse()
+        ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain('// Edited elsewhere.');
+});
diff --git a/tests/Unit/Session/AtomicChangeSetTest.php b/tests/Unit/Session/AtomicChangeSetTest.php
new file mode 100644
index 00000000..59e0042a
--- /dev/null
+++ b/tests/Unit/Session/AtomicChangeSetTest.php
@@ -0,0 +1,201 @@
+ [], 'resources' => []];\n");
+    writeTilesetTestPng($root . '/assets/Graphics/Props/Lamp.png', 12, 16);
+    $mapPath = $root . '/assets/Maps/harbour/harbour.data.php';
+    $map = require $mapPath;
+    $map['events']['B'] = ['class' => Ichiloto\Engine\Events\Triggers\DialogueEventTrigger::class, 'data' => ['dialogue' => [['name' => '', 'text' => 'A lamp.']]]];
+    $map['npcs'][] = ['id' => 'lamp-stand-in', 'name' => 'Lamp', 'sprite' => 'L', 'x' => 12, 'y' => 4];
+    file_put_contents($mapPath, " 'text', 'message' => 'The lamp hums.']];\n");
+    writeRelativeScriptForAtomicTest($root);
+
+    return $root;
+}
+
+/** The cinematic's script: one staged actor on a literal cell. */
+function writeRelativeScriptForAtomicTest(string $root): void
+{
+    file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php',
+        " 'stage_actor', 'id' => 'guide', 'sprite' => ['G'], 'x' => 1, 'y' => 2]], true) . ";\n");
+}
+
+/** The cinematic row at an entry, by name. */
+function atomicCinematicRow(EditorSession $session, string $name): array
+{
+    return array_find($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands'])['rows'],
+        static fn(array $row): bool => ($row['entry'] ?? []) === [0] && ($row['name'] ?? '') === $name)
+        ?? throw new RuntimeException('Missing cinematic row ' . $name);
+}
+
+/** Makes every edit of the rebuild inside the session's open change set. */
+function applyAtomicRebuild(EditorSession $session): void
+{
+    $session->createDatabaseRecord(FieldResourceFields::CATEGORY);
+    $index = count($session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records']) - 1;
+    $resource = static fn(string $field): array => array_find($session->readDatabaseRecord(FieldResourceFields::CATEGORY, $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) ?? throw new RuntimeException('Missing resource row ' . $field);
+    foreach (['id' => 'lamp', 'name' => 'Lamp', 'sprites2d.asset' => 'Graphics/Props/Lamp.png'] as $field => $value) {
+        $session->applyDatabaseRecord(FieldResourceFields::CATEGORY, $index, $resource($field)['key'], $value);
+    }
+
+    // The resource is usable at once, though nothing has been written.
+    $session->createWorldObject('harbour', $session->readMap('harbour')['revision'], 'lamp-1', 10, 3, 'lamp', null);
+    $session->setEventSubject('harbour', $session->readMap('harbour')['revision'], 'B', 'lamp-1');
+    $npc = array_search('lamp-stand-in', array_column($session->readMap('harbour')['npcs'], 'id'), true);
+    $session->deleteNpc('harbour', $session->readMap('harbour')['revision'], (int) $npc);
+
+    $session->applyDatabaseRecord('cutscenes/cinematic', 0, atomicCinematicRow($session, 'Relative To')['key'], 'world_object');
+    $session->applyDatabaseRecord('cutscenes/cinematic', 0, atomicCinematicRow($session, 'World Object')['key'], 'lamp-1');
+    $session->applyDatabaseRecord('cutscenes/cinematic', 0, atomicCinematicRow($session, 'Offset Y')['key'], '1');
+
+    $duplicate = array_search('lamp-duplicate', $session->listDatabaseRecords('common_events')['records'], true);
+    expect($duplicate)->toBeInt();
+    $session->deleteDatabaseRecord('common_events', $duplicate);
+}
+
+it('commits a scene rebuild as one change set: a new resource, its object, a bound event, relative staging and removals', function () {
+    $root = atomicSceneProject();
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    applyAtomicRebuild($session);
+
+    $outcome = $session->commitChanges();
+
+    $map = require $root . '/assets/Maps/harbour/harbour.data.php';
+    expect($outcome['status'])->toBe('saved', json_encode($outcome['issues']))
+        ->and(array_keys(FieldPresentationCatalog::load($root . '/assets')->resources))->toBe(['lamp'])
+        ->and(array_column($map['worldObjects'], 'resource', 'id'))->toBe(['lamp-1' => 'lamp'])
+        ->and($map['events']['B']['subject'])->toBe(['kind' => 'world_object', 'id' => 'lamp-1'])
+        ->and(array_column($map['npcs'], 'id'))->not->toContain('lamp-stand-in')
+        ->and((require $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php')[0])
+            ->toMatchArray(['at' => ['kind' => 'world_object', 'id' => 'lamp-1'], 'offset' => ['y' => 1]])
+        ->and(is_file($root . '/assets/Events/lamp-duplicate.php'))->toBeFalse()
+        ->and(is_file($root . '/assets/Events/dresser-note.php'))->toBeTrue()
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('refuses the same rebuild when it leaves a defect, writing no category file and no map', function () {
+    $root = atomicSceneProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    applyAtomicRebuild($session);
+    // A marker painted with no event: the Engine refuses the map.
+    $map = $session->readMap('harbour');
+    $session->paint('harbour', $map['revision'], 'event', [[2, 5]], 'Z');
+
+    $outcome = $session->commitChanges();
+
+    expect($outcome['status'])->toBe('refused')
+        ->and(array_column($outcome['issues'], 'code'))->toContain('event.unmapped')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('keeps a landmark anchored to an event once the event is bound to a world object, renaming it in the same change set', function () {
+    [$root, $file, $session] = createLandmarkProject("'landmarks' => [['id' => 'trial', 'kind' => 'landmark', 'anchor' => ['event' => 'E']]],");
+    $session->beginChanges();
+    $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'old-tower');
+
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'name', 'index' => 0], 'Lamp Trial');
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], 'lamp-trial');
+    $outcome = $session->commitChanges();
+
+    $data = require $file;
+    expect($outcome['status'])->toBe('saved')
+        ->and(array_filter($outcome['issues'], static fn(array $issue): bool => str_contains($issue['message'], 'missing event')))->toBe([])
+        ->and($data['events']['E']['subject'])->toBe(['kind' => 'world_object', 'id' => 'old-tower'])
+        ->and($data['landmarks'][0])->toMatchArray(['id' => 'lamp-trial', 'anchor' => ['event' => 'E']])
+        ->and(json_encode($data['landmarks'][0]['name']))->toContain('Lamp Trial');
+});
+
+it('names every document a change set wrote, and the files each wrote', function () {
+    $root = atomicSceneProject();
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    applyAtomicRebuild($session);
+
+    $outcome = $session->commitChanges();
+
+    expect($outcome['status'])->toBe('saved', json_encode($outcome['issues']))
+        ->and($outcome['saved'])->toBe(['harbour'])
+        ->and($outcome['written'])->toBe([
+            'common_events' => ['assets/Events/lamp-duplicate.php'],
+            'field_resources' => ['assets/Data/Presentation/field.php'],
+            'cinematic harbour-lanterns' => ['assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php'],
+            'map harbour' => ['assets/Maps/harbour/harbour.data.php', 'assets/Maps/harbour/harbour.event.php'],
+        ])
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('writes nothing when the last document of a change set would not read back, and says so', function () {
+    $root = atomicSceneProject();
+    // A value the map's file makes anew each time it is read: the staged
+    // data can never read back as the map the editor holds.
+    $mapPath = $root . '/assets/Maps/harbour/harbour.data.php';
+    file_put_contents($mapPath, str_replace("return array (", "return array (\n  'readAt' => hrtime(true),", (string) file_get_contents($mapPath)));
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    applyAtomicRebuild($session);
+
+    $outcome = $session->commitChanges();
+
+    expect($outcome['status'])->toBe('refused')
+        ->and($outcome['blocking'])->toBe(0)
+        ->and($outcome['reason'])->toStartWith('Nothing was written: map harbour: ')
+        ->and($outcome['reason'])->toContain('would not read back as the edited map')
+        ->and($outcome['saved'])->toBe([])
+        ->and($outcome['written'])->toBe([])
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('writes a change set\'s documents and the save relocations its map needs together, and undo removes the step', function () {
+    $root = atomicSceneProject();
+    // The quay is where the game starts, so saves may stand on its floor.
+    file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::SOLID, '=' => CollisionType::SOLID, ' ' => CollisionType::NONE];\n");
+    file_put_contents($root . '/assets/Data/system.php', " ['player' => "
+        . "['destinationMap' => 'harbour', 'spawnPoint' => ['x' => 5, 'y' => 3], 'spawnSprite' => ['South']]]];\n");
+    $manifestPath = $root . '/assets/Data/save-compatibility.php';
+    $manifest = (string) file_get_contents($manifestPath);
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    applyAtomicRebuild($session);
+    $map = $session->readMap('harbour');
+    $session->paint('harbour', $map['revision'], $map['baseLayer'], [[6, 4]], '#');
+
+    $outcome = $session->commitChanges(relocateSaves: true);
+
+    // The lamp's cell (10, 3) is new to event B, now bound to it, so its saves move off it as the new wall's do.
+    expect($outcome)->toMatchArray(['status' => 'saved', 'saved' => ['harbour'], 'relocated' => ['x 10, y 3 to x 10, y 4', 'x 6, y 4 to x 6, y 5']])
+        ->and(array_keys($outcome['written']))->toBe(['common_events', 'field_resources', 'cinematic harbour-lanterns', 'save relocations for harbour', 'map harbour'])
+        ->and((require $manifestPath)['migrations'])->toBe([['from' => 0, 'to' => 1,
+            'relocations' => [['map' => 'harbour', 'cells' => [[10, 3]], 'to' => [10, 4]], ['map' => 'harbour', 'cells' => [[6, 4]], 'to' => [6, 5]]]]])
+        ->and($session->listUnsavedChanges())->toBe([]);
+
+    $session->undo();
+    expect(file_get_contents($manifestPath))->toBe($manifest);
+});
diff --git a/tests/Unit/Session/AuthoringCanvasTest.php b/tests/Unit/Session/AuthoringCanvasTest.php
new file mode 100644
index 00000000..59afaa55
--- /dev/null
+++ b/tests/Unit/Session/AuthoringCanvasTest.php
@@ -0,0 +1,100 @@
+ ['wood-sign' => ['name' => 'Wood Sign',"
+        . " 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sign' => ['asset' => 'Graphics/Boards/Wood.png', 'fontRole' => 'sign', 'left' => 8,"
+        . " 'right' => 8, 'density' => 1, 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 160]]]];\n");
+    file_put_contents($root . '/assets/Data/Presentation/text.php', " 'ichiloto.text/1', 'roles' => ['sign' => ["
+        . "'fonts' => [['asset' => 'Fonts/Open.ttf']], 'fontSize' => 12, 'foreground' => ['kind' => 'rgb', 'r' => 230, 'g' => 215, 'b' => 170]]]];\n");
+    $session = EditorSession::open($root);
+    $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'inn-sign', 1, 1, 'wood-sign', 'Inn');
+
+    return [$root, $session];
+}
+
+/** The window's measurement of a label it was asked to lay out. */
+function canvasTextPrepared(string $requestId): string
+{
+    return json_encode(['protocol' => 2, 'type' => 'text_prepared', 'requestId' => $requestId, 'layoutId' => 'layout-inn',
+        'metrics' => ['advance' => 18, 'baseline' => 9, 'lineBounds' => ['x' => 0, 'y' => 0, 'width' => 18, 'height' => 12],
+            'inkBounds' => ['x' => 0, 'y' => 1, 'width' => 18, 'height' => 10], 'paintBounds' => ['x' => 0, 'y' => 1, 'width' => 18, 'height' => 10]]],
+        JSON_THROW_ON_ERROR);
+}
+
+it('draws the map only once the window is ready, under one session for the open map', function () {
+    [, $session] = createCanvasSignProject();
+    $first = $session->exchangeAuthoringCanvas('test-map', []);
+
+    expect(findCanvasMessages($first, 'frame'))->toBe([])
+        ->and($first)->toMatchArray(['map' => 'test-map', 'grid' => ['columns' => 4, 'rows' => 2, 'cellWidth' => 48, 'cellHeight' => 48]]);
+    $ready = $session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: $first['sessionId']);
+
+    expect($ready['sessionId'])->toBe($first['sessionId'])
+        ->and(findCanvasMessages($ready, 'frame'))->not->toBe([])
+        ->and($ready['assetPaths'])->toContain('Graphics/Boards/Wood.png');
+    $session->closeAuthoringCanvas('test-map');
+});
+
+it('asks the window to lay out a sign\'s label, and draws its board once the label is measured', function () {
+    [, $session] = createCanvasSignProject();
+    $first = $session->exchangeAuthoringCanvas('test-map', []);
+    $ready = $session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: $first['sessionId']);
+    $request = findCanvasMessages($ready, 'prepare_text')[0] ?? null;
+
+    expect($request['payload']['spec']['text'] ?? null)->toBe('Inn')
+        ->and(json_encode($ready['messages']))->not->toContain('world_canvas');
+    $measured = $session->exchangeAuthoringCanvas('test-map', [canvasTextPrepared($request['payload']['requestId'])], sessionId: $first['sessionId']);
+
+    expect(json_encode($measured['messages']))->toContain('world-object:test-map:inn-sign')->toContain('layout-inn');
+    $session->closeAuthoringCanvas('test-map');
+});
+
+it('sends nothing while the map is unchanged, and the edit once it changes', function () {
+    [, $session] = createCanvasSignProject();
+    $first = $session->exchangeAuthoringCanvas('test-map', []);
+    $ready = $session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: $first['sessionId']);
+    $request = findCanvasMessages($ready, 'prepare_text')[0];
+    $measured = $session->exchangeAuthoringCanvas('test-map', [...acknowledgeCanvasFrames($ready),
+        canvasTextPrepared($request['payload']['requestId'])], sessionId: $first['sessionId']);
+    $settled = $session->exchangeAuthoringCanvas('test-map', acknowledgeCanvasFrames($measured), sessionId: $first['sessionId']);
+
+    expect(findCanvasMessages($settled, 'frame'))->toBe([]);
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#');
+    $edited = $session->exchangeAuthoringCanvas('test-map', [], sessionId: $first['sessionId']);
+
+    expect(findCanvasMessages($edited, 'frame'))->toHaveCount(1)
+        ->and($edited['revision'])->toBeGreaterThan($map['revision']);
+    $session->closeAuthoringCanvas('test-map');
+});
+
+it('ignores answers meant for an earlier canvas, and releases a closed one', function () {
+    [, $session] = createCanvasSignProject();
+    $first = $session->exchangeAuthoringCanvas('test-map', []);
+
+    // A READY for a session this canvas is not is not applied: nothing is drawn.
+    expect(findCanvasMessages($session->exchangeAuthoringCanvas('test-map', [canvasWindowReady()], sessionId: 'stale'), 'frame'))->toBe([])
+        ->and($session->closeAuthoringCanvas('test-map'))->toBe(['closed' => true])
+        ->and($session->closeAuthoringCanvas('test-map'))->toBe(['closed' => false])
+        ->and($session->exchangeAuthoringCanvas('test-map', [])['sessionId'])->not->toBe($first['sessionId']);
+    $session->closeAuthoringCanvas('test-map');
+});
diff --git a/tests/Unit/Session/BattleArenaSessionTest.php b/tests/Unit/Session/BattleArenaSessionTest.php
new file mode 100644
index 00000000..dee908f4
--- /dev/null
+++ b/tests/Unit/Session/BattleArenaSessionTest.php
@@ -0,0 +1,211 @@
+ \$arena('Road', 'Road.png'), 'arena.cave' => \$arena('Cave', 'Cave.png')],\n", "    defaultArena: 'arena.cave',\n"],
+        ["    arenas: [],\n", ''], (string) file_get_contents($battle)));
+    $path = $root . '/assets/Data/Presentation/arenas.php';
+    file_put_contents($path, <<<'PHP_SOURCE'
+ 'arena.cave',
+    'arenas' => [
+        'arena.road' => ['name' => 'Road', 'background' => ['image' => 'Graphics/Battlebacks/Road.png'], 'ground' => ['top' => 300]],
+        'arena.cave' => ['name' => 'Cave', 'background' => ['image' => 'Graphics/Battlebacks/Cave.png']],
+    ],
+];
+PHP_SOURCE);
+
+    return [$root, $path, EditorSession::open($root)];
+}
+
+function findArenaRow(EditorSession $session, int $index, string $field): ?array
+{
+    return array_find($session->readDatabaseRecord(BattleArenaFields::CATEGORY, $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+it('lists battle arenas as a graphical category and reads each arena\'s background and ground', function () {
+    [, , $session] = createBattleArenaProject();
+    $keys = array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories());
+
+    expect($keys)->toContain(BattleArenaFields::CATEGORY, BattleArenaFields::DEFAULT_CATEGORY)
+        ->and(array_map(static fn($category): string => $category->key, DatabaseCatalog::all()))->not->toContain(BattleArenaFields::CATEGORY)
+        ->and(findArenaRow($session, 0, 'ground.top')['value'])->toBe('300')
+        ->and(findArenaRow($session, 1, 'ground.top')['value'])->toBe('(the battle theme\'s)')
+        ->and($session->readTroopFormation(0)['arena'])->toBe('arena.cave')
+        ->and($session->readTroopFormation(0, 'arena.road')['ground'])->toMatchArray(['top' => 300.0, 'bottom' => 600.0]);
+});
+
+it('marks where an arena\'s ground begins as one step, keeps the file\'s comments and default, and stands troops on it', function () {
+    [, $path, $session] = createBattleArenaProject();
+    $before = $session->readTroopFormation(0, 'arena.cave')['members'][0]['battler']['ground'];
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 1, findArenaRow($session, 1, 'ground.top')['key'], '250');
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 1, findArenaRow($session, 1, 'ground.perspective')['key'], '0.9');
+    $session->saveDatabase(BattleArenaFields::CATEGORY);
+    $written = (string) file_get_contents($path);
+
+    expect($written)->toContain('// Each background and where its ground begins.', "'default' => 'arena.cave'")
+        ->and((require $path)['arenas']['arena.cave']['ground'])->toBe(['top' => 250.0, 'perspective' => 0.9])
+        // A placed member keeps its depth; perspective scales it by how far back it stands.
+        ->and($session->readTroopFormation(0, 'arena.cave')['members'][0]['battler']['ground'])->toEqual($before)
+        ->and($session->readTroopFormation(0, 'arena.cave')['ground'])->toMatchArray(['top' => 250.0, 'perspective' => 0.9]);
+});
+
+it('refuses a ground the battle could not stand battlers on, before anything is written', function () {
+    [, $path, $session] = createBattleArenaProject();
+    $file = (string) file_get_contents($path);
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, findArenaRow($session, 0, 'ground.bottom')['key'], '200');
+
+    expect(fn() => $session->saveDatabase(BattleArenaFields::CATEGORY))->toThrow(SessionRefusal::class, 'arenas.arena.road.ground')
+        ->and(file_get_contents($path))->toBe($file);
+});
+
+it('adds an arena on the project\'s first image under the id it is created with, and offers it to every arena picker', function () {
+    [$root, $path, $session] = createBattleArenaProject();
+    $index = $session->createDatabaseRecord(BattleArenaFields::CATEGORY, 'arena.marsh-edge')['index'];
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, $index, findArenaRow($session, $index, 'name')['key'], 'Marsh Edge');
+    $session->saveDatabase(BattleArenaFields::CATEGORY);
+
+    expect((require $path)['arenas']['arena.marsh-edge'])->toBe(['name' => 'Marsh Edge', 'background' => ['image' => 'Graphics/Battlebacks/Cave.png']])
+        ->and(findArenaRow($session, $index, 'id'))->toMatchArray(['value' => 'arena.marsh-edge', 'kind' => 'info'])
+        ->and(array_column(EditorSession::open($root)->readTroopFormation(0)['arenas'], 'id'))
+        ->toBe(['arena.road', 'arena.cave', 'arena.marsh-edge']);
+});
+
+it('previews an arena with its ground, the starting party and a troop standing on it, unsaved edits included', function () {
+    [, , $session] = createBattleArenaProject();
+    $preview = $session->readArenaPreview(0);
+
+    expect($preview['arena'])->toBe('arena.road')
+        ->and($preview['ground'])->toMatchArray(['top' => 300.0, 'bottom' => 600.0])
+        ->and($preview['troop'])->toBe(0)->and($preview['troops'])->toBe(['Pair'])
+        ->and($preview['party'][0]['ground']['y'])->toBeLessThanOrEqual(600.0)
+        ->and(array_map(static fn(array $member): float => $member['battler']['ground']['y'], $preview['members']))
+        ->each->toBeGreaterThanOrEqual(300.0);
+
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, findArenaRow($session, 0, 'ground.top')['key'], '350');
+    expect($session->readArenaPreview(0)['ground']['top'])->toBe(350.0)
+        ->and(fn() => $session->readArenaPreview(7))->toThrow(SessionRefusal::class);
+});
+
+it('previews a ground edge as it is dragged, read as authored ground, without writing it', function () {
+    [, $path, $session] = createBattleArenaProject();
+    $before = (string) file_get_contents($path);
+    $dragged = $session->readArenaPreview(0, ground: ['top' => 420]);
+
+    // The troop stands on the dragged ground; the arena itself is unchanged, saved and unsaved.
+    expect($dragged['ground']['top'])->toBe(420.0)
+        ->and(array_map(static fn(array $member): float => $member['battler']['ground']['y'], $dragged['members']))->each->toBeGreaterThanOrEqual(420.0)
+        ->and($session->readArenaPreview(0)['ground']['top'])->toBe(300.0)
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and((string) file_get_contents($path))->toBe($before)
+        // A ground the battle would refuse is refused here too.
+        ->and(fn() => $session->readArenaPreview(0, ground: ['top' => 650]))->toThrow(SessionRefusal::class);
+});
+
+it('sets an arena\'s land footing as one step, stands the troop on it, and lists its points as rows', function () {
+    [, $path, $session] = createBattleArenaProject();
+    $road = [['x' => 400, 'y' => 300], ['x' => 1350, 'y' => 300], ['x' => 1350, 'y' => 600], ['x' => 600.5, 'y' => 600]];
+    expect($session->setArenaFooting(0, EnemyHabitat::LAND, $road)['changed'])->toBeTrue()
+        ->and($session->setArenaFooting(0, EnemyHabitat::LAND, $road)['changed'])->toBeFalse();
+    $preview = $session->readArenaPreview(0);
+
+    expect($preview['footing']['land'])->toBe([['x' => 400.0, 'y' => 300.0], ['x' => 1350.0, 'y' => 300.0],
+            ['x' => 1350.0, 'y' => 600.0], ['x' => 600.5, 'y' => 600.0]])
+        ->and($preview['footing']['water'])->toBe([])
+        ->and(findArenaRow($session, 0, 'landFootingList')['value'])->toBe('4')
+        ->and(findArenaRow($session, 0, 'landFooting3X')['value'])->toBe('600.5')
+        ->and(array_map(static fn(array $member): float => $member['battler']['ground']['x'], $preview['members']))
+        ->each->toBeGreaterThanOrEqual(400.0);
+
+    $session->saveDatabase(BattleArenaFields::CATEGORY);
+    expect((require $path)['arenas']['arena.road']['footing'])->toBe(['land' => $road])
+        ->and((string) file_get_contents($path))->toContain('// Each background and where its ground begins.');
+
+    // A point typed on its row is the same footing, read the same way.
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, findArenaRow($session, 0, 'landFooting0X')['key'], '420');
+    expect($session->readArenaPreview(0)['footing']['land'][0])->toBe(['x' => 420.0, 'y' => 300.0]);
+    $session->undo();
+    $session->undo();
+    expect($session->readArenaPreview(0)['footing']['land'])->toBe([]);
+});
+
+it('refuses a footing that is not a polygon or is for the air, and removes the footing when given no points', function () {
+    [, $path, $session] = createBattleArenaProject();
+    expect(fn() => $session->setArenaFooting(0, EnemyHabitat::WATER, [['x' => 1, 'y' => 1], ['x' => 2, 'y' => 2]]))
+        ->toThrow(SessionRefusal::class, 'footing')
+        ->and(fn() => $session->setArenaFooting(0, EnemyHabitat::WATER, [['x' => 1, 'y' => 'low'], ['x' => 2, 'y' => 2], ['x' => 3, 'y' => 1]]))
+        ->toThrow(SessionRefusal::class, 'Water Footing[0]')
+        ->and(fn() => $session->setArenaFooting(0, EnemyHabitat::AIR, [['x' => 1, 'y' => 1], ['x' => 9, 'y' => 1], ['x' => 9, 'y' => 9]]))
+        ->toThrow(SessionRefusal::class, 'air battlers stand over either');
+    $session->setArenaFooting(0, EnemyHabitat::WATER, [['x' => 0, 'y' => 300], ['x' => 600, 'y' => 300], ['x' => 600, 'y' => 600]]);
+    $session->setArenaFooting(0, EnemyHabitat::WATER, []);
+    $session->saveDatabase(BattleArenaFields::CATEGORY);
+
+    expect((require $path)['arenas']['arena.road'])->not->toHaveKey('footing');
+});
+
+it('stands an enemy where its habitat lives, authored on its record', function () {
+    [$root] = createBattleArenaProject();
+    writeRegularBatEnemy($root);
+    $session = EditorSession::open($root);
+    $water = [['x' => 0, 'y' => 300], ['x' => 300, 'y' => 300], ['x' => 300, 'y' => 600], ['x' => 0, 'y' => 600]];
+    $session->setArenaFooting(0, EnemyHabitat::WATER, $water);
+    $session->setArenaFooting(0, EnemyHabitat::LAND, [['x' => 300, 'y' => 300], ['x' => 1350, 'y' => 300], ['x' => 1350, 'y' => 600], ['x' => 300, 'y' => 600]]);
+    $habitat = array_find($session->readDatabaseRecord('enemies', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'habitat');
+    expect($habitat['value'])->toBe('land')
+        ->and(array_map(static fn(array $member): float => $member['battler']['ground']['x'], $session->readArenaPreview(0)['members']))
+        ->each->toBeGreaterThanOrEqual(300.0);
+
+    $session->applyDatabaseRecord('enemies', 0, $habitat['key'], 'water');
+    expect(array_map(static fn(array $member): float => $member['battler']['ground']['x'], $session->readArenaPreview(0)['members']))
+        ->each->toBeLessThanOrEqual(300.0);
+});
+
+it('sets an arena\'s light on battlers\' shadows, refusing a colour the battle cannot read before writing', function () {
+    [, $path, $session] = createBattleArenaProject();
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, findArenaRow($session, 0, 'shadow.strength')['key'], '1.5');
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, findArenaRow($session, 0, 'shadow.color')['key'], '#201008');
+    $session->saveDatabase(BattleArenaFields::CATEGORY);
+    expect((require $path)['arenas']['arena.road']['shadow'])->toBe(['strength' => 1.5, 'color' => '#201008']);
+
+    $file = (string) file_get_contents($path);
+    $session->applyDatabaseRecord(BattleArenaFields::CATEGORY, 0, findArenaRow($session, 0, 'shadow.color')['key'], 'brown');
+    expect(fn() => $session->saveDatabase(BattleArenaFields::CATEGORY))->toThrow(SessionRefusal::class, 'arenas.arena.road.shadow.color')
+        ->and(file_get_contents($path))->toBe($file);
+});
+
+it('previews a footing as its corner is dragged, read as authored footing, without writing it', function () {
+    [, $path, $session] = createBattleArenaProject();
+    $before = (string) file_get_contents($path);
+    $water = [['x' => 0, 'y' => 300], ['x' => 300, 'y' => 300], ['x' => 300, 'y' => 600], ['x' => 0, 'y' => 600]];
+    $dragged = $session->readArenaPreview(0, footing: ['land' => [['x' => 500, 'y' => 300], ['x' => 1350, 'y' => 300], ['x' => 1350, 'y' => 600], ['x' => 500, 'y' => 600]], 'water' => $water]);
+
+    expect($dragged['footing']['land'][0])->toBe(['x' => 500.0, 'y' => 300.0])
+        ->and(array_map(static fn(array $member): float => $member['battler']['ground']['x'], $dragged['members']))->each->toBeGreaterThanOrEqual(500.0)
+        ->and($session->readArenaPreview(0)['footing']['land'])->toBe([])
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and((string) file_get_contents($path))->toBe($before)
+        ->and(fn() => $session->readArenaPreview(0, footing: ['land' => [['x' => 1, 'y' => 1]]]))->toThrow(SessionRefusal::class);
+});
diff --git a/tests/Unit/Session/BattleTestSessionTest.php b/tests/Unit/Session/BattleTestSessionTest.php
new file mode 100644
index 00000000..f493f0e1
--- /dev/null
+++ b/tests/Unit/Session/BattleTestSessionTest.php
@@ -0,0 +1,124 @@
+describeBattleTest();
+
+    expect($test['source'])->toBe('starting party')
+        ->and($test['troops'])->toBe(['Pair'])
+        ->and(array_column($test['members'], 'actor'))->toBe(['Kaelion'])
+        ->and($test['members'][0]['maxLevel'])->toBeInt()
+        ->and(array_column($test['members'][0]['equipment'], 'slot'))->toContain('Weapon')
+        ->and($test['members'][0]['equipment'][0]['choices'][0])->toBe(['id' => null, 'name' => '(None)'])
+        ->and($test['members'][0]['choices']['commands'][0]['id'])->toBeNull()
+        ->and($test['problems'])->toBe([])
+        ->and($test['issue'])->toBeNull();
+});
+
+it('sets the battle test in system data as one undo step, and the previews stand its party', function () {
+    $root = battleTestProject();
+    $file = $root . '/assets/Data/system.php';
+    $before = (string) file_get_contents($file);
+    $session = EditorSession::open($root);
+
+    $test = $session->applyBattleTest(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]);
+    expect($test['source'])->toBe('battle test')
+        ->and($test['troop'])->toBe('Pair')
+        ->and($test['members'][0]['level'])->toBe(3)
+        ->and($session->readTroopFormation(0)['partySource'])->toBe('battle test');
+
+    $session->saveDatabase('system');
+    expect((require $file)[0][ProjectBattleTest::SYSTEM_KEY] ?? (require $file)[ProjectBattleTest::SYSTEM_KEY] ?? null)
+        ->toBe(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]]);
+
+    $session->undo();
+    $session->saveDatabase('system');
+    expect(file_get_contents($file))->toBe($before)
+        ->and($session->readTroopFormation(0)['partySource'])->toBe('starting party');
+});
+
+it('refuses what the Engine would not read, changing nothing', function () {
+    $root = battleTestProject();
+    $session = EditorSession::open($root);
+
+    expect(fn() => $session->applyBattleTest(['members' => [['actor' => 'Kaelion']]]))->toThrow(SessionRefusal::class, 'whole-number level')
+        ->and(fn() => $session->applyBattleTest(['party' => []]))->toThrow(SessionRefusal::class, 'has no party')
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('names the problems a battle test party has, and the validator warns of them and of a missing troop', function () {
+    $root = battleTestProject();
+    $session = EditorSession::open($root);
+    $test = $session->applyBattleTest(['troop' => 'Nobody', 'members' => [['actor' => 'Kaelion', 'level' => 999]]]);
+    $session->saveDatabase('system');
+    $issues = array_map(static fn($issue): string => $issue->message, new BattleTestValidator()->validate(ProjectWorkspace::fromProject($root)));
+
+    expect(implode(' ', $test['problems']))->toContain('level 999 is beyond its highest')
+        ->and(implode(' ', $issues))->toContain('The battle test troop Nobody is not among the troops.')
+        ->and(implode(' ', $issues))->toContain('level 999 is beyond its highest')
+        ->and(fn() => $session->startBattleTest())->toThrow(SessionRefusal::class);
+});
+
+it('refuses to start without a troop, or while a database a battle reads is unsaved', function () {
+    $root = battleTestProject();
+    $session = EditorSession::open($root);
+
+    expect(fn() => $session->startBattleTest())->toThrow(SessionRefusal::class, 'Choose a troop');
+
+    $placement = array_find($session->readDatabaseRecord('troops', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'member0GraphicalPlacement');
+    $session->applyDatabaseRecord('troops', 0, $placement['key'], '300, 300, 275, 190');
+    expect(fn() => $session->startBattleTest(0))->toThrow(SessionRefusal::class, 'first; the battle reads them on disk');
+});
+
+it('plays a battle test from the project root with the battle test as it stands, never writing the project', function () {
+    $root = battleTestProject();
+    $system = $root . '/assets/Data/system.php';
+    $before = (string) file_get_contents($system);
+    // The stand-in console reports the launch context it was given, then its other arguments.
+    $console = $root . '/fake-console.php';
+    file_put_contents($console, 'applyBattleTest(['members' => [['actor' => 'Kaelion', 'level' => 3]]]);
+        $session->startBattleTest(0);
+        $deadline = microtime(true) + 5;
+        while ($session->describeBattleTestRun()['running'] && microtime(true) < $deadline) {
+            usleep(20000);
+        }
+        $log = (string) $session->describeBattleTestRun()['log'];
+        $context = json_decode(substr($log, 0, strpos($log, ' play ')), true);
+
+        expect($context['battleTest'])->toEqual(['troop' => 'Pair', 'members' => [['actor' => 'Kaelion', 'level' => 3]]])
+            ->and($context)->not->toHaveKey('mode')
+            ->and($log)->toContain(' play --no-tmux --no-interaction --renderer=gpui --terminal-window -d ' . realpath($root))
+            ->and(is_dir($context['dataRoot']))->toBeFalse()
+            ->and(file_get_contents($system))->toBe($before);
+    } finally {
+        putenv('ICHILOTO_CONSOLE_BIN');
+    }
+});
+
+it('describes a draft the dialog is editing without writing it', function () {
+    $root = battleTestProject();
+    $session = EditorSession::open($root);
+    $draft = $session->describeBattleTest(['members' => [['actor' => 'Kaelion', 'level' => 5]]]);
+
+    expect($draft['source'])->toBe('battle test')
+        ->and($draft['members'][0]['level'])->toBe(5)
+        ->and($session->describeBattleTest()['source'])->toBe('starting party')
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
diff --git a/tests/Unit/Session/BattlerArtSessionTest.php b/tests/Unit/Session/BattlerArtSessionTest.php
new file mode 100644
index 00000000..61338021
--- /dev/null
+++ b/tests/Unit/Session/BattlerArtSessionTest.php
@@ -0,0 +1,206 @@
+describeBattlerArt('enemies', 'Regular Bat'))->toBe(['category' => 'battler_enemies', 'index' => null, 'owner' => null]);
+
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+    setBattlerRow($session, $index, 'artwork.pivot', '0.25, 1');
+
+    // The preview shows the art as set, before it is saved.
+    expect($session->readEnemyPreview(0)['formation']['members'][0]['battler']['image']['asset'] ?? null)->toBe('Graphics/Enemies/Bat.png')
+        ->and(file_exists($root . '/assets/' . BattlerBindings::FILE))->toBeFalse();
+
+    $session->saveDatabase('battler_enemies');
+
+    expect(BattlerBindings::load($root . '/assets')->enemies['Regular Bat'])->toEqual(new BattlerArtwork('Graphics/Enemies/Bat.png', 40, 80, 10, 80))
+        ->and($session->describeBattlerArt('enemies', 'Regular Bat'))->toBe(['category' => 'battler_enemies', 'index' => 0, 'owner' => 'data']);
+});
+
+it('keeps poses as the role-keyed map the battle reads', function () {
+    $root = battlerArtProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+    $heading = array_find($session->readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['listHeading'] ?? false) === true);
+    $session->addDatabaseItem('battler_enemies', $index, $heading['key'], true);
+    setBattlerRow($session, $index, 'pose0Role', 'attack');
+    setBattlerRow($session, $index, 'pose0Image', 'Graphics/Enemies/BatAttack.png');
+    $session->saveDatabase('battler_enemies');
+
+    $written = require $root . '/assets/' . BattlerBindings::FILE;
+
+    setBattlerRow($session, $index, 'pose0BakedShadow', 'true');
+    $session->saveDatabase('battler_enemies');
+    $written = require $root . '/assets/' . BattlerBindings::FILE;
+
+    expect($written['enemies']['Regular Bat']['poses'])->toBe(['attack' => ['image' => 'Graphics/Enemies/BatAttack.png', 'bakedShadow' => true]])
+        ->and(BattlerBindings::load($root . '/assets')->enemyPoses['Regular Bat']->roles)->toHaveKey('attack');
+});
+
+it('saves an enemy\'s battle art with the enemy, as its page edits both', function () {
+    $root = battlerArtProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+
+    expect($session->saveDatabase('enemies')['saved'])->toBeTrue()
+        ->and(BattlerBindings::load($root . '/assets')->enemies)->toHaveKey('Regular Bat')
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('shows the enemy category unsaved while its battle art is, since its Save writes both', function () {
+    $session = EditorSession::open(battlerArtProject());
+    expect($session->listDatabaseRecords('enemies'))->toMatchArray(['dirty' => false, 'unsavedCategories' => []]);
+
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+
+    expect($session->listDatabaseRecords('enemies'))->toMatchArray(['dirty' => true, 'unsavedCategories' => ['enemies']])
+        ->and($session->listDatabaseRecords('actors')['dirty'])->toBeFalse();
+    $session->saveDatabase('enemies');
+    expect($session->listDatabaseRecords('enemies'))->toMatchArray(['dirty' => false, 'unsavedCategories' => []]);
+});
+
+it('refuses to save art the battle could not read, writing nothing', function () {
+    $root = battlerArtProject();
+    $session = EditorSession::open($root);
+    $session->createDatabaseRecord('battler_enemies', 'Regular Bat');
+
+    expect(fn() => $session->saveDatabase('battler_enemies'))->toThrow(SessionRefusal::class, 'a battler needs artwork, poses or both')
+        ->and(file_exists($root . '/assets/' . BattlerBindings::FILE))->toBeFalse();
+});
+
+it('leaves art the presentation code binds to the code, and refuses a second owner for it', function () {
+    $root = battlerArtProject();
+    $battle = (string) file_get_contents($root . '/assets/Data/Presentation/battle.php');
+    file_put_contents($root . '/assets/Data/Presentation/battle.php', str_replace('    enemies: [],',
+        "    enemies: ['Regular Bat' => new Ichiloto\\Engine\\Battle\\Presentation\\BattlerArtwork('Graphics/Enemies/Bat.png', 40, 80, 20, 80)],", $battle));
+    $session = EditorSession::open($root);
+
+    expect($session->describeBattlerArt('enemies', 'Regular Bat'))->toMatchArray(['index' => null, 'owner' => 'code'])
+        ->and($session->describeBattlerArt('enemies', 'Regular Bat')['note'])->toContain('binds this art in code');
+
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+
+    expect(fn() => $session->saveDatabase('battler_enemies'))->toThrow(SessionRefusal::class, 'Regular Bat is bound both in');
+});
+
+it('makes one record per battler, and keeps battle art out of the terminal\'s category list', function () {
+    $session = EditorSession::open(battlerArtProject());
+    $session->createDatabaseRecord('battler_enemies', 'Regular Bat');
+
+    expect(fn() => $session->createDatabaseRecord('battler_enemies', 'Regular Bat'))->toThrow(SessionRefusal::class, 'Regular Bat already has enemy battle art.')
+        ->and(fn() => $session->createDatabaseRecord('battler_enemies'))->toThrow(SessionRefusal::class, 'Enemy battle art is made for what it belongs to')
+        ->and(array_column(array_map(static fn($category): array => (array) $category, DatabaseCatalog::all()), 'key'))->not->toContain('battler_enemies', 'battler_actors', 'battle_scale')
+        ->and(fn() => $session->describeBattlerArt('troops', 'Pair'))->toThrow(SessionRefusal::class, 'actors or enemies');
+});
+
+it('previews an actor in the party formation\'s front slot with its art as set, unsaved edits included', function () {
+    $root = battlerArtProject();
+    writeTilesetTestPng($root . '/assets/Graphics/Actors/Kaelion.png', 50, 100);
+    $session = EditorSession::open($root);
+    $actor = array_search('Kaelion', $session->listDatabaseRecords('actors')['records'], true);
+    $identity = $session->readActorPreview($actor)['identity'];
+
+    $art = $session->createDatabaseRecord('battler_actors', $identity)['index'];
+    $image = array_find($session->readDatabaseRecord('battler_actors', $art)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'artwork.image');
+    $session->applyDatabaseRecord('battler_actors', $art, $image['key'], 'Graphics/Actors/Kaelion.png');
+    $preview = $session->readActorPreview($actor);
+
+    expect($preview['formationIssue'])->toBeNull()
+        // A party of one stands in the formation's front slot.
+        ->and($preview['formation']['party'][0])->toMatchArray(['name' => 'Kaelion', 'ground' => ['x' => 1180.0, 'y' => 560.0]])
+        ->and($preview['formation']['party'][0]['image']['asset'] ?? null)->toBe('Graphics/Actors/Kaelion.png')
+        ->and($preview['formation']['members'])->toBe([])
+        ->and($session->describeBattlerArt('actors', $identity))->toMatchArray(['index' => $art, 'owner' => 'data']);
+});
+
+it('sets the ground point of a base image and the idle pose showing it as one undo step', function () {
+    $root = battlerArtProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+    $heading = array_find($session->readDatabaseRecord('battler_enemies', $index)['rows'], static fn(array $row): bool => ($row['listHeading'] ?? false) === true);
+    $session->addDatabaseItem('battler_enemies', $index, $heading['key'], true);
+    setBattlerRow($session, $index, 'pose0Image', 'Graphics/Enemies/Bat.png');
+    $both = [
+        ['key' => readBattlerRow($session, $index, 'pose0Pivot')['key'], 'value' => '0.25, 0.9'],
+        ['key' => readBattlerRow($session, $index, 'artwork.pivot')['key'], 'value' => '0.25, 0.9'],
+    ];
+
+    expect($session->applyDatabaseRecordValues('battler_enemies', $index, $both, 'Ground point')['changed'])->toBeTrue()
+        ->and(readBattlerRow($session, $index, 'pose0Pivot')['value'])->toBe('0.25, 0.9')
+        ->and(readBattlerRow($session, $index, 'artwork.pivot')['value'])->toBe('0.25, 0.9');
+
+    expect($session->undo()['label'])->toBe('Ground point')
+        ->and(readBattlerRow($session, $index, 'pose0Pivot')['value'])->toBe('')
+        ->and(readBattlerRow($session, $index, 'artwork.pivot')['value'])->toBe('');
+
+    $session->redo();
+    expect(readBattlerRow($session, $index, 'artwork.pivot')['value'])->toBe('0.25, 0.9');
+
+    // One refused value takes back the rest.
+    $refused = [['key' => readBattlerRow($session, $index, 'pose0Pivot')['key'], 'value' => '0.5, 0.5'],
+        ['key' => readBattlerRow($session, $index, 'artwork.pivot')['key'], 'value' => 'off the image']];
+    expect(fn() => $session->applyDatabaseRecordValues('battler_enemies', $index, $refused, 'Ground point'))->toThrow(SessionRefusal::class)
+        ->and(readBattlerRow($session, $index, 'pose0Pivot')['value'])->toBe('0.25, 0.9');
+});
+
+it('names unsaved battle art among the unsaved changes, as the status bar counts them', function () {
+    $root = battlerArtProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+    setBattlerRow($session, $index, 'artwork.pivot', '0.25, 1');
+
+    expect($session->listUnsavedChanges())->not->toBe([]);
+
+    $session->saveDatabase('battler_enemies');
+
+    expect($session->listUnsavedChanges())->toBe([]);
+});
+
+it('sets how a battler meets the surface: its body rises or sinks by its elevation, and its contact is declared', function () {
+    $root = battlerArtProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+    $grounded = $session->readEnemyPreview(0)['formation']['members'][0]['battler'];
+    setBattlerRow($session, $index, 'grounding.elevation', '0.4');
+    $risen = $session->readEnemyPreview(0)['formation']['members'][0]['battler'];
+    setBattlerRow($session, $index, 'grounding.elevation', '-0.2');
+    setBattlerRow($session, $index, 'grounding.contact', 'burrow');
+    $sunk = $session->readEnemyPreview(0)['formation']['members'][0]['battler'];
+
+    // The body rises or sinks; the feet stay where the battle stands it, and the burrow rings it there.
+    expect([$risen['ground'], $sunk['ground']])->toBe([$grounded['ground'], $grounded['ground']])
+        ->and($risen['offset'])->toBeGreaterThan(0.0)->and($sunk['offset'])->toBeLessThan(0.0)
+        ->and($grounded['bounds']['y'] - $risen['bounds']['y'])->toEqualWithDelta($risen['offset'], .000001)
+        ->and($sunk['contact']['kind'])->toBe('burrow')
+        ->and($sunk['contact']['y'] + $sunk['contact']['height'] / 2)->toEqualWithDelta($sunk['ground']['y'], .5);
+
+    $session->saveDatabase('battler_enemies');
+    expect((require $root . '/assets/' . BattlerBindings::FILE)['enemies']['Regular Bat']['grounding'])
+        ->toBe(['elevation' => -0.2, 'contact' => 'burrow']);
+    setBattlerRow($session, $index, 'grounding.contact', 'splash');
+    expect(fn() => $session->saveDatabase('battler_enemies'))->toThrow(SessionRefusal::class, 'grounding.contact');
+    // Art painting its own shadow stands at the height it was painted, with that shadow.
+    setBattlerRow($session, $index, 'grounding.contact', 'shadow');
+    setBattlerRow($session, $index, 'grounding.bakedShadow', 'true');
+    expect(fn() => $session->saveDatabase('battler_enemies'))->toThrow(SessionRefusal::class, 'stands at the height it was painted');
+});
diff --git a/tests/Unit/Session/BuildingAuthoringSessionTest.php b/tests/Unit/Session/BuildingAuthoringSessionTest.php
new file mode 100644
index 00000000..b30cd7ec
--- /dev/null
+++ b/tests/Unit/Session/BuildingAuthoringSessionTest.php
@@ -0,0 +1,332 @@
+getPixel($x, $y);
+}
+
+/** Brings the hut's art in, copies it into sheet C and defines the piece the walkthrough uses, as one author would. */
+function defineHutPiece(string $root, Closure $request): void
+{
+    $incoming = writeIncomingBuildingArt(dirname($root) . '/' . basename($root) . '-incoming');
+    rememberTemporaryProject(dirname($incoming));
+    expectAnswer($request('assets.import', ['source' => $incoming, 'destination' => 'Graphics/Incoming/Hut.png']));
+    expectAnswer($request('tilesets.copyCells', ['index' => 0, 'tileset' => 'town', 'source' => 'Graphics/Incoming/Hut.png',
+        'from' => [0, 0], 'size' => [3, 2], 'tile' => 263]));
+    expect(expectAnswer($request('tilesets.createPiece', ['index' => 0, 'tileset' => 'town', 'name' => 'Hut', 'layer' => 'buildings',
+        'tileLayer' => 'houses', 'rows' => getHutTiles()]))['id'])->toBe('hut');
+    foreach ([[0, 0, '/'], [1, 0, '^'], [2, 0, '\\'], [0, 1, '|'], [2, 1, '|']] as [$x, $y, $glyph]) {
+        expectAnswer($request('tilesets.setPieceGlyph', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'x' => $x, 'y' => $y, 'glyph' => $glyph]));
+    }
+    expectAnswer($request('tilesets.setPieceGlyph', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'x' => 1, 'y' => 1,
+        'glyph' => '⊓', 'color' => '#ffd75f']));
+    expectAnswer($request('tileset.setOccupancy', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'expected' => null,
+        'value' => [[null, null, null], [CollisionType::SOLID->value, CollisionType::SOLID->value, CollisionType::SOLID->value]]]));
+    expectAnswer($request('tilesets.setPieceDoor', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'x' => 1, 'y' => 1, 'entrance' => 'front']));
+    // The roof is dragged across in one step, so it draws above characters walking behind the building.
+    expectAnswer($request('tilesets.mark', ['index' => 0, 'mark' => 'above', 'tiles' => getHutTiles()[0]]));
+    expectAnswer($request('database.save', ['category' => 'tilesets']));
+}
+
+/** The town as the GUI reads it: its buildings glyphs, collision rows and events. */
+function readTown(Closure $request): array
+{
+    $map = expectAnswer($request('map.read', ['map' => 'town']));
+    $buildings = array_find($map['layers'], static fn(array $layer): bool => $layer['name'] === 'buildings');
+
+    return ['revision' => $map['revision'], 'glyphs' => array_map(static fn(array $row): string => implode('', $row), $buildings['rows']), 'occupancy' => $map['occupancy']['rows'],
+        'events' => array_column($map['events'], 'cells', 'marker')];
+}
+
+it('brings building art in through the asset picker, keeping its bytes, refusing to replace another image unasked, with undo', function () {
+    $root = createBuildingProject();
+    $request = createBuildingRpc($root);
+    $incoming = writeIncomingBuildingArt(dirname($root) . '/' . basename($root) . '-incoming');
+    rememberTemporaryProject(dirname($incoming));
+    $target = $root . '/assets/Graphics/Incoming/Hut.png';
+
+    $imported = expectAnswer($request('assets.import', ['source' => $incoming, 'destination' => 'Graphics/Incoming/Hut.png']));
+    expect($imported)->toMatchArray(['path' => 'Graphics/Incoming/Hut.png', 'width' => 6, 'height' => 4, 'changed' => true])
+        ->and(file_get_contents($target))->toBe(file_get_contents($incoming))
+        ->and(ReferenceCatalog::getPngAssets($root))->toContain('Graphics/Incoming/Hut.png');
+    // The same image again changes nothing; another one asks first.
+    expect(expectAnswer($request('assets.import', ['source' => $incoming, 'destination' => $target]))['changed'])->toBeFalse();
+    $other = dirname($incoming) . '/Other.png';
+    file_put_contents($other, PngPixels::createBlank(4, 4)->encode());
+    expect(expectRefusal($request('assets.import', ['source' => $other, 'destination' => 'Graphics/Incoming/Hut.png'])))
+        ->toBe('assets/Graphics/Incoming/Hut.png already holds another image. Choose another name, or replace it.');
+    expectAnswer($request('assets.import', ['source' => $other, 'destination' => 'Graphics/Incoming/Hut.png', 'replace' => true]));
+    expect(file_get_contents($target))->toBe(file_get_contents($other));
+    expectAnswer($request('history.undo'));
+    expect(file_get_contents($target))->toBe(file_get_contents($incoming));
+    expectAnswer($request('history.undo'));
+    expect(is_file($target))->toBeFalse()->and(is_dir(dirname($target)))->toBeFalse();
+    expectAnswer($request('history.redo'));
+    expect(file_get_contents($target))->toBe(file_get_contents($incoming));
+
+    // Only images the game draws come in, and only into the assets.
+    $text = dirname($incoming) . '/notes.png';
+    file_put_contents($text, 'not an image');
+    expect(expectRefusal($request('assets.import', ['source' => $text, 'destination' => 'Graphics/notes.png'])))->toBe('notes.png is not a PNG image.')
+        ->and(expectRefusal($request('assets.import', ['source' => $incoming, 'destination' => '../Hut.png'])))
+        ->toBe('../Hut.png is not a place inside the assets folder.')
+        ->and(expectRefusal($request('assets.import', ['source' => $incoming, 'destination' => dirname($root) . '/Hut.png'])))
+        ->toBe("Images are brought into the project's assets folder. Choose a place inside it.")
+        ->and(expectRefusal($request('assets.import', ['source' => $incoming, 'destination' => 'Graphics/Hut.jpg'])))
+        ->toContain('is not a PNG\'s name');
+});
+
+it('copies an image\'s cells into free cells of a sheet pixel for pixel, never over art, with undo and redo', function () {
+    $root = createBuildingProject();
+    $request = createBuildingRpc($root);
+    $incoming = writeIncomingBuildingArt(dirname($root) . '/' . basename($root) . '-incoming');
+    rememberTemporaryProject(dirname($incoming));
+    expectAnswer($request('assets.import', ['source' => $incoming, 'destination' => 'Graphics/Incoming/Hut.png']));
+    $sheet = $root . '/assets/Graphics/Tilesets/Town_C.png';
+    $before = (string) file_get_contents($sheet);
+
+    $free = expectAnswer($request('tilesets.freeCells', ['index' => 0, 'tileset' => 'town']));
+    expect($free['issues'])->toBe([])
+        ->and($free['free'])->not->toContain(TileId::EMPTY, TileId::C)
+        ->and($free['free'])->toContain(TileId::B + 1, TileId::C + 1, ...getHutTiles()[0]);
+    expect(expectAnswer($request('tilesets.imageCells', ['index' => 0, 'tileset' => 'town', 'source' => 'Graphics/Incoming/Hut.png'])))
+        ->toMatchArray(['tileSize' => 2, 'columns' => 3, 'rows' => 2, 'empty' => []]);
+
+    // Onto art, or the empty tile, nothing is copied.
+    expect(expectRefusal($request('tilesets.copyCells', ['index' => 0, 'tileset' => 'town', 'source' => 'Graphics/Incoming/Hut.png',
+        'from' => [0, 0], 'size' => [3, 2], 'tile' => TileId::C])))->toBe('Sheet C already has art in cell 0, 0. Copy into free cells only; nothing was copied.')
+        ->and(expectRefusal($request('tilesets.copyCells', ['index' => 0, 'tileset' => 'town', 'source' => 'Graphics/Incoming/Hut.png',
+            'from' => [0, 0], 'size' => [1, 1], 'tile' => TileId::EMPTY])))->toContain('empty tile')
+        ->and(expectRefusal($request('tilesets.copyCells', ['index' => 0, 'tileset' => 'town', 'source' => 'Graphics/Incoming/Hut.png',
+            'from' => [0, 0], 'size' => [3, 2], 'tile' => TileId::getPlainTileAt(TilesetSheet::C, 14, 0)])))->toContain('do not fit sheet C')
+        ->and(expectRefusal($request('tilesets.copyCells', ['index' => 0, 'tileset' => 'town', 'source' => 'Graphics/Incoming/Hut.png',
+            'from' => [2, 1], 'size' => [2, 1], 'tile' => 263])))->toContain('has no 2 x 1 cells at 2, 1')
+        ->and(file_get_contents($sheet))->toBe($before);
+
+    $copied = expectAnswer($request('tilesets.copyCells', ['index' => 0, 'tileset' => 'town', 'source' => 'Graphics/Incoming/Hut.png',
+        'from' => [0, 0], 'size' => [3, 2], 'tile' => 263]));
+    expect($copied)->toBe(['sheet' => 'C', 'asset' => 'Graphics/Tilesets/Town_C.png', 'tiles' => getHutTiles()]);
+    $art = PngPixels::decode((string) file_get_contents($incoming), 'art');
+    $written = PngPixels::decode((string) file_get_contents($sheet), 'sheet');
+    $original = PngPixels::decode($before, 'before');
+    for ($y = 0; $y < 32; $y++) {
+        for ($x = 0; $x < 32; $x++) {
+            $inside = $x >= 14 && $x < 20 && $y < 4;
+            expect($written->getPixel($x, $y))->toBe($inside ? $art->getPixel($x - 14, $y) : $original->getPixel($x, $y));
+        }
+    }
+    // The tiles the copy names draw those cells, as the Engine composes them.
+    expect(\Ichiloto\Engine\Rendering\Tilesets\TileComposer::compose(384, 2)[0][0]->x)->toBe(16);
+    expect(expectAnswer($request('tilesets.freeCells', ['index' => 0, 'tileset' => 'town']))['free'])->not->toContain(...getHutTiles()[0]);
+
+    expectAnswer($request('history.undo'));
+    expect(file_get_contents($sheet))->toBe($before);
+    expectAnswer($request('history.redo'));
+    expect(PngPixels::decode((string) file_get_contents($sheet), 'sheet')->hasSamePixels($written))->toBeTrue();
+    // A sheet changed outside the Editor is not overwritten by undo.
+    file_put_contents($sheet, $before);
+    expect(expectRefusal($request('history.undo')))->toContain('changed outside the cell copy');
+});
+
+it('defines a building piece over its cells: tiles, a glyph per cell, footprint, roof above characters and a painted door, with undo', function () {
+    $root = createBuildingProject();
+    $request = createBuildingRpc($root);
+    $path = $root . '/assets/Data/Tilesets/town.php';
+    defineHutPiece($root, $request);
+
+    $piece = Tileset::load($root . '/assets', 'town')->pieces['hut'];
+    expect($piece->name)->toBe('Hut')
+        ->and($piece->layer)->toBe('buildings')
+        ->and($piece->glyphs)->toBe([['/', '^', '\\'], ['|', '⊓', '|']])
+        ->and($piece->tiles)->toBe(['houses' => array_map(static fn(array $row): array => array_map(strval(...), $row), getHutTiles())])
+        ->and($piece->occupancy)->toBe([[null, null, null], [CollisionType::SOLID, CollisionType::SOLID, CollisionType::SOLID]])
+        ->and($piece->doorway)->toBe([['x' => 1, 'y' => 1, 'entrance' => DoorwayEntrance::FRONT]])
+        ->and(Tileset::load($root . '/assets', 'town')->above)->toBe(getHutTiles()[0])
+        ->and(file_get_contents($path))->toContain('// The town\'s art.', '⊓');
+
+    // The tileset view lays the upper sheets out as their images do, and describes the piece cell by cell.
+    $preview = expectAnswer($request('tilesets.preview', ['index' => 0]));
+    $c = array_find($preview['palette']['tabs'], static fn(array $tab): bool => $tab['name'] === 'C');
+    expect($c['ids'][0])->toBe(array_map(static fn(int $column): int => TileId::getPlainTileAt(TilesetSheet::C, $column, 0), range(0, 15)))
+        ->and($preview['layerNames'])->toBe(['glyph' => ['buildings', 'terrain'], 'tiles' => ['houses']]);
+    $authored = array_find($preview['authoring'], static fn(array $entry): bool => $entry['id'] === 'hut');
+    expect($authored['cells'][1][1])->toBe(['symbol' => '⊓', 'color' => '#ffd75f'])
+        ->and($authored['doorway'])->toBe([['x' => 1, 'y' => 1, 'entrance' => 'front']]);
+
+    // Each cell edit is one undo step: the door, then a glyph, go back as they came.
+    expectAnswer($request('tilesets.setPieceDoor', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'x' => 1, 'y' => 1, 'entrance' => null]));
+    expectAnswer($request('tilesets.setPieceGlyph', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'x' => 1, 'y' => 1, 'glyph' => ' ']));
+    $cleared = array_find(expectAnswer($request('tilesets.preview', ['index' => 0]))['authoring'], static fn(array $entry): bool => $entry['id'] === 'hut');
+    expect($cleared['doorway'])->toBe([])->and($cleared['cells'][1][1]['symbol'])->toBe(' ');
+    expectAnswer($request('history.undo'));
+    expectAnswer($request('history.undo'));
+    expectAnswer($request('database.save', ['category' => 'tilesets']));
+    expect(Tileset::load($root . '/assets', 'town')->pieces['hut']->doorway)->toHaveCount(1)
+        ->and(file_get_contents($path))->toContain('⊓');
+
+    // Plain words for what cannot be.
+    expect(expectRefusal($request('tilesets.setPieceGlyph', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'x' => 0, 'y' => 0, 'glyph' => 'ab'])))
+        ->toBe('A cell holds one character that fills one terminal cell.')
+        ->and(expectRefusal($request('tilesets.setPieceDoor', ['index' => 0, 'tileset' => 'town', 'piece' => 'hut', 'x' => 3, 'y' => 0, 'entrance' => 'on'])))
+        ->toBe('The piece has no cell 3, 0.')
+        ->and(expectRefusal($request('tilesets.createPiece', ['index' => 0, 'tileset' => 'town', 'name' => '  ', 'layer' => 'buildings',
+            'tileLayer' => 'houses', 'rows' => [[263]]])))->toBe('Give the piece a name.');
+    // A second piece of the same name gets an id of its own.
+    expect(expectAnswer($request('tilesets.createPiece', ['index' => 0, 'tileset' => 'town', 'name' => 'Hut', 'layer' => 'buildings',
+        'tileLayer' => 'houses', 'rows' => [[263]]]))['id'])->toBe('hut-2');
+    expectAnswer($request('history.undo'));
+    expect(array_column(expectAnswer($request('tilesets.preview', ['index' => 0]))['authoring'], 'id'))->toBe(['hut']);
+});
+
+it('marks a dragged rectangle of tiles above characters, or takes the mark away, as one undo step', function () {
+    $root = createBuildingProject();
+    $request = createBuildingRpc($root);
+    $above = static fn(): array => expectAnswer($request('tilesets.preview', ['index' => 0]))['above'];
+    expectAnswer($request('tilesets.mark', ['index' => 0, 'mark' => 'above', 'tile' => 300]));
+    expect(expectAnswer($request('tilesets.mark', ['index' => 0, 'mark' => 'above', 'tiles' => [263, 300, 384]]))['tiles'])->toBe(3);
+    expect($above())->toBe([300, 263, 384]);
+    expectAnswer($request('history.undo'));
+    expect($above())->toBe([300]);
+    expectAnswer($request('history.redo'));
+    expectAnswer($request('tilesets.mark', ['index' => 0, 'mark' => 'above', 'tiles' => [263, 384], 'marked' => false]));
+    expect($above())->toBe([300])
+        ->and(expectRefusal($request('tilesets.mark', ['index' => 0, 'mark' => 'tables', 'tiles' => [263]])))->toContain('Only A2 autotiles can be tables');
+});
+
+it('places the building with its footprint, gives its painted door an entrance, moves and removes it whole, with undo and redo', function () {
+    $root = createBuildingProject();
+    $request = createBuildingRpc($root);
+    defineHutPiece($root, $request);
+    $town = readTown($request);
+
+    // Placing it writes its glyphs, its tiles and, asked, the collision its footprint gives, in one step.
+    $placed = expectAnswer($request('pieces.place', ['map' => 'town', 'revision' => $town['revision'], 'piece' => 'hut',
+        'from' => [2, 1], 'to' => [2, 1], 'footprint' => true]));
+    expect($placed)->toMatchArray(['changed' => true, 'copies' => 1, 'footprint' => 3]);
+    $town = readTown($request);
+    expect(array_slice($town['glyphs'], 1, 2))->toBe(['  /^\\   ', '  |⊓|   '])
+        ->and($town['occupancy'][1])->toBe([0, 0, 0, 0, 0, 0, 0, 0])
+        ->and($town['occupancy'][2])->toBe([0, 0, 1, 1, 1, 0, 0, 0]);
+
+    // Selected, it says where it stands and that its door has no entrance yet; validation says so too.
+    $found = expectAnswer($request('pieces.findPlaced', ['map' => 'town', 'x' => 3, 'y' => 2]))['pieces'];
+    expect($found)->toBe([['id' => 'hut', 'name' => 'Hut', 'x' => 2, 'y' => 1, 'width' => 3, 'height' => 2, 'footprint' => true,
+        'doors' => [['x' => 3, 'y' => 2, 'entrance' => 'front', 'entranceCell' => [3, 3], 'event' => null]]]])
+        ->and(expectAnswer($request('pieces.findPlaced', ['map' => 'town', 'x' => 0, 'y' => 0]))['pieces'])->toBe([]);
+    $doorway = static fn(): array => array_values(array_filter(expectAnswer($request('project.validate', ['maps' => ['town']]))['issues'],
+        static fn(array $issue): bool => ($issue['code'] ?? null) === 'object.doorway'));
+    expect(array_column($doorway(), 'message'))->toBe(["Piece 'hut' at 2,1 paints a door at 3,2 entered from the front, with no entrance at 3,3. At (3, 2)."]);
+    // The map on its own says the same, naming the cell so an editor can show it.
+    $checked = array_values(array_filter(expectAnswer($request('map.validate', ['map' => 'town']))['issues'],
+        static fn(array $issue): bool => $issue['code'] === 'object.doorway'));
+    expect($checked[0]['cells'])->toBe([[3, 2]])->and($checked[0]['hint'])->toContain('Place an entrance at each door');
+
+    // The door's entrance goes on the cell in front of it, then leads inside like any transfer.
+    $entrance = expectAnswer($request('pieces.addEntrance', ['map' => 'town', 'revision' => readTown($request)['revision'], 'piece' => 'hut',
+        'x' => 2, 'y' => 1, 'door' => 0]));
+    expect($entrance['cell'])->toBe([3, 3]);
+    expectAnswer($request('event.destination', ['map' => 'town', 'revision' => $entrance['revision'], 'marker' => $entrance['marker'],
+        'destination' => 'inside', 'x' => 1, 'y' => 1, 'heading' => 'North']));
+    expect($doorway())->toBe([])
+        ->and(expectAnswer($request('pieces.findPlaced', ['map' => 'town', 'x' => 2, 'y' => 1]))['pieces'][0]['doors'][0]['event'])->toBe($entrance['marker'])
+        ->and(expectRefusal($request('pieces.addEntrance', ['map' => 'town', 'revision' => readTown($request)['revision'], 'piece' => 'hut',
+            'x' => 2, 'y' => 1, 'door' => 0])))->toBe("Entrance {$entrance['marker']} already meets that door.");
+
+    // Moved, it takes its glyphs, tiles, collision and entrance with it, and leaves open ground behind.
+    $moved = expectAnswer($request('pieces.move', ['map' => 'town', 'revision' => readTown($request)['revision'], 'piece' => 'hut',
+        'from' => [2, 1], 'to' => [4, 2], 'footprint' => true]));
+    expect($moved['entrances'])->toBe([$entrance['marker']]);
+    $town = readTown($request);
+    expect(array_slice($town['glyphs'], 1, 3))->toBe(['        ', '    /^\\ ', '    |⊓| '])
+        ->and($town['occupancy'][2])->toBe([0, 0, 0, 0, 0, 0, 0, 0])
+        ->and($town['occupancy'][3])->toBe([0, 0, 0, 0, 1, 1, 1, 0])
+        ->and($town['events'][$entrance['marker']])->toBe([[5, 4]])
+        ->and(expectAnswer($request('pieces.findPlaced', ['map' => 'town', 'x' => 5, 'y' => 3]))['pieces'][0]['x'])->toBe(4)
+        ->and(expectAnswer($request('pieces.findPlaced', ['map' => 'town', 'x' => 3, 'y' => 2]))['pieces'])->toBe([])
+        ->and($doorway())->toBe([]);
+    $houses = array_find(expectAnswer($request('tiles.read', ['map' => 'town']))['layers'], static fn(array $layer): bool => $layer['name'] === 'houses');
+    expect(array_slice($houses['rows'][1], 2, 3))->toBe([0, 0, 0])
+        ->and(array_slice($houses['rows'][2], 4, 3))->toBe(getHutTiles()[0])
+        ->and(array_slice($houses['rows'][3], 4, 3))->toBe(getHutTiles()[1]);
+
+    // One undo puts it back where it stood, entrance and all; redo moves it again.
+    expectAnswer($request('history.undo'));
+    $back = readTown($request);
+    expect($back['occupancy'][2])->toBe([0, 0, 1, 1, 1, 0, 0, 0])->and($back['events'][$entrance['marker']])->toBe([[3, 3]]);
+    expectAnswer($request('history.redo'));
+    expect(readTown($request)['events'][$entrance['marker']])->toBe([[5, 4]]);
+
+    // Removed, it takes its glyphs, tiles and collision away; open ground is walkable again.
+    $removed = expectAnswer($request('pieces.remove', ['map' => 'town', 'revision' => readTown($request)['revision'], 'piece' => 'hut',
+        'x' => 4, 'y' => 2, 'footprint' => true]));
+    expect($removed['freed'])->toBe(3);
+    $town = readTown($request);
+    expect(implode('', $town['glyphs']))->toBe(str_repeat(' ', 48))
+        ->and(array_merge(...$town['occupancy']))->toBe(array_fill(0, 48, 0))
+        ->and(expectAnswer($request('pieces.findPlaced', ['map' => 'town', 'x' => 5, 'y' => 3]))['pieces'])->toBe([])
+        ->and(expectRefusal($request('pieces.remove', ['map' => 'town', 'revision' => $town['revision'], 'piece' => 'hut',
+            'x' => 4, 'y' => 2, 'footprint' => true])))->toBe('No whole Hut stands at 4, 2 any more. Nothing was changed.');
+    expectAnswer($request('history.undo'));
+    expect(readTown($request)['occupancy'][3])->toBe([0, 0, 0, 0, 1, 1, 1, 0]);
+
+    // Saved and opened again, the moved building is there as the game reads it.
+    expectAnswer($request('map.save', ['map' => 'town']));
+    $reopened = createBuildingRpc($root);
+    expect(expectAnswer($reopened('pieces.findPlaced', ['map' => 'town', 'x' => 6, 'y' => 3]))['pieces'][0])->toMatchArray(['x' => 4, 'y' => 2]);
+});
+
+it('refuses moves that cannot be whole and placement without a footprint leaves collision alone', function () {
+    $root = createBuildingProject();
+    $request = createBuildingRpc($root);
+    defineHutPiece($root, $request);
+    $placed = expectAnswer($request('pieces.place', ['map' => 'town', 'revision' => readTown($request)['revision'], 'piece' => 'hut',
+        'from' => [0, 0], 'to' => [0, 0]]));
+    expect($placed['footprint'])->toBe(0)->and(array_merge(...readTown($request)['occupancy']))->toBe(array_fill(0, 48, 0));
+    $before = readTown($request);
+    expect(expectRefusal($request('pieces.move', ['map' => 'town', 'revision' => $before['revision'], 'piece' => 'hut',
+        'from' => [0, 0], 'to' => [6, 0], 'footprint' => true])))->toContain('does not fit at (6, 0)');
+    expect(readTown($request)['glyphs'])->toBe($before['glyphs']);
+});
+
+it('writes a review page of a map as the game draws it, unsaved edits included, without changing the project', function () {
+    $root = createBuildingProject();
+    $request = createBuildingRpc($root);
+    defineHutPiece($root, $request);
+    expectAnswer($request('pieces.place', ['map' => 'town', 'revision' => readTown($request)['revision'], 'piece' => 'hut',
+        'from' => [2, 1], 'to' => [2, 1], 'footprint' => true]));
+    $out = rememberTemporaryProject(sys_get_temp_dir() . '/' . uniqid('editor-building-review-', true));
+    $before = sourceHashTree($root . '/assets');
+
+    $review = expectAnswer($request('map.review', ['map' => 'town', 'directory' => $out, 'region' => [1, 0, 5, 4], 'player' => [3, 3]]));
+    expect($review['page'])->toBe($out . '/review.html')
+        ->and(file_get_contents($review['page']))->toContain('and(is_file($review['files']['png']))->toBeTrue()
+        ->and(file_get_contents($review['files']['terminal-ans']))->toContain('⊓')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+    expect(expectRefusal($request('map.review', ['map' => 'town', 'directory' => $out, 'region' => [7, 5, 4, 4]])))->not->toBe('');
+});
diff --git a/tests/Unit/Session/CanvasToolSessionTest.php b/tests/Unit/Session/CanvasToolSessionTest.php
new file mode 100644
index 00000000..ee7040be
--- /dev/null
+++ b/tests/Unit/Session/CanvasToolSessionTest.php
@@ -0,0 +1,67 @@
+ Cells as "x,y", sorted, so the order a shape lists them in does not matter. */
+function listShapeCells(array $answer): array
+{
+    $cells = array_map(static fn(array $cell): string => "{$cell[0]},{$cell[1]}", $answer['cells']);
+    sort($cells);
+
+    return $cells;
+}
+
+it('widens a brush stroke by the brush and keeps it on the map', function () {
+    $session = canvasToolSession();
+
+    expect(listShapeCells($session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 3)))->toBe(['0,0', '0,1', '1,0', '1,1'])
+        ->and(listShapeCells($session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 1, [[0, 0], [1, 0], [2, 0]])))->toBe(['0,0', '1,0', '2,0'])
+        ->and(listShapeCells($session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 2, [[2, 0]])))->toBe(['2,0', '2,1', '3,0', '3,1']);
+});
+
+it('draws lines and rectangles between two corners, a filled rectangle never widened past them', function () {
+    $session = canvasToolSession();
+
+    expect(listShapeCells($session->getToolShape('test-map', 'line', [0, 0], [3, 0], 1)))->toBe(['0,0', '1,0', '2,0', '3,0'])
+        ->and(listShapeCells($session->getToolShape('test-map', 'rectangle', [3, 1], [0, 0], 1)))->toHaveCount(8)
+        ->and(listShapeCells($session->getToolShape('test-map', 'filled_rectangle', [1, 0], [2, 1], 5)))->toBe(['1,0', '1,1', '2,0', '2,1']);
+});
+
+it('refuses a tool that does not paint and a brush width the editors do not offer', function () {
+    $session = canvasToolSession();
+
+    expect(fn() => $session->getToolShape('test-map', 'select', [0, 0], [1, 1], 1))->toThrow(SessionRefusal::class, 'not a painting tool')
+        ->and(fn() => $session->getToolShape('test-map', 'brush', [0, 0], [0, 0], 4))->toThrow(SessionRefusal::class, 'cells wide');
+});
+
+it('fills the region of one glyph, or of one tile counting an autotile as its kind', function () {
+    $session = canvasToolSession();
+
+    expect(listShapeCells($session->getFillRegion('test-map', 2, 1, 'map:4', null)))->toBe(['1,1', '2,1'])
+        // A tile layer the map lacks is empty everywhere.
+        ->and(listShapeCells($session->getFillRegion('test-map', 0, 0, null, 'ground')))->toHaveCount(8);
+
+    // Two shapes of one autotile kind, and a plain tile beside them.
+    $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'ground', [[0, 0, 2048], [1, 0, 2049], [2, 0, 42]]);
+    expect(listShapeCells($session->getFillRegion('test-map', 0, 0, null, 'ground')))->toBe(['0,0', '1,0'])
+        ->and(fn() => $session->getFillRegion('test-map', 0, 0, null, null))->toThrow(SessionRefusal::class, 'Name the glyph layer or the tile layer');
+});
diff --git a/tests/Unit/Session/ChangeSetTest.php b/tests/Unit/Session/ChangeSetTest.php
new file mode 100644
index 00000000..99e24bfb
--- /dev/null
+++ b/tests/Unit/Session/ChangeSetTest.php
@@ -0,0 +1,376 @@
+ $holes
+ */
+function changeSetProject(array $holes = []): string
+{
+    $root = mapGraphicsProject();
+    $directory = $root . '/assets/Maps/test-map';
+    file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource("    \n    ", 'EVENTS'));
+    $terrain = ['....', '....'];
+    $floor = [[2816, 2816, 2816, 2816], [2816, 2816, 2816, 2816]];
+    $decor = [[0, 5, 0, 0], [0, 5, 5, 0]];
+    foreach ([[3, 1], ...$holes] as [$x, $y]) {
+        $terrain[$y][$x] = ' ';
+        $floor[$y][$x] = 0;
+        $decor[$y][$x] = 0;
+    }
+    file_put_contents($directory . '/layers/01.terrain.map.php', MapGridSource::buildSource(implode("\n", $terrain), 'AUTHORED'));
+    $rows = static fn(array $grid): string => implode("\n", array_map(static fn(array $row): string => implode(' ', $row), $grid));
+    writeTileLayer($directory, '01.floor.tiles.php', $rows($floor));
+    writeTileLayer($directory, '02.decor.tiles.php', $rows($decor));
+
+    return $root;
+}
+
+/** The id of a test map layer by its name. */
+function changeSetLayer(EditorSession $session, string $name): string
+{
+    return array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['name'] === $name)['id'];
+}
+
+/** Paints one glyph on the test map, at its current revision. */
+function changeSetPaint(EditorSession $session, string $layer, int $x, int $y, string $symbol): void
+{
+    $session->paint('test-map', $session->readMap('test-map')['revision'], changeSetLayer($session, $layer), [[$x, $y]], $symbol);
+}
+
+/** @return list The blocking and reported messages of a commit. */
+function changeSetMessages(array $outcome, string $severity = 'error'): array
+{
+    return array_values(array_map(static fn(array $issue): string => $issue['message'],
+        array_filter($outcome['issues'], static fn(array $issue): bool => $issue['severity'] === $severity)));
+}
+
+it('starts from a project whose test map has no errors', function () {
+    $session = EditorSession::open(changeSetProject());
+
+    expect($session->validateProject(['test-map'])['errors'])->toBe(0);
+});
+
+it('commits a change set that leaves its maps valid, through each map\'s own save', function () {
+    $root = changeSetProject();
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    changeSetPaint($session, 'buildings', 1, 0, ' ');
+
+    $outcome = $session->commitChanges();
+
+    expect($outcome['status'])->toBe('saved')
+        ->and($outcome['maps'])->toBe(['test-map'])
+        ->and($outcome['saved'])->toBe(['test-map'])
+        ->and($outcome['blocking'])->toBe(0)
+        ->and(MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe("    \n xx ")
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('refuses a change set that leaves a raw glyph showing, writes nothing and undoes it', function () {
+    $root = changeSetProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    changeSetPaint($session, 'buildings', 3, 1, 'x');
+
+    $outcome = $session->commitChanges();
+
+    expect($outcome['status'])->toBe('refused')
+        ->and($outcome['blocking'])->toBe(1)
+        ->and($outcome['reason'])->toBe('1 error the player would meet.')
+        ->and(changeSetMessages($outcome))->toBe(["Gameplay glyph 'x' on buildings has no graphical presentation. At (3, 1)."])
+        ->and($outcome['issues'][0]['map'])->toBe('test-map')
+        ->and($outcome['issues'][0]['hint'])->not->toBe('')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([])
+        // The change set is closed: another may begin.
+        ->and($session->beginChanges()['status'])->toBe('open');
+});
+
+it('refuses a change set on a map that already shows a raw glyph until that is fixed too', function () {
+    $root = changeSetProject(holes: [[2, 1]]);
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    changeSetPaint($session, 'buildings', 1, 0, ' ');
+
+    $refused = $session->commitChanges();
+    expect($refused['status'])->toBe('refused')
+        ->and(changeSetMessages($refused))->toBe(["Gameplay glyph 'x' on buildings has no graphical presentation. At (2, 1)."]);
+
+    // Fixing the defect in the same change set lets it through.
+    $session->beginChanges();
+    changeSetPaint($session, 'buildings', 1, 0, ' ');
+    changeSetPaint($session, 'buildings', 2, 1, ' ');
+    expect($session->commitChanges()['status'])->toBe('saved');
+});
+
+it('reports what committing would find without writing anything when only checking', function () {
+    $root = changeSetProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    changeSetPaint($session, 'buildings', 1, 0, ' ');
+
+    $checked = $session->commitChanges(checkOnly: true);
+
+    expect($checked['status'])->toBe('checked')
+        ->and($checked['blocking'])->toBe(0)
+        ->and($checked['saved'])->toBe([])
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('holds a change set to itself: no saves inside it, no undo past its start, and edits pending before it refuse it', function () {
+    $session = EditorSession::open(changeSetProject());
+    changeSetPaint($session, 'buildings', 1, 0, ' ');
+    expect(fn() => $session->beginChanges())->toThrow(SessionRefusal::class, 'Save or undo pending edits');
+
+    $session->undo();
+    $session->beginChanges();
+    expect(fn() => $session->saveMap('test-map'))->toThrow(SessionRefusal::class, 'Saving a map is not part of a change set')
+        ->and(fn() => $session->saveAll())->toThrow(SessionRefusal::class, 'is not part of a change set')
+        ->and(fn() => $session->undo())->toThrow(SessionRefusal::class, 'before the change set began')
+        ->and(fn() => $session->beginChanges())->toThrow(SessionRefusal::class, 'already open');
+
+    changeSetPaint($session, 'buildings', 1, 0, ' ');
+    expect($session->cancelChanges()['status'])->toBe('cancelled')
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('undoes what a refused change set wrote at once, such as inserted rows', function () {
+    $root = changeSetProject();
+    $before = sourceHashTree($root . '/assets');
+    $buildings = changeSetLayer(EditorSession::open($root), 'buildings');
+
+    $outcome = ChangeBatch::apply($root, [
+        ['method' => 'map.insertLines', 'params' => ['map' => 'test-map', 'axis' => 'y', 'at' => 2, 'count' => 1], 'answer' => 'write'],
+        ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[3, 1]], 'symbol' => 'x']],
+    ]);
+
+    expect($outcome['status'])->toBe('refused')
+        ->and(changeSetMessages($outcome))->toContain("Gameplay glyph 'x' on buildings has no graphical presentation. At (3, 1).")
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+});
+
+it('applies a batch of session requests as one change set, filling each map\'s revision as it stands', function () {
+    $root = changeSetProject();
+    $buildings = changeSetLayer(EditorSession::open($root), 'buildings');
+
+    $outcome = ChangeBatch::apply($root, [
+        ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[1, 0]], 'symbol' => ' ']],
+        ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[2, 1]], 'symbol' => ' ']],
+    ]);
+
+    expect($outcome['status'])->toBe('saved')
+        ->and($outcome['saved'])->toBe(['test-map'])
+        ->and(EditorSession::open($root)->readMap('test-map')['revision'])->toBeInt();
+    expect(MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe("    \n x  ");
+});
+
+it('names the step a batch was refused at, and writes nothing of it', function () {
+    $root = changeSetProject();
+    $before = sourceHashTree($root . '/assets');
+    $buildings = changeSetLayer(EditorSession::open($root), 'buildings');
+
+    $outcome = ChangeBatch::apply($root, [
+        ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[1, 0]], 'symbol' => ' ']],
+        ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => 'no-layer', 'cells' => [[0, 0]], 'symbol' => '#']],
+        ['method' => 'map.save', 'params' => ['map' => 'test-map']],
+    ]);
+
+    expect($outcome)->toMatchArray(['status' => 'failed', 'step' => 2, 'method' => 'map.paint'])
+        ->and($outcome['message'])->toContain('test-map has no layer no-layer.')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and(ChangeBatch::apply($root, [['method' => 'map.save', 'params' => ['map' => 'test-map']]])['message'])
+            ->toContain('Saving a map is not part of a change set')
+        ->and(ChangeBatch::apply($root, [['method' => 'changes.commit', 'params' => []]])['message'])
+            ->toContain('made by the batch itself');
+});
+
+it('reads a batch file as a list of changes or as {"changes": [...]}', function () {
+    $directory = rememberTemporaryProject(sys_get_temp_dir() . '/ichiloto-batch-' . bin2hex(random_bytes(4)));
+    mkdir($directory);
+    file_put_contents($directory . '/list.json', '[{"method": "maps.list", "params": {}}]');
+    file_put_contents($directory . '/object.json', '{"changes": [{"method": "maps.list", "params": {}}]}');
+    file_put_contents($directory . '/broken.json', '{"changes": 3}');
+
+    expect(ChangeBatch::readFile($directory . '/list.json'))->toBe([['method' => 'maps.list', 'params' => []]])
+        ->and(ChangeBatch::readFile($directory . '/object.json'))->toBe([['method' => 'maps.list', 'params' => []]])
+        ->and(fn() => ChangeBatch::readFile($directory . '/broken.json'))->toThrow(Ichiloto\Editor\Session\InvalidRequest::class, 'list of changes');
+});
+
+it('judges events made inside the change set: a chest with no reward is refused, one with a reward is written', function () {
+    $root = changeSetProject();
+    writeItemRecords($root, new Ichiloto\Engine\Entities\Inventory\Items\Item(id: 'item.tonic', name: 'Tonic', description: '', icon: 't', price: 5));
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    $created = $session->createEvent('test-map', $session->readMap('test-map')['revision'], [[0, 0]], 'Chest', 'C');
+    $loot = static fn(string $field): array => ['target' => 'event', 'marker' => 'C', 'path' => ['data', $field]];
+    $session->applyInspector('test-map', $created['revision'], $loot('lootType'), 'item');
+
+    $refused = $session->commitChanges();
+    expect($refused['status'])->toBe('refused')
+        ->and(array_column($refused['issues'], 'code'))->toContain('chest.reward')
+        ->and(array_column($refused['issues'], 'map'))->each->toBe('test-map');
+
+    $session->beginChanges();
+    $created = $session->createEvent('test-map', $session->readMap('test-map')['revision'], [[0, 0]], 'Chest', 'C');
+    $revision = $session->applyInspector('test-map', $created['revision'], $loot('lootType'), 'item')['revision'];
+    $session->applyInspector('test-map', $revision, $loot('loot'), 'item.tonic');
+
+    $saved = $session->commitChanges();
+    expect($saved['status'])->toBe('saved')
+        ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['C']['data']['loot'])->toBe('item.tonic');
+});
+
+it('asks a batch to choose the piece a glyph plays when several draw it, and paints it once chosen', function () {
+    $root = changeSetProject();
+    writeTestTileset($root, pieces: [
+        'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]],
+        'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['6']]],
+    ]);
+    $buildings = changeSetLayer(EditorSession::open($root), 'buildings');
+    $paint = ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[3, 1]], 'symbol' => 'x']];
+
+    $asked = ChangeBatch::apply($root, [$paint], checkOnly: true);
+    expect($asked['status'])->toBe('failed')
+        ->and($asked['message'])->toContain('Glyph x could be several pieces; give the change "choices": {"x": key}');
+
+    $paint['params']['choices'] = ['x' => 'crate:0:0'];
+    expect(ChangeBatch::apply($root, [$paint])['status'])->toBe('saved')
+        ->and(MapGridSource::readFile($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php'))->toBe("0 5 0 0\n0 5 5 6");
+});
+
+/**
+ * The change set test project with a second map that already shows a raw
+ * glyph (an error), and a skill a class names, so retiring it writes a file
+ * set that never touches a map.
+ */
+function sourceSetChangeSetProject(): string
+{
+    $root = changeSetProject();
+    $maps = $root . '/assets/Maps';
+    mkdir($maps . '/broken-map');
+    copyDirectoryRecursively($maps . '/test-map', $maps . '/broken-map');
+    foreach (glob($maps . '/broken-map/test-map.*') ?: [] as $file) {
+        rename($file, $maps . '/broken-map/broken-' . substr(basename($file), strlen('test-')));
+    }
+    $data = $maps . '/broken-map/broken-map.data.php';
+    file_put_contents($data, str_replace("'Test Map'", "'Broken Map'", (string) file_get_contents($data)));
+    // A hole under a building glyph: the glyph shows raw, an error found before any change set.
+    file_put_contents($maps . '/broken-map/layers/01.terrain.map.php', MapGridSource::buildSource("....\n..  ", 'AUTHORED'));
+    writeTileLayer($maps . '/broken-map', '01.floor.tiles.php', "2816 2816 2816 2816\n2816 2816 0 0");
+    writeTileLayer($maps . '/broken-map', '02.decor.tiles.php', "0 5 0 0\n0 5 0 0");
+    writeSkillRecords($root,
+        new Ichiloto\Engine\Entities\Skills\SpecialSkill('Old Cut', 'Cuts.', '', 2, 0),
+        new Ichiloto\Engine\Entities\Skills\SpecialSkill('New Cut', 'Cuts well.', '', 4, 0),
+    );
+    $classes = $root . '/assets/Data/classes.php';
+    file_put_contents($classes, preg_replace("/'name' => 'Vanguard',\n/",
+        "'name' => 'Vanguard',\n    'skillsToLearn' => [\n      ['level' => 2, 'skill' => 'Old Cut'],\n    ],\n", (string) file_get_contents($classes), 1));
+
+    return $root;
+}
+
+/** The batch change that retires Old Cut into New Cut, writing its file set at once. */
+function sourceSetRetirement(): array
+{
+    return ['method' => 'skills.retire', 'params' => ['index' => 0, 'replacement' => 'New Cut'], 'answer' => 'write'];
+}
+
+it('starts the file set project with an error on the second map only', function () {
+    $session = EditorSession::open(sourceSetChangeSetProject());
+
+    expect($session->validateProject(['test-map'])['errors'])->toBe(0)
+        ->and($session->validateProject(['broken-map'])['errors'])->toBeGreaterThan(0);
+});
+
+it('judges only the maps a batch changed when a step writes a file set that reloads the project', function () {
+    $root = sourceSetChangeSetProject();
+    $buildings = changeSetLayer(EditorSession::open($root), 'buildings');
+
+    $outcome = ChangeBatch::apply($root, [
+        sourceSetRetirement(),
+        ['method' => 'map.paint', 'params' => ['map' => 'test-map', 'layer' => $buildings, 'cells' => [[1, 0]], 'symbol' => ' ']],
+    ]);
+
+    expect($outcome['status'])->toBe('saved')
+        ->and($outcome['maps'])->toBe(['test-map'])
+        ->and($outcome['blocking'])->toBe(0)
+        ->and(array_column($outcome['issues'], 'map'))->not->toContain('broken-map')
+        ->and((require $root . '/assets/Data/classes.php')[0]['skillsToLearn'])->toBe([['level' => 2, 'skill' => 'New Cut']])
+        ->and(MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe("    \n xx ");
+    expect(ChangeBatch::apply($root, [['method' => 'skills.retire', 'params' => ['index' => 0], 'answer' => 'write']]))
+        ->toMatchArray(['status' => 'saved', 'maps' => [], 'blocking' => 0]);
+});
+
+it('still judges a map whose files a batch\'s file set rewrote, and an edit made to an untouched map', function () {
+    $root = sourceSetChangeSetProject();
+    $before = sourceHashTree($root . '/assets');
+    $buildings = changeSetLayer(EditorSession::open($root), 'buildings');
+
+    $rewritten = ChangeBatch::apply($root, [
+        ['method' => 'map.insertLines', 'params' => ['map' => 'broken-map', 'axis' => 'y', 'at' => 2, 'count' => 1], 'answer' => 'write'],
+    ]);
+    $edited = ChangeBatch::apply($root, [
+        sourceSetRetirement(),
+        ['method' => 'map.paint', 'params' => ['map' => 'broken-map', 'layer' => $buildings, 'cells' => [[1, 0]], 'symbol' => ' ']],
+    ]);
+
+    expect($rewritten['status'])->toBe('refused')
+        ->and($rewritten['maps'])->toBe(['broken-map'])
+        ->and(array_unique(array_column($rewritten['issues'], 'map')))->toBe(['broken-map'])
+        ->and($edited['status'])->toBe('refused')
+        ->and($edited['maps'])->toBe(['broken-map'])
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+});
+
+it('keeps the revision of every map a file set never wrote, through its undo and redo', function () {
+    $session = EditorSession::open(sourceSetChangeSetProject());
+    $revision = static fn(string $map): int => $session->readMap($map)['revision'];
+    [$test, $broken] = [$revision('test-map'), $revision('broken-map')];
+
+    $question = $session->retireSkill(0, 'New Cut');
+    $session->retireSkill(0, 'New Cut', 'write', $question['confirm']);
+    expect([$revision('test-map'), $revision('broken-map')])->toBe([$test, $broken])
+        ->and($session->undo())->toMatchArray(['maps' => [], 'revisions' => []])
+        ->and($session->redo())->toMatchArray(['maps' => [], 'revisions' => []])
+        ->and([$revision('test-map'), $revision('broken-map')])->toBe([$test, $broken]);
+
+    // A file set that rewrites a map moves its revision on, and only its.
+    $question = $session->insertMapLines('test-map', $test, 'y', 2, 1);
+    $inserted = $session->insertMapLines('test-map', $test, 'y', 2, 1, 'write', $question['confirm']);
+    $undone = $session->undo();
+    expect($inserted['revision'])->toBeGreaterThan($test)
+        ->and($undone['maps'])->toBe(['test-map'])
+        ->and($undone['revisions']['test-map'])->toBeGreaterThan($inserted['revision'])
+        ->and($revision('broken-map'))->toBe($broken)
+        ->and(fn() => changeSetPaint($session, 'buildings', 1, 0, ' '))->not->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->paint('test-map', $test, changeSetLayer($session, 'buildings'), [[2, 1]], ' '))
+            ->toThrow(SessionRefusal::class, 'changed since revision');
+});
+
+it('draws a map read afresh by a file set on a canvas of its own', function () {
+    $session = EditorSession::open(sourceSetChangeSetProject());
+    $first = $session->exchangeAuthoringCanvas('test-map', []);
+
+    $question = $session->retireSkill(0, 'New Cut');
+    $session->retireSkill(0, 'New Cut', 'write', $question['confirm']);
+    $after = $session->exchangeAuthoringCanvas('test-map', [], sessionId: $first['sessionId']);
+
+    expect($after['sessionId'])->not->toBe($first['sessionId'])
+        ->and($after['revision'])->toBe($first['revision'])
+        ->and($session->exchangeAuthoringCanvas('test-map', [], sessionId: $after['sessionId'])['sessionId'])->toBe($after['sessionId']);
+    $session->closeAuthoringCanvas('test-map');
+});
diff --git a/tests/Unit/Session/CinematicPreviewControlTest.php b/tests/Unit/Session/CinematicPreviewControlTest.php
new file mode 100644
index 00000000..67d5d29e
--- /dev/null
+++ b/tests/Unit/Session/CinematicPreviewControlTest.php
@@ -0,0 +1,238 @@
+ 'authored'", "'policy' => '{$policy}'", (string) file_get_contents($file));
+    $source = str_replace("  'finalizer' => [", "  'finalizer' => [\n    ['type' => 'cinematic_music', 'track' => 'preview-theme', 'loop' => false, 'fadeIn' => 0.2, 'completionBehavior' => 'stop'],", $source);
+    file_put_contents($file, $source);
+    mkdir($root . '/assets/Audio/BGM', 0o777, true);
+    file_put_contents($root . '/assets/Audio/BGM/preview-theme.ogg', '');
+
+    return $root;
+}
+
+/** Inspect the owned isolated preview without adding a runtime state-mutation protocol. */
+function readControlledCinematic(EditorSession $session): CinematicPreviewSession
+{
+    return new ReflectionProperty($session, 'cinematicPreview')->getValue($session);
+}
+
+it('skips a cinematic through its timed finalizer with renderer-neutral cleanup and no project writes', function (bool $graphical, bool $playing) {
+    $root = createCinematicControlProject();
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    $map = $session->readMap('harbour');
+    try {
+        $started = $session->startCinematicPreview(0, 60, 16);
+        if ($graphical) {
+            $session->exchangeCinematicScene([json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => [
+                'sprite_source_rect', 'graphical_canvas', 'canvas_overlay', 'canvas_clip_opacity', 'frame_viewport', 'field_motion',
+            ]])], $started['sessionId']);
+        }
+        $preview = readControlledCinematic($session);
+        if ($playing) {
+            $session->controlCinematicPreview('play');
+            $session->controlCinematicPreview('tick', 0.1);
+        }
+        expect($preview->snapshot()->values['staged actors'])->not->toBe([])
+            ->and($preview->snapshot()->values['field input'])->toBe('cinematic');
+
+        $skipped = $session->controlCinematicPreview('skip');
+        expect($skipped)->toMatchArray(['status' => 'finalizing', 'playing' => true, 'finished' => false, 'failure' => null])
+            ->and($skipped['terminalCanvas']['textLayers'][0]['grid'])->toMatchArray(['columns' => 60, 'rows' => 16]);
+        $finalizing = $preview->snapshot()->values;
+        expect(fn() => $session->controlCinematicPreview('skip'))->toThrow(SessionRefusal::class, 'the finalizer is already running')
+            ->and($preview->snapshot()->values)->toBe($finalizing);
+
+        $finished = $session->controlCinematicPreview('tick', 0.5);
+        expect($finished)->toMatchArray(['status' => 'completed', 'playing' => false, 'finished' => true, 'failure' => null]);
+        $values = $preview->snapshot()->values;
+        expect($values['switch harbour_lanterns_seen'])->toBeTrue()
+            ->and($values['story events'])->toContain('cinematic:harbour-lanterns:completed')
+            ->and($values['staged actors'])->toBe([])
+            ->and($values['camera']['followsPlayer'])->toBeTrue()
+            ->and($values['field input'])->toBe('player')
+            ->and($values['save available'])->toBeTrue()
+            ->and($values['checkpoints'])->toBe([])
+            ->and(fn() => $session->controlCinematicPreview('skip'))->toThrow(SessionRefusal::class, 'no cinematic is active')
+            ->and($session->readMap('harbour'))->toBe($map)
+            ->and($session->listUnsavedChanges())->toBe([])
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        $session->close();
+    }
+    expect(sourceHashTree($root))->toBe($before);
+})->with([
+    'terminal paused' => [false, false], 'terminal playing' => [false, true],
+    'graphical paused' => [true, false], 'graphical playing' => [true, true],
+]);
+
+it('completes an immediate finalizer without leaving playback running or changing authored source', function () {
+    $root = cutsceneProject();
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    try {
+        $session->startCinematicPreview(0, 40, 12);
+        expect($session->controlCinematicPreview('skip'))->toMatchArray(['status' => 'completed', 'finished' => true, 'playing' => false])
+            ->and(readControlledCinematic($session)->snapshot()->values['switch harbour_lanterns_seen'])->toBeTrue()
+            ->and($session->listUnsavedChanges())->toBe([])
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        $session->close();
+    }
+});
+
+it('returns skip policy refusals over the existing protocol without replacing or advancing the isolated preview', function () {
+    $root = createCinematicControlProject('forbidden');
+    $before = sourceHashTree($root);
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params]));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $session = new ReflectionProperty($host, 'session')->getValue($host);
+    try {
+        $started = $request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12])['result'];
+        $preview = readControlledCinematic($session);
+        $state = $preview->snapshot()->values;
+        $reply = $request('cutscenes.cinematicControl', ['action' => 'skip']);
+
+        expect($reply['error']['kind'])->toBe('refusal')
+            ->and($reply['error']['message'])->toBe('Skip refused: ' . $preview->skipRefusalReason())
+            ->and($reply['error']['message'])->toContain('skip policy is "forbidden"')
+            ->and(readControlledCinematic($session))->toBe($preview)
+            ->and($preview->snapshot()->values)->toBe($state)
+            ->and($request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12, 'keep' => true])['result'])->toBe($started)
+            ->and($session->listUnsavedChanges())->toBe([])
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        $session->close();
+    }
+});
+
+it('reports the Engine failure when skip is requested after a failed preview instead of hiding it behind an inactive-cinematic refusal', function () {
+    $root = cutsceneProject();
+    $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($file, " 'wait', 'seconds' => 0.1],
+        ['type' => 'move_route', 'subject' => 'staged_actor', 'actorId' => 'nobody', 'steps' => [['direction' => 'up', 'count' => 1]]],
+    ], true) . ";\n");
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    try {
+        $session->startCinematicPreview(0, 40, 12);
+        $session->controlCinematicPreview('step');
+        $result = $session->controlCinematicPreview('skip');
+        expect($result)->toMatchArray(['status' => 'failed', 'finished' => true, 'playing' => false])
+            ->and($result['failure'])->toBe(readControlledCinematic($session)->failure()['message'])
+            ->and($result['failure'])->toContain('nobody')
+            ->and($session->listUnsavedChanges())->toBe([])
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        $session->close();
+    }
+});
+
+it('refuses an unsafe authored finalizer before preview launch and leaves no cinematic to skip', function (array $finalizer, string $reason) {
+    $root = cutsceneProject();
+    $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php';
+    $data = require $file;
+    $data['finalizer'] = $finalizer;
+    file_put_contents($file, " $session->startCinematicPreview(0, 40, 12))->toThrow(SessionRefusal::class, $reason)
+            ->and(fn() => $session->controlCinematicPreview('skip'))->toThrow(SessionRefusal::class, 'No cinematic is being previewed.')
+            ->and($session->listUnsavedChanges())->toBe([])
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        $session->close();
+    }
+})->with([
+    'empty finalizer' => [[], 'requires an authored finalizer'],
+    'unsafe command' => [[['type' => 'wait', 'seconds' => 0.2]], 'unsafe type "wait"'],
+]);
+
+it('serves semantic skip and finalization over the protocol and refuses an absent preview', function () {
+    $root = createCinematicControlProject();
+    $before = sourceHashTree($root);
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 7, 'method' => $method, 'params' => $params]));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $session = new ReflectionProperty($host, 'session')->getValue($host);
+    try {
+        $absent = ['kind' => 'refusal', 'message' => 'No cinematic is being previewed.'];
+        expect($request('cutscenes.cinematicControl', ['action' => 'skip'])['error'])->toBe($absent);
+        $request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12]);
+        expect($request('cutscenes.cinematicControl', ['action' => 'skip'])['result'])->toMatchArray(['status' => 'finalizing', 'playing' => true])
+            ->and($request('cutscenes.cinematicControl', ['action' => 'tick', 'seconds' => 0.5])['result'])
+            ->toMatchArray(['status' => 'completed', 'finished' => true, 'failure' => null]);
+        $request('cutscenes.cinematicStop');
+        expect($request('cutscenes.cinematicControl', ['action' => 'skip'])['error'])->toBe($absent)
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        $session->close();
+    }
+});
+
+it('answers waiting dialogue and choices through the session, as the terminal editor does', function () {
+    $root = cutsceneProject();
+    file_put_contents($root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php', <<<'PHP_SOURCE'
+ 'text', 'name' => 'Keeper', 'text' => 'Mind the step.'],
+  ['type' => 'choice', 'prompt' => 'Go on?', 'options' => [
+    ['text' => 'Yes', 'then' => [['type' => 'set_switch', 'name' => 'went_on', 'value' => true]]],
+    ['text' => 'No', 'then' => [['type' => 'set_switch', 'name' => 'stayed', 'value' => true]]],
+  ]],
+];
+PHP_SOURCE);
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    try {
+        $session->startCinematicPreview(0, 40, 12);
+        $session->controlCinematicPreview('play');
+        expect($session->controlCinematicPreview('tick', 0.1))->toMatchArray(['status' => 'waiting', 'waitingFor' => 'dialogue']);
+
+        $session->controlCinematicPreview('confirm');
+        expect($session->controlCinematicPreview('tick', 0.1))->toMatchArray(['waitingFor' => 'choice']);
+
+        $session->controlCinematicPreview('choice', delta: 1);
+        $session->controlCinematicPreview('confirm');
+        $session->controlCinematicPreview('tick', 0.1);
+        expect(readControlledCinematic($session)->snapshot()->values['switch stayed'] ?? null)->toBeTrue()
+            ->and(readControlledCinematic($session)->snapshot()->values)->not->toHaveKey('switch went_on')
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        $session->close();
+    }
+});
+
+it('takes the choice step from the host request', function () {
+    $root = cutsceneProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params]));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $session = new ReflectionProperty($host, 'session')->getValue($host);
+    try {
+        $request('cutscenes.cinematicPreview', ['index' => 0, 'width' => 40, 'height' => 12]);
+        expect($request('cutscenes.cinematicControl', ['action' => 'choice', 'delta' => 1]))->toHaveKey('result')
+            ->and($request('cutscenes.cinematicControl', ['action' => 'confirm']))->toHaveKey('result')
+            ->and($request('cutscenes.cinematicControl', ['action' => 'dance'])['error']['kind'])->toBe('refusal');
+    } finally {
+        $session->close();
+    }
+});
diff --git a/tests/Unit/Session/ComputedSourceRefusalTest.php b/tests/Unit/Session/ComputedSourceRefusalTest.php
new file mode 100644
index 00000000..c83cb38b
--- /dev/null
+++ b/tests/Unit/Session/ComputedSourceRefusalTest.php
@@ -0,0 +1,92 @@
+readDatabaseRecord('optimize_weights', $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+    expect($row)->not->toBeNull("weights {$index} has a {$field} row");
+
+    return $row;
+}
+
+it('refuses a weight the file builds in a loop when it is edited, saying why, and writes nothing', function () {
+    [$path, $session] = openOptimizePolicy(<<<'PHP'
+ 5, 'speed' => 1];
+$roleSlots = [];
+foreach (['vanguard', 'oracle'] as $role) {
+  $roleSlots[$role] = ['weapon' => $weapon];
+}
+
+return [
+  'roleSlotStatWeights' => $roleSlots,
+  'elementOutcomeWeights' => ['defence:*:absorb' => 16],
+];
+PHP);
+    $before = (string) file_get_contents($path);
+    $labels = $session->listDatabaseRecords('optimize_weights')['records'];
+    $slot = array_search('vanguard · weapon', $labels, true);
+    expect($slot)->toBeInt();
+
+    $refusal = null;
+    try {
+        $session->applyDatabaseRecord('optimize_weights', $slot, findWeightRow($session, $slot, 'weights.attack')['key'], '9');
+    } catch (SessionRefusal $thrown) {
+        $refusal = $thrown;
+    }
+
+    expect($refusal)->not->toBeNull()
+        ->and($refusal->getMessage())->toContain('$roleSlots, which the file\'s own code builds or uses')
+        ->and(findWeightRow($session, $slot, 'weights.attack')['value'])->toBe('5', 'the record kept its value')
+        ->and((string) file_get_contents($path))->toBe($before);
+});
+
+it('edits a weight written in a variable as data where the variable is written', function () {
+    [$path, $session] = openOptimizePolicy(<<<'PHP'
+ ['weapon' => ['attack' => 5, 'speed' => 1]],
+];
+
+return [
+  'roleSlotStatWeights' => $roleSlots,
+];
+PHP);
+    $before = (string) file_get_contents($path);
+    $slot = array_search('vanguard · weapon', $session->listDatabaseRecords('optimize_weights')['records'], true);
+    expect($slot)->toBeInt();
+
+    $session->applyDatabaseRecord('optimize_weights', $slot, findWeightRow($session, $slot, 'weights.attack')['key'], '9');
+    $session->saveDatabase('optimize_weights');
+
+    expect((string) file_get_contents($path))->toBe(str_replace("'attack' => 5", "'attack' => 9", $before));
+});
diff --git a/tests/Unit/Session/ConfigChangeBatchTest.php b/tests/Unit/Session/ConfigChangeBatchTest.php
new file mode 100644
index 00000000..4ad360d7
--- /dev/null
+++ b/tests/Unit/Session/ConfigChangeBatchTest.php
@@ -0,0 +1,33 @@
+listDatabaseRecords('terms')['records'];
+    $settings = $session->listDatabaseRecords('configuration')['records'];
+    $term = array_search('messages.income.held', $terms, true);
+    $setting = array_search('graphics.field.zoom', $settings, true);
+
+    $outcome = ChangeBatch::apply($root, [
+        ['method' => 'database.apply', 'params' => ['category' => 'terms', 'index' => $term,
+            'key' => ['target' => 'record', 'field' => 'value', 'frame' => []], 'value' => '%4 %2 waiting at the guild.']],
+        ['method' => 'database.apply', 'params' => ['category' => 'configuration', 'index' => $setting,
+            'key' => ['target' => 'record', 'field' => 'value', 'frame' => []], 'value' => '3']],
+    ]);
+    $config = require $root . '/config.php';
+
+    expect($outcome['status'])->toBe('saved')
+        ->and($config['messages']['income']['held'] ?? null)->toBe('%4 %2 waiting at the guild.')
+        ->and($config['graphics']['field']['zoom'] ?? null)->toEqual(3);
+});
diff --git a/tests/Unit/Session/CounterAttackSessionTest.php b/tests/Unit/Session/CounterAttackSessionTest.php
new file mode 100644
index 00000000..d55d9fd2
--- /dev/null
+++ b/tests/Unit/Session/CounterAttackSessionTest.php
@@ -0,0 +1,144 @@
+readDatabaseRecord($category, $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+it('offers only the skills the Engine accepts for a counter', function () {
+    $root = counterAttackProject();
+    $session = EditorSession::open($root);
+    [$map] = $session->describeMaps();
+    $offered = array_column($session->listReferences($map['id'], 'counter_skills'), 'value');
+    $catalog = SkillCatalog::load($root . '/assets');
+
+    expect($offered)->toBe(['Riposte'])
+        ->and(new CounterAttackRule('Riposte')->resolveSkill($catalog)->name)->toBe('Riposte');
+});
+
+it('grants an enemy a counter attack and takes it away, leaving no empty grant behind', function () {
+    $root = counterAttackProject();
+    $session = EditorSession::open($root);
+    $file = $root . '/assets/Data/Enemies/regular-bat.php';
+    $before = (string) file_get_contents($file);
+
+    expect(counterRow($session, 'enemies', 0, 'counterAttack.skill'))->toMatchArray(['kind' => 'reference', 'reference' => 'counter_skills']);
+
+    $session->applyDatabaseRecord('enemies', 0, counterRow($session, 'enemies', 0, 'counterAttack.skill')['key'], 'Riposte');
+    $session->saveDatabase('enemies');
+    $granted = require $file;
+
+    expect($granted['data']['counterAttack'])->toBe(['skill' => 'Riposte'])
+        ->and(CounterAttackRule::fromArray($granted['data']['counterAttack'])->skill)->toBe('Riposte');
+
+    $session->applyDatabaseRecord('enemies', 0, counterRow($session, 'enemies', 0, 'counterAttack.skill')['key'], '');
+    $session->saveDatabase('enemies');
+
+    expect((require $file)['data'])->not->toHaveKey('counterAttack')
+        ->and(file_get_contents($file))->toBe($before);
+});
+
+it('lets states and non-magic skills grant one, and never spells', function () {
+    $root = counterAttackProject();
+    file_put_contents($root . '/assets/Data/states.php', " 'parry-stance', 'name' => 'Parry Stance', 'icon' => '', 'description' => '']];\n");
+    $session = EditorSession::open($root);
+    $skills = $session->listDatabaseRecords('skills')['records'];
+
+    expect(counterRow($session, 'states', 0, 'counterAttack.skill'))->not->toBeNull()
+        ->and(counterRow($session, 'skills', array_search('Riposte', $skills, true), 'counterAttack.skill'))->not->toBeNull()
+        ->and(counterRow($session, 'skills', array_search('Flare', $skills, true), 'counterAttack.skill'))->toBeNull();
+
+    $session->applyDatabaseRecord('states', 0, counterRow($session, 'states', 0, 'counterAttack.skill')['key'], 'Riposte');
+    $session->saveDatabase('states');
+
+    expect((require $root . '/assets/Data/states.php')[0]['counterAttack'])->toBe(['skill' => 'Riposte']);
+});
+
+it('grants an actor a counter attack through the actor service', function () {
+    $root = counterAttackProject();
+    $session = EditorSession::open($root);
+    $actor = array_search('Kaelion', $session->listDatabaseRecords('actors')['records'], true);
+    $row = counterRow($session, 'actors', $actor, 'counterAttack');
+
+    expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'counter_skills', 'value' => '(no counter)']);
+
+    $session->applyDatabaseRecord('actors', $actor, $row['key'], 'Riposte');
+    $session->saveDatabase('actors');
+    $workspace = ProjectWorkspace::fromProject($root);
+    $saved = array_find($workspace->actorDatabase->getActors(), static fn($candidate): bool => $candidate->getName() === 'Kaelion');
+
+    expect($saved->getData()['counterAttack'])->toBe(['skill' => 'Riposte'])
+        ->and(counterRow(EditorSession::open($root), 'actors', $actor, 'counterAttack')['value'])->toBe('Riposte');
+
+    $session->applyDatabaseRecord('actors', $actor, counterRow($session, 'actors', $actor, 'counterAttack')['key'], '');
+    $session->saveDatabase('actors');
+    $cleared = array_find(ProjectWorkspace::fromProject($root)->actorDatabase->getActors(), static fn($candidate): bool => $candidate->getName() === 'Kaelion');
+
+    expect($cleared->getData())->not->toHaveKey('counterAttack');
+});
+
+it('reports a grant the Engine would refuse, with the Engine\'s reason', function () {
+    $root = counterAttackProject();
+    $file = $root . '/assets/Data/Enemies/regular-bat.php';
+    file_put_contents($file, str_replace("'level' => 2,", "'level' => 2,\n    'counterAttack' => ['skill' => 'Flare'],", (string) file_get_contents($file)));
+
+    $issues = new CounterAttackValidator()->validate(ProjectWorkspace::fromProject($root));
+
+    expect($issues)->toHaveCount(1)
+        ->and($issues[0]->where)->toContain('Regular Bat')
+        ->and($issues[0]->message)->toContain('battle-usable basic or special skill');
+});
+
+it('never offers or accepts a skill the project links to a summon', function () {
+    $root = counterAttackProject();
+    // A special skill that would otherwise qualify, linked to an authored summon.
+    writeSkillRecords($root,
+        new BasicSkill('Riposte', '', '', 0, 0),
+        new \Ichiloto\Engine\Entities\Skills\SpecialSkill('Ember Call', '', '', 0, 0),
+    );
+    $summon = $root . '/assets/Cutscenes/Summons/ember';
+    mkdir($summon, 0777, true);
+    file_put_contents($summon . '/ember.data.php', " 'ember', 'name' => 'Ember', 'linkedActionId' => 'Ember Call',
+        'availability' => ['conditions' => []],
+        'wielders' => ['mode' => 'characters', 'characters' => ['Kaelion'], 'tenancy' => 'exclusive'],
+    ], true) . ";\n");
+    file_put_contents($summon . '/ember.timeline.php', " 12, 'lengthFrames' => 1, 'tracks' => [], 'cues' => []];\n");
+    $file = $root . '/assets/Data/Enemies/regular-bat.php';
+    file_put_contents($file, str_replace("'level' => 2,", "'level' => 2,\n    'counterAttack' => ['skill' => 'Ember Call'],", (string) file_get_contents($file)));
+
+    $session = EditorSession::open($root);
+    [$map] = $session->describeMaps();
+    $issues = new CounterAttackValidator()->validate(ProjectWorkspace::fromProject($root));
+
+    expect(array_column($session->listReferences($map['id'], 'counter_skills'), 'value'))->toBe(['Riposte'])
+        ->and($issues)->toHaveCount(1)
+        ->and($issues[0]->where)->toContain('Regular Bat')
+        ->and($issues[0]->message)->toContain('without summon');
+});
diff --git a/tests/Unit/Session/CurrentRecordsSessionTest.php b/tests/Unit/Session/CurrentRecordsSessionTest.php
new file mode 100644
index 00000000..0a7e09a5
--- /dev/null
+++ b/tests/Unit/Session/CurrentRecordsSessionTest.php
@@ -0,0 +1,126 @@
+ ['name' => 'Chest', 'layer' => 'buildings', 'glyphs' => ['m'], 'tiles' => ['furniture' => ['60']]],
+    ]);
+    // The fixture paints marker E; give it a definition so a change set on the map can be written.
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'events' => [],", "'events' => ['E' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', "
+        . "'data' => ['dialogue' => [['name' => '', 'text' => 'A sign.']]]]],", (string) file_get_contents($data)));
+
+    return [$root, EditorSession::open($root)];
+}
+
+/** Sets one of the Home tileset's rows by field id. */
+function applyTilesetRow(EditorSession $session, string $field, string $value): void
+{
+    $index = array_search('Home', $session->listDatabaseRecords('tilesets')['records'], true);
+    $row = array_find($session->readDatabaseRecord('tilesets', $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field)
+        ?? throw new RuntimeException('No tileset row ' . $field);
+    $session->applyDatabaseRecord('tilesets', $index, $row['key'], $value);
+}
+
+/** @return list The buildings layer, row by row. */
+function readCurrentBuildingRows(EditorSession $session): array
+{
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4');
+
+    return array_map(static fn(array $row): string => implode('', $row), $layer['rows']);
+}
+
+it('paints with a tileset piece as edited earlier in the same change set, and lists a piece added there', function () {
+    [$root, $session] = currentRecordsSession();
+    $tileset = (string) file_get_contents($root . '/assets/Data/Tilesets/home.php');
+    $session->beginChanges();
+
+    // The chest becomes a visual surface: its tile stands for no glyph.
+    applyTilesetRow($session, 'piece0Glyphs', ' ');
+    applyTilesetRow($session, 'piece0Id', 'surface');
+    $painted = $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'furniture', [[0, 0, 60]], 'Place tiles');
+
+    expect($painted)->toMatchArray(['status' => 'applied', 'changed' => 1, 'glyphs' => 0])
+        ->and(readCurrentBuildingRows($session)[0])->toBe(' /  ')
+        ->and(array_column($session->listPieces('test-map')['pieces'], 'id'))->toBe(['surface'])
+        // The map world draws with the tileset as edited, not as saved.
+        ->and(array_keys(getSessionMap($session)->loadGraphics()?->tileset->pieces ?? []))->toBe(['surface'])
+        ->and((string) file_get_contents($root . '/assets/Data/Tilesets/home.php'))->toBe($tileset);
+
+    expect($session->commitChanges()['status'])->toBe('saved')
+        ->and((require $root . '/assets/Data/Tilesets/home.php')['pieces'])->toHaveKey('surface');
+});
+
+it('undoes a refused change set\'s tileset edit with the rest, writing nothing', function () {
+    [$root, $session] = currentRecordsSession();
+    $before = sourceHashTree($root . '/assets');
+    $session->beginChanges();
+    applyTilesetRow($session, 'piece0Glyphs', ' ');
+    $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'furniture', [[0, 0, 60]], 'Place tiles');
+    // A marker painted with no event: the Engine refuses the map.
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], 'event', [[2, 1]], 'Z');
+
+    expect($session->commitChanges()['status'])->toBe('refused')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and(array_column($session->listPieces('test-map')['pieces'], 'id'))->toBe(['chest']);
+});
+
+/** Creates the place category bard-school on its page and names it from test-map's landmark at E. */
+function nameUnsavedPlaceCategory(EditorSession $session): void
+{
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'building', 'E');
+    $index = $session->createDatabaseRecord(MapCategoryFields::CATEGORY)['index'];
+    foreach (['id' => 'bard-school', 'name.default' => 'Bard School', 'base' => 'organization'] as $field => $value) {
+        $row = array_find($session->readDatabaseRecord(MapCategoryFields::CATEGORY, $index)['rows'],
+            static fn(array $row): bool => ($row['key']['field'] ?? null) === $field) ?? throw new RuntimeException('No category row ' . $field);
+        $session->applyDatabaseRecord(MapCategoryFields::CATEGORY, $index, $row['key'], $value);
+    }
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0], 'bard-school');
+}
+
+it('validates a landmark against a place category added earlier in the same change set', function () {
+    [$root, , $session] = createAtlasProject();
+    $session->beginChanges();
+    nameUnsavedPlaceCategory($session);
+
+    // The saved file does not have the category yet, so a check reading it would refuse the landmark.
+    $entries = readLandmarkEntries($session);
+    expect(MapLandmarkFields::findProblem(getSessionMap($session), $entries, MapCategoryCatalog::load($root . '/assets')))->not->toBeNull()
+        ->and(MapLandmarkFields::findProblem(getSessionMap($session), $entries, getSessionMap($session)->loadMapCategories()))->toBeNull();
+
+    $outcome = $session->commitChanges();
+    expect(array_filter(array_column($outcome['issues'], 'message'), static fn(string $message): bool => str_contains($message, 'Minimap landmarks')))->toBe([])
+        ->and($outcome['status'])->toBe('saved')
+        ->and(MapCategoryCatalog::load($root . '/assets')->getIds())->toContain('bard-school');
+});
+
+it('undoes a refused change set\'s place category with the landmark naming it, writing nothing', function () {
+    [$root, , $session] = createAtlasProject();
+    $before = sourceHashTree($root . '/assets');
+    $session->beginChanges();
+    nameUnsavedPlaceCategory($session);
+    $session->paint('test-map', $session->readMap('test-map')['revision'], 'event', [[1, 1]], 'Z');
+
+    expect($session->commitChanges()['status'])->toBe('refused')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and(getSessionMap($session)->loadMapCategories()->getIds())->not->toContain('bard-school');
+});
diff --git a/tests/Unit/Session/CutsceneRecordSessionTest.php b/tests/Unit/Session/CutsceneRecordSessionTest.php
new file mode 100644
index 00000000..f783a64f
--- /dev/null
+++ b/tests/Unit/Session/CutsceneRecordSessionTest.php
@@ -0,0 +1,358 @@
+describeProject();
+
+    expect(array_column($hello['cutscenes'], 'key'))->toBe(['cutscenes/cinematic', 'cutscenes/summon', 'cutscenes/effect'])
+        ->and(array_column($hello['cutscenes'], 'label'))->toBe(['Cinematic', 'Summon', 'Effect'])
+        ->and(array_unique(array_column($hello['cutscenes'], 'group')))->toBe(['Cutscenes'])
+        ->and(array_unique(array_column($hello['databases'], 'group')))->toBe(['Database']);
+});
+
+it('reads a summon\'s fields and its tracks, keyframes and cues as frames of its record', function () {
+    $session = EditorSession::open(cutsceneProject());
+    $records = $session->listDatabaseRecords('cutscenes/summon');
+    $record = $session->readDatabaseRecord('cutscenes/summon', 0);
+
+    expect($records['records'])->toHaveCount(1)
+        ->and(array_column($record['rows'], 'label'))->toContain('Name');
+});
+
+it('edits a summon field as one undo step and saves it to its data file', function () {
+    $root = cutsceneProject();
+    $session = EditorSession::open($root);
+    $name = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name');
+    $file = $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.data.php';
+    $before = (string) file_get_contents($file);
+
+    expect($session->applyDatabaseRecord('cutscenes/summon', 0, $name['key'], 'Lantern Wraith')['changed'])->toBeTrue()
+        ->and($session->listUnsavedChanges())->not->toBe([]);
+    $session->undo();
+    expect(array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name')['value'])
+        ->toBe($name['value']);
+    $session->redo();
+    $session->saveAll();
+
+    expect((string) file_get_contents($file))->toContain("'Lantern Wraith'")
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and(fn() => $session->readDatabaseRecord('cutscenes/nothing', 0))->toThrow(SessionRefusal::class);
+});
+
+it('authors a summon\'s element affinities and effective-against elements and saves them to its data file', function () {
+    $root = cutsceneProject();
+    $session = EditorSession::open($root);
+    $row = static fn(string $label): array => array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'],
+        static fn(array $row): bool => trim($row['label']) === $label);
+    $file = $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.data.php';
+
+    expect($session->applyDatabaseRecord('cutscenes/summon', 0, $row('Element Affinities')['key'], 'Fire: 0.25; Water: 1.5')['changed'])->toBeTrue()
+        ->and($session->applyDatabaseRecord('cutscenes/summon', 0, $row('Effective Against')['key'], 'Ice')['changed'])->toBeTrue();
+    $session->saveAll();
+    $data = (static fn(string $path): array => require $path)($file);
+
+    expect($data['elementAffinities'])->toBe(['Fire' => 0.25, 'Water' => 1.5])
+        ->and($data['effectiveAgainst'])->toBe(['Ice'])
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('saves a summon category as the Database window saves any category', function () {
+    $root = cutsceneProject();
+    $session = EditorSession::open($root);
+    $name = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name');
+    $session->applyDatabaseRecord('cutscenes/summon', 0, $name['key'], 'Lantern Wraith');
+
+    expect($session->listDatabaseRecords('cutscenes/summon'))->toMatchArray(['dirty' => true])
+        ->and($session->saveDatabase('cutscenes/summon')['saved'])->toBeTrue()
+        ->and((string) file_get_contents($root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.data.php'))->toContain("'Lantern Wraith'")
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('writes a rest frame the timeline did not hold to the timeline, where the summon reads it', function () {
+    $root = cutsceneProject();
+    $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp';
+    $dataBefore = (string) file_get_contents($folder . '/lantern-wisp.data.php');
+    expect((string) file_get_contents($folder . '/lantern-wisp.timeline.php'))->not->toContain("'restFrame'");
+    $session = EditorSession::open($root);
+    $rest = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Rest Frame');
+
+    $session->applyDatabaseRecord('cutscenes/summon', 0, $rest['key'], '12');
+    $session->saveAll();
+
+    expect((string) file_get_contents($folder . '/lantern-wisp.timeline.php'))->toContain("'restFrame' => 12")
+        ->and((string) file_get_contents($folder . '/lantern-wisp.data.php'))->toBe($dataBefore)
+        ->and(Ichiloto\Editor\Cutscenes\CutsceneLibrary::fromProject($root)->find(Ichiloto\Editor\Cutscenes\CutsceneType::SUMMON, 'lantern-wisp')
+            ->summonDefinition()->restFrame)->toBe(12);
+});
+
+it('lists a project reference without a map, and a summon\'s own cues from the summon being edited', function () {
+    $root = cutsceneProject();
+    mkdir($root . '/assets/Graphics/Effects', 0o777, true);
+    writeTilesetTestPng($root . '/assets/Graphics/Effects/Spark.png', 8, 8);
+    $session = EditorSession::open($root);
+    $cues = $session->listReferences(null, 'summon_cues', ['category' => 'cutscenes/summon', 'index' => 0]);
+
+    expect(array_column($session->listReferences(null, 'png_assets'), 'value'))->toContain('Graphics/Effects/Spark.png')
+        ->and($cues)->not->toBe([])
+        ->and(fn() => $session->listReferences(null, 'summon_cues', ['category' => 'cutscenes/summon', 'index' => 9]))
+            ->toThrow(SessionRefusal::class);
+});
+
+it('describes a summon timeline with the row keys that edit it, and moves a keyframe as one undo step', function () {
+    $session = EditorSession::open(cutsceneProject());
+    $timeline = $session->describeCutsceneTimeline('cutscenes/summon', 0);
+    $keyframe = $timeline['tracks'][0]['keyframes'][0];
+
+    expect($timeline['fps'])->toBeGreaterThan(0)
+        ->and($timeline['lengthFrames'])->toBeGreaterThan(0)
+        ->and($timeline['tracks'])->not->toBe([])
+        ->and($keyframe)->toHaveKeys(['frame', 'duration', 'frameKey', 'durationKey']);
+
+    $session->applyDatabaseRecord('cutscenes/summon', 0, $keyframe['frameKey'], (string) ($keyframe['frame'] + 1));
+    expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'][0]['keyframes'][0]['frame'])->toBe($keyframe['frame'] + 1);
+    $session->undo();
+    expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'][0]['keyframes'][0]['frame'])->toBe($keyframe['frame'])
+        ->and(fn() => $session->describeCutsceneTimeline('cutscenes/cinematic', 0))->toThrow(SessionRefusal::class);
+});
+
+it('previews a summon frame as its terminal presentation draws it, clamped to the timeline', function () {
+    $session = EditorSession::open(cutsceneProject());
+    $preview = $session->readCutscenePreview('cutscenes/summon', 0, 9999, 40, 10);
+
+    expect($preview['frame'])->toBe($preview['totalFrames'] - 1)
+        ->and($preview['lines'])->toHaveCount(10)
+        ->and(mb_strlen($preview['lines'][0]))->toBe(40);
+    expectTerminalCanvas($preview['terminalCanvas'], 40, 10);
+});
+
+/** A terminal picture as the renderer receives it: one text layer on the picture's own grid, its runs free of control codes. */
+function expectTerminalCanvas(array $canvas, int $columns, int $rows): void
+{
+    expect($canvas['textLayers'])->toHaveCount(1);
+    $layer = $canvas['textLayers'][0];
+    expect($layer['grid']['columns'])->toBe($columns)
+        ->and($layer['grid']['rows'])->toBe($rows)
+        ->and($canvas['width'])->toBe($columns * $layer['grid']['cellWidth'])
+        ->and($canvas['height'])->toBe($rows * $layer['grid']['cellHeight']);
+    foreach ($layer['runs'] as $run) {
+        expect($run['row'])->toBeLessThan($rows)
+            ->and($run['column'] + mb_strlen($run['text']))->toBeLessThanOrEqual($columns)
+            ->and(preg_match('/\p{Cc}/u', $run['text']))->toBe(0);
+    }
+}
+
+it('shows an effect\'s graphical sequence with its image track\'s art, and sets the pivot as one undo step', function () {
+    $session = EditorSession::open(effectProject());
+    $index = array_search('dusk-slash', $session->listDatabaseRecords('cutscenes/effect')['records'], true);
+
+    expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['presentation'])->toBe('terminal')
+        ->and($session->selectCutscenePresentation('cutscenes/effect', $index, 'graphical'))->toBe(['presentation' => 'graphical']);
+    $art = $session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art'];
+    expect($art)->toMatchArray(['asset' => 'Graphics/Effects/dusk-slash.png', 'columns' => 2, 'rows' => 1, 'width' => 32, 'height' => 16])
+        ->and($art['attachmentOptions'])->toBe(['center', 'head', 'ground']);
+
+    $session->applyDatabaseRecord('cutscenes/effect', $index, $art['pivotKey'], '0.25, 1');
+    expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe('0.25, 1');
+    $session->undo();
+    expect($session->describeCutsceneTimeline('cutscenes/effect', $index)['tracks'][0]['art']['pivot'])->toBe($art['pivot'])
+        ->and(fn() => $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'))->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->selectCutscenePresentation('cutscenes/effect', $index, 'holographic'))->toThrow(SessionRefusal::class);
+});
+
+it('creates, duplicates and deletes summons as one undo step each, reaching disk on save', function () {
+    $root = cutsceneProject();
+    $session = EditorSession::open($root);
+    $summons = static fn(): array => $session->listDatabaseRecords('cutscenes/summon');
+
+    expect($summons())->toMatchArray(['canCreate' => true, 'canDuplicate' => true, 'canDelete' => true]);
+    $created = $session->createDatabaseRecord('cutscenes/summon', 'Ember Djinn');
+    $id = array_find($session->readDatabaseRecord('cutscenes/summon', $created['index'])['rows'], static fn(array $row): bool => trim($row['label']) === 'Id')['value'];
+    expect($id)->toBe('ember-djinn')
+        ->and($summons()['records'])->toHaveCount(2);
+
+    $session->undo();
+    expect($summons()['records'])->toHaveCount(1);
+    $session->redo();
+    $copy = $session->duplicateDatabaseRecord('cutscenes/summon', 0);
+    expect($summons()['records'])->toHaveCount(3);
+    $session->saveAll();
+    expect(is_file($root . '/assets/Cutscenes/Summons/ember-djinn/ember-djinn.data.php'))->toBeTrue()
+        ->and(is_dir($root . '/assets/Cutscenes/Summons/lantern-wisp-copy'))->toBeTrue();
+
+    $session->deleteDatabaseRecord('cutscenes/summon', $copy['index']);
+    expect($summons()['records'])->toHaveCount(2);
+    $session->saveAll();
+    expect(is_dir($root . '/assets/Cutscenes/Summons/lantern-wisp-copy'))->toBeFalse();
+});
+
+it('authors a summon image track with the effect image fields, compiled per renderer', function () {
+    $root = cutsceneProject();
+    $file = $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php';
+    // A summon with art rests on an authored frame, as reduced motion draws it.
+    file_put_contents($file, str_replace(["  'lengthFrames' => 24,", "    ['type' => 'text', 'id' => 'name',"], ["  'lengthFrames' => 24,\n  'restFrame' => 12,", 
+        "    ['type' => 'image', 'id' => 'wisp-art', 'asset' => 'Graphics/Summons/Wisp.png', 'sheet' => ['columns' => 2, 'rows' => 1],\n"
+        . "      'anchor' => 'target', 'attachment' => 'ground', 'pivot' => ['x' => 0.5, 'y' => 1],\n"
+        . "      'keyframes' => [['frame' => 0, 'sourceFrame' => 0], ['frame' => 12, 'sourceFrame' => 1]]],\n"
+        . "    ['type' => 'text', 'id' => 'name',"], (string) file_get_contents($file)));
+    $session = EditorSession::open($root);
+    $track = array_find($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'], static fn(array $track): bool => $track['id'] === 'wisp-art');
+    $asset = Ichiloto\Editor\Cutscenes\CutsceneLibrary::fromProject($root)->find(Ichiloto\Editor\Cutscenes\CutsceneType::SUMMON, 'lantern-wisp');
+
+    // The record shows the image track's own rows, and its keyframes' sheet cells.
+    expect($track['type'])->toBe('image')
+        ->and($track['art'])->toMatchArray(['asset' => 'Graphics/Summons/Wisp.png', 'columns' => 2, 'pivot' => '0.5, 1', 'width' => null])
+        ->and(array_column($track['keyframes'], 'sourceFrame'))->toBe([0, 1])
+        // The terminal never needs the art; the graphical presentation refuses an image that is not there.
+        ->and($asset->compiledSummon()->fps)->toBe(12)
+        ->and(fn() => $asset->compiledSummon(Ichiloto\Engine\Animations\Timelines\EffectPresentation::GRAPHICAL))->toThrow(RuntimeException::class, 'Graphics/Summons/Wisp.png');
+
+    mkdir($root . '/assets/Graphics/Summons', 0o777, true);
+    writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8);
+    expect($asset->compiledSummon(Ichiloto\Engine\Animations\Timelines\EffectPresentation::GRAPHICAL)->fps)->toBe(12);
+});
+
+it('sets how an image track fills its cells, one undo step, keeping the timeline\'s own source', function () {
+    $root = cutsceneProject();
+    $file = $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php';
+    // Written as authors write tracks: the keys run on from the first line and the bracket closes the last.
+    file_put_contents($file, str_replace(["  'lengthFrames' => 24,", "    ['type' => 'text', 'id' => 'name',"], ["  'lengthFrames' => 24,\n  'restFrame' => 0,",
+        "    ['type' => 'image', 'id' => 'wisp-art', 'asset' => 'Graphics/Summons/Wisp.png', 'sheet' => ['columns' => 2, 'rows' => 1],\n"
+        . "      'keyframes' => [['frame' => 0, 'sourceFrame' => 0]]],\n    ['type' => 'text', 'id' => 'name',"], (string) file_get_contents($file)));
+    mkdir($root . '/assets/Graphics/Summons', 0o777, true);
+    writeTilesetTestPng($root . '/assets/Graphics/Summons/Wisp.png', 16, 8);
+    $session = EditorSession::open($root);
+    $art = fn(): array => array_find($session->describeCutsceneTimeline('cutscenes/summon', 0)['tracks'], static fn(array $track): bool => $track['id'] === 'wisp-art')['art'];
+
+    // Unset reads as the default it falls back to, in brackets.
+    expect($art()['fit'])->toBe('(stretch)')
+        ->and($art()['fitOptions'])->toBe(['stretch', 'contain']);
+    $session->applyDatabaseRecord('cutscenes/summon', 0, $art()['fitKey'], 'contain');
+    expect($art()['fit'])->toBe('contain');
+    $session->undo();
+    expect($art()['fit'])->toBe('(stretch)');
+    $session->redo();
+    $session->saveAll();
+
+    $written = (string) file_get_contents($file);
+    expect($written)->toContain('// The wisp itself.', "'keyframes' => [['frame' => 0, 'sourceFrame' => 0]],\n      'fit' => 'contain'],")
+        ->and($session->listUnsavedChanges())->toBe([]);
+    $session->applyDatabaseRecord('cutscenes/summon', 0, $art()['fitKey'], '');
+    $session->saveAll();
+    expect((string) file_get_contents($file))->not->toContain("'fit'")->toContain('// The wisp itself.');
+});
+
+it('previews a cinematic as the terminal plays it, and plays, steps, restarts and stops it, writing nothing', function () {
+    $root = cutsceneProject();
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    $index = array_search('harbour-lanterns', array_map(strval(...), $session->listDatabaseRecords('cutscenes/cinematic')['records']), true);
+    $index = $index === false ? 0 : $index;
+
+    $started = $session->startCinematicPreview($index, 60, 16);
+    expect($started)->toMatchArray(['id' => 'harbour-lanterns', 'playing' => false])
+        ->and($started['lines'])->not->toBe([])
+        ->and($started['failure'])->toBeNull();
+    expectTerminalCanvas($started['terminalCanvas'], 60, 16);
+
+    $session->controlCinematicPreview('play');
+    $played = $session->controlCinematicPreview('tick', 0.5);
+    expect($played['elapsed'])->toBeGreaterThan(0.0);
+    $session->controlCinematicPreview('pause');
+    $paused = $session->controlCinematicPreview('tick', 0.5);
+    expect($paused['elapsed'])->toBe($played['elapsed'])
+        ->and($session->controlCinematicPreview('step')['elapsed'])->toBeGreaterThan($played['elapsed'])
+        // Asked to keep it, an unchanged cinematic carries on; an edited one starts again.
+        ->and($session->startCinematicPreview($index, 60, 16, keep: true)['elapsed'])->toBeGreaterThan($played['elapsed'])
+        ->and($session->controlCinematicPreview('restart')['elapsed'])->toBe(0.0)
+        ->and(fn() => $session->controlCinematicPreview('rewind'))->toThrow(SessionRefusal::class)
+        ->and($session->controlCinematicPreview('step')['elapsed'])->toBeGreaterThan(0.0);
+    $name = array_find($session->readDatabaseRecord('cutscenes/cinematic', $index)['rows'], static fn(array $row): bool => trim($row['label']) === 'Name');
+    $session->applyDatabaseRecord('cutscenes/cinematic', $index, $name['key'], 'Harbour Lanterns Renamed');
+    expect($session->startCinematicPreview($index, 60, 16, keep: true)['elapsed'])->toBe(0.0)
+        ->and($session->stopCinematicPreview())->toBe(['stopped' => true])
+        ->and(fn() => $session->controlCinematicPreview('play'))->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->startCinematicPreview(9, 60, 16))->toThrow(SessionRefusal::class);
+    $session->undo();
+    expect(sourceHashTree($root))->toBe($before);
+});
+
+it('draws the previewed cinematic in the window\'s graphical view through the session, writing nothing', function () {
+    $root = cutsceneProject();
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    $started = $session->startCinematicPreview(0, 60, 16);
+    $ready = json_encode(['protocol' => 2, 'type' => 'ready', 'capabilities' => ['sprite_source_rect', 'graphical_canvas', 'canvas_overlay',
+        'canvas_clip_opacity', 'frame_viewport', 'field_motion']]);
+
+    $exchange = $session->exchangeCinematicScene([$ready], $started['sessionId']);
+    // The window builds its scene with the grid the session names: the preview's screen in the game's graphical text cells.
+    expect($exchange['grid'])->toBe(['columns' => 60, 'rows' => 16, 'cellWidth' => 10, 'cellHeight' => 20]);
+    $sent = $exchange['messages'];
+    expect(array_column($sent, 'type'))->toContain('frame')
+        ->and(array_key_exists('protocol', $sent[0]['payload']))->toBeFalse()
+        ->and(fn() => $session->exchangeCinematicScene(['not a renderer event'], $started['sessionId']))->toThrow(SessionRefusal::class, 'graphical preview stopped')
+        ->and($session->detachCinematicScene())->toBe(['detached' => true]);
+    $session->stopCinematicPreview();
+    expect(fn() => $session->exchangeCinematicScene([$ready]))->toThrow(SessionRefusal::class, 'No cinematic is being previewed')
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('replaces a branch arm\'s only route with a new one built row by row, and saves what it reads back', function () {
+    $root = cutsceneProject();
+    $file = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($file, <<<'PHP'
+ 'branch',
+    'conditions' => [
+      ['type' => 'event', 'name' => 'a', 'negate' => true],
+    ],
+    'then' => [[
+      'type' => 'move_route', 'subject' => 'player', 'remember' => 'caller-entry',
+      'waypoints' => [['y' => 1],
+['x' => 3], ['y' => 2] ], 'secondsPerStep' => 0.1,
+    ]],
+    'else' => [],
+  ],
+];
+PHP);
+    $session = EditorSession::open($root);
+    $category = 'cutscenes/cinematic';
+    $frame = ['commands', 0, 'then'];
+    $setRow = function (string $name, string $value, array $entry) use ($session, $category, $frame): void {
+        $row = array_find($session->readDatabaseRecord($category, 0, $frame)['rows'],
+            static fn(array $row): bool => ($row['name'] ?? $row['label']) === $name && ($row['entry'] ?? null) === $entry);
+        $session->applyDatabaseRecord($category, 0, $row['key'], $value);
+    };
+
+    $session->removeDatabaseItem($category, 0, $session->readDatabaseRecord($category, 0, $frame)['rows'][0]['key']);
+    $session->addDatabaseItem($category, 0, ['frame' => $frame]);
+    foreach (['Type' => 'move_route', 'Subject' => 'player', 'Route Mode' => 'waypoints', 'Remember As' => 'caller-entry', 'Seconds Per Step' => '0.1'] as $name => $value) {
+        $setRow($name, $value, [0]);
+    }
+    foreach ([['Y' => '31'], ['X' => '47'], ['Y' => '20']] as $n => $point) {
+        $mode = array_find($session->readDatabaseRecord($category, 0, $frame)['rows'],
+            static fn(array $row): bool => ($row['name'] ?? '') === 'Route Mode' && ($row['entry'] ?? null) === [0]);
+        $session->addDatabaseItem($category, 0, $mode['key'], true);
+        foreach ($point as $name => $value) {
+            $setRow($name, $value, [0, $n]);
+        }
+    }
+    $saved = $session->saveAll();
+
+    expect($saved['failures'])->toBe([])
+        // The route keeps the key order the file wrote it in.
+        ->and((require $file)[0]['then'])->toBe([[
+            'type' => 'move_route', 'subject' => 'player', 'remember' => 'caller-entry',
+            'waypoints' => [['y' => 31], ['x' => 47], ['y' => 20]], 'secondsPerStep' => 0.1,
+        ]])
+        ->and((string) file_get_contents($file))->toContain("['type' => 'event', 'name' => 'a', 'negate' => true],");
+});
diff --git a/tests/Unit/Session/DatabaseCoverageSessionTest.php b/tests/Unit/Session/DatabaseCoverageSessionTest.php
new file mode 100644
index 00000000..fa545416
--- /dev/null
+++ b/tests/Unit/Session/DatabaseCoverageSessionTest.php
@@ -0,0 +1,141 @@
+readDatabaseRecord($category, $index);
+    // References are read in the context of a map.
+    $map = $session->describeMaps()[0]['id'];
+
+    foreach ($read['rows'] as $row) {
+        $value = match ($row['kind']) {
+            'reference' => array_values(array_diff(
+                array_column($session->listReferences($map, $row['reference']), 'value'),
+                [$row['value']],
+            ))[0] ?? null,
+            'text' => ($row['value'] ?? '') === '' ? null : $row['value'] . ' Revised',
+            'integer' => is_numeric($row['raw'] ?? null) ? (string) ((int) $row['raw'] + 1) : null,
+            'float' => is_numeric($row['raw'] ?? null) ? (string) ((float) $row['raw'] + 0.5) : null,
+            'boolean' => ($row['raw'] ?? $row['value']) === 'true' ? 'false' : 'true',
+            'options' => array_values(array_diff($row['options'] ?? [], [$row['value']]))[0] ?? null,
+            default => null,
+        };
+
+        if ($value === null || ! isset($row['key'])) {
+            continue;
+        }
+
+        try {
+            if (($session->applyDatabaseRecord($category, $index, $row['key'], $value)['changed'] ?? false) === true) {
+                return true;
+            }
+        } catch (SessionRefusal) {
+            continue;
+        }
+    }
+
+    // A list's heading offers its first entry, as the GUI's "+ element" does.
+    $heading = array_find($read['rows'], static fn(array $row): bool => ($row['listHeading'] ?? false) === true && isset($row['key']));
+
+    if ($heading !== null) {
+        return $session->addDatabaseItem($category, $index, $heading['key'], child: true)['changed'];
+    }
+
+    return ($read['listNoun'] ?? null) !== null
+        && $session->addDatabaseItem($category, $index, ['frame' => []])['changed'];
+}
+
+it('opens every category, and creates, edits, saves and undoes in each writable one back to the bytes it read', function () {
+    $root = makeTemporaryProject();
+    // A new enemy starts on the project's first enemy sprite.
+    mkdir($root . '/assets/Graphics/Enemies', 0o777, true);
+    file_put_contents($root . '/assets/Graphics/Enemies/blob.txt', "(oo)\n");
+    // A new field resource starts on the project's first image.
+    writeTilesetTestPng($root . '/assets/Graphics/Props/Coverage.png', 4, 4);
+    // A new text role starts on the project's first font; the editor never decodes it, so its bytes are synthetic.
+    mkdir($root . '/assets/Fonts', 0o777, true);
+    file_put_contents($root . '/assets/Fonts/Coverage.ttf', 'synthetic font');
+    $session = EditorSession::open($root);
+    $writable = [];
+
+    foreach ($session->describeProject()['databases'] as $category) {
+        $key = $category['key'];
+        $list = $session->listDatabaseRecords($key);
+
+        foreach (array_keys($list['records']) as $index) {
+            expect($session->readDatabaseRecord($key, $index)['rows'])->toBeArray();
+        }
+
+        if (! $list['editable']) {
+            continue;
+        }
+
+        if ($list['records'] === [] && $list['canCreate']) {
+            $created = $session->createDatabaseRecord($key);
+
+            expect($session->saveDatabase($key)['saved'])->toBeTrue()
+                ->and(EditorSession::open($root)->listDatabaseRecords($key)['records'])->toBe($created['records']);
+        }
+
+        $writable[] = $key;
+    }
+
+    $before = sourceHashTree($root);
+    $roundTripped = [];
+
+    foreach ($writable as $key) {
+        $read = $session->readDatabaseRecord($key, 0)['rows'];
+
+        if (! editRecordOnce($session, $key, 0)) {
+            continue;
+        }
+
+        expect($session->saveDatabase($key)['saved'])->toBeTrue()
+            ->and(sourceHashTree($root))->not->toBe($before);
+
+        $session->undo();
+        $session->saveDatabase($key);
+
+        $after = sourceHashTree($root);
+
+        // Every file it read holds its bytes again. A category over defaults
+        // alone (a project with no system.php) has its file now, written by
+        // the save before the undo and holding what it read.
+        expect(array_intersect_key($after, $before))->toBe($before, sprintf('%s did not save back to the bytes it read.', $key))
+            ->and(count($after) - count($before))->toBeLessThanOrEqual(1)
+            ->and(EditorSession::open($root)->readDatabaseRecord($key, 0)['rows'])->toBe($read);
+        $before = $after;
+        $roundTripped[] = $key;
+    }
+
+    // Every category the session can write took an edit and gave it back.
+    expect($roundTripped)->toBe($writable);
+});
+
+it('says what a new record needs when the project lacks it', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+
+    expect(fn() => $session->createDatabaseRecord('enemies'))
+        ->toThrow(SessionRefusal::class, 'A new enemy starts on the project\'s first enemy sprite. Add one under assets/Graphics/Enemies first.');
+});
diff --git a/tests/Unit/Session/DatabaseSessionTest.php b/tests/Unit/Session/DatabaseSessionTest.php
new file mode 100644
index 00000000..8a4045ff
--- /dev/null
+++ b/tests/Unit/Session/DatabaseSessionTest.php
@@ -0,0 +1,287 @@
+ ($row['key']['field'] ?? null) === $fieldId)
+        ?? throw new RuntimeException("No record row {$fieldId}.");
+}
+
+it('lists a category with what it can do, and reads a frame\'s rows with the items they belong to', function () {
+    $root = makeTemporaryProject();
+    $unwritable = makeUnwritableCategory($root);
+    $session = EditorSession::open($root);
+    $states = $session->listDatabaseRecords('states');
+    $types = $session->listDatabaseRecords($unwritable);
+    $root = $session->readDatabaseRecord('common_events', 0);
+    $then = $session->readDatabaseRecord('common_events', 0, [3, 'then']);
+
+    expect($states)->toMatchArray(['editable' => true, 'dirty' => false, 'canCreate' => true, 'canDuplicate' => true, 'canDelete' => true, 'canReorder' => false])
+        ->and($types)->toMatchArray(['editable' => false, 'canCreate' => false, 'canDelete' => false])
+        ->and($root)->toMatchArray(['frame' => [], 'frameLabel' => null, 'editable' => true, 'readOnly' => null])
+        ->and(databaseSessionRow($root, '__scriptId'))->not->toHaveKey('item')
+        ->and(databaseSessionRow($root, '__scriptId')['kind'])->toBe('info')
+        ->and(databaseSessionRow($root, 'command3Then'))->toMatchArray(['frame' => [3, 'then'], 'item' => true, 'itemNoun' => 'command'])
+        ->and($then['frameLabel'])->toBe('Commands › Branch 4 › Then')
+        ->and(databaseSessionRow($then, 'command0Text'))->toMatchArray([
+            'kind' => 'text',
+            'key' => ['target' => 'record', 'field' => 'command0Text', 'frame' => [3, 'then']],
+            'item' => true,
+        ])
+        ->and(fn() => $session->readDatabaseRecord('common_events', 0, [9, 'then']))->toThrow(SessionRefusal::class, 'is no longer there')
+        ->and(fn() => $session->readDatabaseRecord('common_events', 0, ['then' => 3]))->toThrow(SessionRefusal::class, 'A frame must be a list')
+        ->and(fn() => $session->readDatabaseRecord('states', 9))->toThrow(SessionRefusal::class, 'states has no record 9.')
+        ->and(fn() => $session->readDatabaseRecord('skills', 0))->toThrow(SessionRefusal::class, 'skills has no record 0.');
+});
+
+it('applies a row as one undo step, history naming the category, and refuses what the field cannot take', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $name = databaseSessionRow($session->readDatabaseRecord('states', 0), 'name');
+
+    $applied = $session->applyDatabaseRecord('states', 0, $name['key'], 'Venom');
+    expect($applied)->toBe(['changed' => true, 'records' => ['Venom', 'Stun']])
+        ->and($session->listDatabaseRecords('states')['dirty'])->toBeTrue()
+        ->and($session->applyDatabaseRecord('states', 0, $name['key'], 'Venom')['changed'])->toBeFalse();
+
+    $undone = $session->undo();
+    expect($undone)->toMatchArray(['label' => 'Name edit', 'maps' => [], 'databases' => ['states']])
+        ->and($session->listDatabaseRecords('states'))->toMatchArray(['dirty' => false, 'records' => ['Poison', 'Stun']])
+        ->and($session->redo()['databases'])->toBe(['states'])
+        ->and($session->listDatabaseRecords('states')['records'][0])->toBe('Venom');
+
+    $conditions = databaseSessionRow($session->readDatabaseRecord('common_events', 0), 'command3Conditions');
+    expect(fn() => $session->applyDatabaseRecord('common_events', 0, $conditions['key'], 'nonsense'))
+        ->toThrow(SessionRefusal::class, 'Condition "nonsense" cannot be read')
+        ->and(fn() => $session->applyDatabaseRecord('common_events', 0, ['field' => 'command3Then', 'frame' => []], '2'))
+        ->toThrow(SessionRefusal::class, 'Command 4 Then Commands cannot be edited here.')
+        ->and(fn() => $session->applyDatabaseRecord('states', 0, ['field' => 'nope', 'frame' => []], 'x'))
+        ->toThrow(SessionRefusal::class, 'no longer in the record')
+        ->and($session->listDatabaseRecords('common_events')['dirty'])->toBeFalse();
+});
+
+it('adds and removes items by row, beneath an item that holds a list, and at a frame\'s end', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $commands = static fn(array $frame = []): array => array_column(
+        array_filter($session->readDatabaseRecord('common_events', 0, $frame)['rows'], static fn(array $row): bool => str_ends_with($row['key']['field'] ?? '', 'Type')),
+        'value',
+    );
+
+    expect($session->addDatabaseItem('common_events', 0, ['frame' => [3, 'then']])['changed'])->toBeTrue()
+        ->and($commands([3, 'then']))->toBe(['text', 'text']);
+
+    $session->applyDatabaseRecord('common_events', 0, ['field' => 'command0Type', 'frame' => []], 'choice');
+    $prompt = databaseSessionRow($session->readDatabaseRecord('common_events', 0), 'command0Prompt');
+    expect($prompt['childNoun'])->toBe('option')
+        ->and($session->addDatabaseItem('common_events', 0, $prompt['key'], child: true)['changed'])->toBeTrue();
+    $option = databaseSessionRow($session->readDatabaseRecord('common_events', 0), 'command0Option0Text');
+    expect($option)->toMatchArray(['item' => true, 'itemNoun' => 'option'])->not->toHaveKey('childNoun');
+
+    $session->addDatabaseItem('common_events', 0, $prompt['key']);
+    expect($commands())->toBe(['choice', 'text', 'record_event', 'give_gold', 'branch'])
+        ->and($session->removeDatabaseItem('common_events', 0, $option['key'])['changed'])->toBeTrue()
+        ->and(fn() => $session->addDatabaseItem('common_events', 0, ['field' => '__scriptId', 'frame' => []]))
+        ->toThrow(SessionRefusal::class, 'belongs to no item')
+        ->and($session->removeDatabaseItem('common_events', 0, ['field' => '__scriptId', 'frame' => []])['changed'])->toBeFalse();
+
+    expect(array_column([$session->undo(), $session->undo(), $session->undo(), $session->undo(), $session->undo()], 'label'))
+        ->toBe(['Option remove', 'Command add', 'Option add', 'Command 1 Type edit', 'Command add'])
+        ->and($session->listDatabaseRecords('common_events')['dirty'])->toBeFalse();
+});
+
+it('creates, duplicates, moves and deletes records, each one undo step', function () {
+    $root = makeTemporaryProject();
+    $unwritable = makeUnwritableCategory($root);
+    $session = EditorSession::open($root);
+
+    expect($session->createDatabaseRecord('states'))->toBe(['index' => 2, 'records' => ['Poison', 'Stun', 'New State']])
+        ->and($session->duplicateDatabaseRecord('states', 0))->toBe(['index' => 1, 'records' => ['Poison', 'Poison', 'Stun', 'New State']])
+        ->and($session->deleteDatabaseRecord('states', 1))->toBe(['index' => 0, 'records' => ['Poison', 'Stun', 'New State']])
+        ->and(fn() => $session->moveDatabaseRecord('states', 0, 'down'))->toThrow(SessionRefusal::class, 'would not survive reopening')
+        ->and(fn() => $session->createDatabaseRecord('terms'))->toThrow(SessionRefusal::class, 'Term entries cannot be created from the editor.')
+        ->and(fn() => $session->deleteDatabaseRecord($unwritable, 0))->toThrow(SessionRefusal::class, 'Read-only: ');
+
+    expect(array_column([$session->undo(), $session->undo(), $session->undo()], 'label'))
+        ->toBe(['Delete state Poison', 'State duplicate', 'State create'])
+        ->and($session->listDatabaseRecords('states'))->toMatchArray(['dirty' => false, 'records' => ['Poison', 'Stun']]);
+
+    $first = $session->createDatabaseRecord('battle_entry_rules');
+    $second = $session->createDatabaseRecord('battle_entry_rules');
+    $labels = $second['records'];
+    expect($session->moveDatabaseRecord('battle_entry_rules', $first['index'], 'down'))
+        ->toBe(['index' => $second['index'], 'changed' => true, 'records' => array_reverse($labels)])
+        ->and($session->moveDatabaseRecord('battle_entry_rules', $second['index'], 'down')['changed'])->toBeFalse()
+        ->and($session->undo())->toMatchArray(['label' => 'Battle entry rule move', 'databases' => ['battle_entry_rules']])
+        ->and($session->listDatabaseRecords('battle_entry_rules')['records'])->toBe($labels);
+});
+
+it('saves a category through its own file, and refuses one that cannot be written', function () {
+    $root = makeTemporaryProject();
+    $unwritable = makeUnwritableCategory($root);
+    $session = EditorSession::open($root);
+
+    expect($session->saveDatabase('states'))->toBe(['saved' => false, 'warnings' => [], 'backupFailures' => []]);
+
+    $session->applyDatabaseRecord('states', 0, ['field' => 'name', 'frame' => []], 'Venom');
+    $saved = $session->saveDatabase('states');
+
+    expect($saved)->toMatchArray(['saved' => true, 'warnings' => [], 'backupFailures' => []])
+        ->and(loadRecordDatabase($root, 'states')->getEntryLabels())->toBe(['Venom', 'Stun'])
+        ->and($session->listDatabaseRecords('states')['dirty'])->toBeFalse()
+        ->and($session->hasUnsavedChanges())->toBeFalse()
+        ->and(fn() => $session->saveDatabase($unwritable))->toThrow(SessionRefusal::class, 'Read-only: ');
+});
+
+it('serves database authoring over the line protocol', function () {
+    $root = makeTemporaryProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+
+    $read = $request(2, 'database.record', ['category' => 'troops', 'index' => 0, 'frame' => []])['result'];
+    $applied = $request(3, 'database.apply', ['category' => 'troops', 'index' => 0, 'key' => databaseSessionRow($read, 'name')['key'], 'value' => 'Bats'])['result'];
+    $added = $request(4, 'database.add', ['category' => 'troops', 'index' => 0, 'key' => databaseSessionRow($read, 'member0Enemy')['key']])['result'];
+    $removed = $request(5, 'database.remove', ['category' => 'troops', 'index' => 0, 'key' => databaseSessionRow($read, 'member0Enemy')['key']])['result'];
+    $created = $request(6, 'database.create', ['category' => 'troops'])['result'];
+    $copy = $request(7, 'database.duplicate', ['category' => 'troops', 'index' => 0])['result'];
+    $deleted = $request(8, 'database.delete', ['category' => 'troops', 'index' => 1])['result'];
+    $undone = $request(9, 'history.undo')['result'];
+    $saved = $request(10, 'database.save', ['category' => 'troops'])['result'];
+
+    expect($applied)->toBe(['changed' => true, 'records' => ['Bats', 'Lone Rat']])
+        ->and([$added['changed'], $removed['changed']])->toBe([true, true])
+        ->and($created['index'])->toBe(2)
+        ->and($copy['index'])->toBe(1)
+        ->and($deleted['index'])->toBe(0)
+        ->and($undone['databases'])->toBe(['troops'])
+        ->and($saved['saved'])->toBeTrue()
+        ->and($request(11, 'database.records', ['category' => 'troops'])['result']['dirty'])->toBeFalse()
+        ->and($request(12, 'database.move', ['category' => 'troops', 'index' => 0, 'direction' => 'down'])['error']['kind'])->toBe('refusal')
+        ->and($request(13, 'database.move', ['category' => 'troops', 'index' => 0, 'direction' => 'sideways'])['error'])
+            ->toBe(['kind' => 'request', 'message' => '"direction" must be "up" or "down".'])
+        ->and($request(14, 'database.apply', ['category' => 'troops', 'index' => 0, 'value' => 'x'])['error']['kind'])->toBe('request')
+        ->and($request(15, 'database.add', ['category' => 'troops', 'index' => 0, 'key' => [], 'child' => 'yes'])['error'])
+            ->toBe(['kind' => 'request', 'message' => '"child" must be a boolean.'])
+        ->and($request(16, 'database.record', ['category' => 'troops', 'index' => 0, 'frame' => 'x'])['error']['kind'])->toBe('request');
+});
+
+it('marks the items NPC rows belong to, from the same rules the database rows use', function () {
+    $root = makeTemporaryProject('ichiloto-npc-items-');
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $data = require $path;
+    $data['npcs'] = [['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1, 'script' => [
+        ['type' => 'choice', 'prompt' => 'Well?', 'options' => [['text' => 'Yes', 'then' => []], ['text' => 'No', 'then' => []]]],
+    ]]];
+    file_put_contents($path, "readNpc('test-map', 0, ['script']);
+    $option = databaseSessionRow($script, 'command0Option1Text');
+
+    expect(databaseSessionRow($script, 'command0Type'))->toMatchArray(['item' => true, 'itemNoun' => 'command', 'childNoun' => 'option'])
+        ->and($option)->toMatchArray(['item' => true, 'itemNoun' => 'option'])
+        ->and(databaseSessionRow($session->readNpc('test-map', 0), 'name'))->not->toHaveKey('item');
+
+    // An option row removes that option, not the whole choice.
+    $removed = $session->removeNpcItem('test-map', $script['revision'], 0, $option['key']);
+    $after = $session->readNpc('test-map', 0, ['script']);
+    expect($removed['changed'])->toBeTrue()
+        ->and(array_column(array_filter($after['rows'], static fn(array $row): bool => str_ends_with($row['key']['field'] ?? '', 'Text')), 'value'))
+        ->toBe(['Yes']);
+});
+
+it('names every unsaved map and database, and saves them all in one request', function () {
+    $root = makeTemporaryProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]))['result'];
+    $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+
+    expect($request(2, 'project.dirty'))->toBe(['dirty' => false, 'unsaved' => []]);
+
+    $map = $request(3, 'map.read', ['map' => 'test-map']);
+    $request(4, 'map.paint', ['map' => 'test-map', 'revision' => $map['revision'], 'layer' => $map['baseLayer'], 'cells' => [[1, 1]], 'symbol' => '#']);
+    $request(5, 'database.apply', ['category' => 'states', 'index' => 0, 'key' => ['field' => 'name', 'frame' => []], 'value' => 'Venom']);
+    $states = DatabaseCatalog::at(DatabaseCatalog::indexOf('states'))->label;
+
+    expect($request(6, 'project.dirty'))->toBe(['dirty' => true, 'unsaved' => ['test-map', $states . ' database']]);
+
+    $saved = $request(7, 'project.saveAll');
+
+    expect($saved)->toMatchArray(['summary' => 'Saved 1 map, 1 database and 0 cutscenes.', 'failures' => [], 'skippedRenames' => [], 'unsaved' => []])
+        ->and($request(8, 'project.dirty'))->toBe(['dirty' => false, 'unsaved' => []])
+        ->and(ProjectWorkspace::fromProject($root)->maps[0]->getLayerSymbol($map['baseLayer'], 1, 1))->toBe('#')
+        ->and(loadRecordDatabase($root, 'states')->getEntryLabels())->toBe(['Venom', 'Stun']);
+});
+
+it('serves animations as a schema category, its roles a list picked a member at a time', function () {
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/Data/animations.php', " 1, 'name' => 'Hit Spark', 'roles' => ['attack'], 'targetEffect' => 'battle-physical-impact']];");
+    $session = EditorSession::open($root);
+    $roles = databaseSessionRow($session->readDatabaseRecord('animations', 0), 'roles');
+
+    expect($session->listDatabaseRecords('animations')['records'])->toBe(['Hit Spark'])
+        ->and($roles)->toMatchArray(['kind' => 'reference', 'reference' => 'animation_roles', 'multi' => true, 'value' => 'attack']);
+});
+
+it('reads a record with the summaries the terminal shows beside it', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $class = $session->readDatabaseRecord('classes', 0);
+    $state = $session->readDatabaseRecord('states', 0);
+
+    expect(array_column($class['panes'], 'title'))->toBe(['Experience Curve', 'Stat Curves', 'Preview'])
+        ->and($class['panes'][2]['lines'])->toContain('Curve Samples')
+        ->and($state['panes'])->toBe([])
+        ->and($session->readDatabaseRecord('common_events', 0, [3, 'then'])['panes'])->toBe([]);
+});
+
+it('serves the field and rest state fields and an item cure\'s state picker to the graphical editor', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $state = $session->readDatabaseRecord('states', 0);
+
+    foreach (['label', 'fieldTickSteps', 'fieldTickKnocksOut', 'curedByRest'] as $field) {
+        expect(databaseSessionRow($state, $field)['key'])->toMatchArray(['target' => 'record', 'field' => $field]);
+    }
+
+    expect($session->applyDatabaseRecord('states', 0, databaseSessionRow($state, 'fieldTickSteps')['key'], '6')['changed'])->toBeTrue();
+
+    $session->addDatabaseItem('items', 1, ['frame' => []]);
+    $item = $session->readDatabaseRecord('items', 1);
+    $session->applyDatabaseRecord('items', 1, databaseSessionRow($item, 'effect0Type')['key'], 'remove_state');
+    $cure = databaseSessionRow($session->readDatabaseRecord('items', 1), 'effect0StateIds');
+
+    expect($cure['key'])->toMatchArray(['target' => 'record', 'field' => 'effect0StateIds'])
+        ->and(json_encode($cure))->toContain('states');
+});
+
+it('duplicates a record under the name it is meant to have, its file named for it', function () {
+    $root = troopFormationProject();
+    writeRegularBatEnemy($root);
+    $session = EditorSession::open($root);
+
+    expect($session->duplicateDatabaseRecord('enemies', 0, 'Cave Bat'))->toMatchArray(['index' => 1, 'records' => ['Regular Bat', 'Cave Bat']])
+        // A name another record holds is made unique, as an unnamed copy's is.
+        ->and($session->duplicateDatabaseRecord('enemies', 0, 'Cave Bat')['records'])->toContain('Cave Bat-2')
+        ->and(fn() => $session->duplicateDatabaseRecord('enemies', 0, ' '))->toThrow(SessionRefusal::class, 'needs a name');
+
+    $session->saveDatabase('enemies');
+    $copy = require $root . '/assets/Data/Enemies/cave-bat.php';
+    expect($copy['data']['name'])->toBe('Cave Bat')
+        ->and($copy['data']['stats'])->toBe((require $root . '/assets/Data/Enemies/regular-bat.php')['data']['stats']);
+});
diff --git a/tests/Unit/Session/DialoguePortraitsSessionTest.php b/tests/Unit/Session/DialoguePortraitsSessionTest.php
new file mode 100644
index 00000000..9b9f2f23
--- /dev/null
+++ b/tests/Unit/Session/DialoguePortraitsSessionTest.php
@@ -0,0 +1,253 @@
+readDatabaseRecord($category, $index, $frame)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+    expect($row)->not->toBeNull("{$category} {$index} has a {$field} row");
+
+    return $row;
+}
+
+it('lists actor and character identities as one page and edits their artwork in the file\'s own source', function () {
+    [$root, $file, $session] = createDialogueProject();
+    $listing = $session->listDatabaseRecords(DialoguePortraitFields::CATEGORY);
+    expect($listing['editable'])->toBeTrue()
+        ->and($listing['records'])->toBe(['Kaelion (actor)', 'Mira (character)']);
+
+    $portrait = findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'portrait');
+    expect($portrait['kind'])->toBe('reference')->and($portrait['reference'])->toBe('png_assets')
+        ->and($portrait['media'])->toBe(['kind' => 'image', 'root' => 'assets']);
+    $id = findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'id');
+    expect($id['reference'])->toBe('actor_ids');
+
+    // A bust for the actor and a new emotion, chosen as project images.
+    $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'bust')['key'], 'Graphics/Faces/Hero.png');
+    $heading = array_find($session->readDatabaseRecord(DialoguePortraitFields::CATEGORY, 0)['rows'],
+        static fn(array $row): bool => ($row['label'] ?? null) === 'Emotions');
+    $session->addDatabaseItem(DialoguePortraitFields::CATEGORY, 0, $heading['key']);
+    $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'emotion0Name')['key'], 'Sad');
+    $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'emotion0Portrait')['key'], 'Graphics/Faces/HeroSad.png');
+    $session->saveDatabase(DialoguePortraitFields::CATEGORY);
+
+    $source = (string) file_get_contents($file);
+    expect($source)->toContain('// Synthetic dialogue artwork for editor tests.', '// The innkeeper.')
+        ->and($source)->not->toContain('base64');
+    $catalogue = DialoguePresentationCatalog::load($root . '/assets');
+    expect($catalogue->actors['Kaelion']['bust'])->toBe('Graphics/Faces/Hero.png')
+        ->and($catalogue->actors['Kaelion']['emotions']['Sad'])->toBe('Graphics/Faces/HeroSad.png')
+        // Untouched entries keep their authored shape.
+        ->and($catalogue->resources['Mira']['emotions'])->toBe(['Worried' => 'Graphics/Faces/MiraWorried.png']);
+});
+
+it('moves an identity between actors and characters by what it speaks as', function () {
+    [$root, $file, $session] = createDialogueProject();
+    $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0, findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'kind')['key'], DialoguePortraitFields::CHARACTER);
+    // A character's identity is typed; an actor's is chosen among the actors.
+    expect(findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'id')['kind'])->toBe('text');
+    $session->saveDatabase(DialoguePortraitFields::CATEGORY);
+    $catalogue = DialoguePresentationCatalog::load($root . '/assets');
+    expect($catalogue->actors)->toBe([])
+        ->and(array_keys($catalogue->resources))->toBe(['Kaelion', 'Mira'])
+        ->and($catalogue->resources['Kaelion']['emotions']['Angry']['portrait'])->toBe('Graphics/Faces/HeroAngry.png')
+        ->and((string) file_get_contents($file))->toContain('// The innkeeper.');
+});
+
+it('binds speaker names to identities chosen from the catalogue, its unsaved identities included', function () {
+    [$root, , $session] = createDialogueProject();
+    expect($session->listDatabaseRecords(DialogueSpeakerFields::CATEGORY)['records'])->toBe(['Kaelion -> Kaelion', 'Mom -> Mira']);
+    $identity = findDialogueRow($session, DialogueSpeakerFields::CATEGORY, 1, 'identity');
+    expect($identity['reference'])->toBe('dialogue_identities');
+
+    $created = $session->createDatabaseRecord(DialoguePortraitFields::CATEGORY, 'Elder Tov');
+    $choices = $session->listReferences(null, 'dialogue_identities');
+    expect(array_column($choices, 'value'))->toBe(['Kaelion', 'Mira', 'Elder Tov'])
+        ->and(array_column($choices, 'label'))->toBe(['Kaelion (actor)', 'Mira (character)', 'Elder Tov (character)']);
+
+    $speaker = $session->createDatabaseRecord(DialogueSpeakerFields::CATEGORY, 'The Elder')['index'];
+    $session->applyDatabaseRecord(DialogueSpeakerFields::CATEGORY, $speaker, findDialogueRow($session, DialogueSpeakerFields::CATEGORY, $speaker, 'identity')['key'], 'Elder Tov');
+    $session->saveAll();
+    $catalogue = DialoguePresentationCatalog::load($root . '/assets');
+    expect($catalogue->speakers)->toBe(['Kaelion' => 'Kaelion', 'Mom' => 'Mira', 'The Elder' => 'Elder Tov'])
+        ->and(array_keys($catalogue->resources))->toBe(['Mira', 'Elder Tov'])
+        ->and($created['index'])->toBe(2);
+});
+
+it('leaves identities the file builds with code read-only, saying why, while its literal speaker names stay editable', function () {
+    [, $file, $session] = createDialogueProject(<<<'PHP'
+ 'Graphics/Faces/Hero.png'];
+}
+
+return [
+  'schema' => 'ichiloto.dialogue/1',
+  'actors' => $actors,
+  'speakers' => ['Kaelion' => 'Kaelion'],
+];
+PHP);
+    $before = (string) file_get_contents($file);
+    $portraits = $session->listDatabaseRecords(DialoguePortraitFields::CATEGORY);
+    expect($portraits['editable'])->toBeFalse()
+        ->and($portraits['readOnly'])->toContain('builds "actors" with its own PHP code in $actors')
+        ->and($portraits['records'])->toBe(['Kaelion (actor)']);
+    expect(fn() => $session->applyDatabaseRecord(DialoguePortraitFields::CATEGORY, 0,
+        findDialogueRow($session, DialoguePortraitFields::CATEGORY, 0, 'bust')['key'], 'Graphics/Faces/Hero.png'))->toThrow(SessionRefusal::class);
+
+    expect($session->listDatabaseRecords(DialogueSpeakerFields::CATEGORY)['editable'])->toBeTrue();
+    $session->applyDatabaseRecord(DialogueSpeakerFields::CATEGORY, 0, findDialogueRow($session, DialogueSpeakerFields::CATEGORY, 0, 'name')['key'], 'Kael');
+    $session->saveDatabase(DialogueSpeakerFields::CATEGORY);
+    expect((string) file_get_contents($file))->toBe(str_replace("'speakers' => ['Kaelion' => 'Kaelion']", "'speakers' => ['Kael' => 'Kaelion']", $before));
+});
+
+
+it('offers a line the emotions its speaker\'s identity has, through actor ids and speaker names alike', function () {
+    [$root, , $session] = createDialogueProject();
+    file_put_contents($root . '/assets/Data/Skits/breakfast-banter.php', <<<'PHP'
+ 'breakfast-banter',
+  'title' => 'Breakfast Banter',
+  'where' => 'happyville/town-center',
+  'beats' => [
+    ['actor' => 'Kaelion', 'text' => 'Again?', 'emotion' => 'Angry'],
+    ['speaker' => 'Mom', 'text' => 'Eat first.', 'emotion' => 'Furious'],
+    ['speaker' => 'A Stranger', 'text' => 'Hm.'],
+  ],
+];
+PHP);
+    $session = EditorSession::open($root);
+    $actorLine = findDialogueRow($session, 'skits', 0, 'beat0Emotion');
+    expect($actorLine['kind'])->toBe('options')->and($actorLine['options'])->toBe(['Neutral', 'Angry'])->and($actorLine['value'])->toBe('Angry');
+    $rows = $session->readDatabaseRecord('skits', 0)['rows'];
+    $momLine = findDialogueRow($session, 'skits', 0, 'beat1Emotion');
+    expect($momLine['options'])->toBe(['Neutral', 'Worried']);
+    // Kept as written, and said: the identity has no such emotion.
+    $warning = array_values(array_filter($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Beat 2 Emotion Warning'));
+    expect($warning)->toHaveCount(1)->and($warning[0]['kind'])->toBe('info')->and($warning[0]['tone'])->toBe('warning')
+        ->and($warning[0]['value'])->toContain('Mira has no "Furious" emotion');
+    // A speaker the catalogue does not bind shows no portrait: Neutral is all there is.
+    expect(findDialogueRow($session, 'skits', 0, 'beat2Emotion')['options'])->toBe(['Neutral'])
+        ->and(findDialogueRow($session, 'skits', 0, 'beat2Emotion')['value'])->toBe('Neutral');
+
+    $session->applyDatabaseRecord('skits', 0, $momLine['key'], 'Worried');
+    $session->saveDatabase('skits');
+    expect((require $root . '/assets/Data/Skits/breakfast-banter.php')['beats'][1]['emotion'])->toBe('Worried');
+});
+
+it('chooses a text command\'s emotion for the speaker its name resolves to, following a speaker change', function () {
+    [$root, , $session] = createDialogueProject();
+    $command = findDialogueRow($session, 'common_events', 0, 'command0Emotion');
+    // The dresser note is read by nobody, so it shows no portrait.
+    expect($command['options'])->toBe(['Neutral']);
+    $session->applyDatabaseRecord('common_events', 0, findDialogueRow($session, 'common_events', 0, 'command0Name')['key'], 'Mom');
+    $command = findDialogueRow($session, 'common_events', 0, 'command0Emotion');
+    expect($command['options'])->toBe(['Neutral', 'Worried']);
+    $session->applyDatabaseRecord('common_events', 0, $command['key'], 'Worried');
+    $session->saveDatabase('common_events');
+    expect((require $root . '/assets/Events/dresser-note.php')[0])->toBe(['type' => 'text', 'name' => 'Mom', 'text' => 'A note is tucked under the lamp.', 'emotion' => 'Worried']);
+
+    // The branch's own text command, in its frame, follows the same rule.
+    $nested = findDialogueRow($session, 'common_events', 0, 'command0Emotion', [3, 'then']);
+    expect($nested['options'])->toBe(['Neutral']);
+});
+
+it('chooses an NPC line\'s emotion for its own speaker, or the NPC\'s name when the line names none', function () {
+    [, , $session] = createDialogueProject();
+    $revision = $session->readMap('test-map')['revision'];
+    $index = $session->createNpc('test-map', $revision, 1, 1, 'Mom')['index'];
+    $emotion = array_find($session->readNpc('test-map', $index)['rows'], static fn(array $row): bool => str_ends_with((string) ($row['key']['field'] ?? ''), 'Emotion'));
+    expect($emotion)->not->toBeNull()
+        ->and($emotion['options'])->toBe(['Neutral', 'Worried']);
+    $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $emotion['key'], 'Worried');
+    $line = getSessionMap($session)->getNpcs()->toMapData()[$index]['dialogue'][0];
+    expect($line['emotion'])->toBe('Worried');
+});
+
+it('offers each page of a map Dialogue event the emotions of its speaker, kept out of the event until chosen', function () {
+    [$root] = createDialogueProject();
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("  'events' => [\n", <<<'PHP'
+  'events' => [
+    'D' => [
+      'class' => 'Ichiloto\Engine\Events\Triggers\DialogueEventTrigger',
+      'data' => [
+        'dialogue' => [
+          ['name' => 'Mom', 'text' => 'Supper is ready.'],
+          ['name' => 'Kaelion', 'text' => 'Coming!', 'emotion' => 'Sulky'],
+        ],
+      ],
+    ],
+
+PHP, (string) file_get_contents($data)));
+    $session = EditorSession::open($root);
+    $rows = $session->readInspector('test-map', 'D')['rows'];
+    // Plain pages are one dialogue variant's lines, edited as an NPC's are.
+    $first = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue variant 1 Line 1 Emotion');
+    expect($first['kind'])->toBe('options')->and($first['options'])->toBe(['Neutral', 'Worried'])->and($first['value'])->toBe('Neutral');
+    $warning = array_find($rows, static fn(array $row): bool => ($row['label'] ?? '') === 'Dialogue variant 1 Line 2 Emotion Warning');
+    expect($warning['value'])->toContain('Kaelion has no "Sulky" emotion');
+    expect(getSessionMap($session)->getEventDefinition('D')['data']['dialogue'][0])->not->toHaveKey('emotion');
+
+    $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $first['key'], 'Worried');
+    expect(getSessionMap($session)->getEventDefinition('D')['data']['dialogue'][0])->toBe(['name' => 'Mom', 'text' => 'Supper is ready.', 'emotion' => 'Worried']);
+});
+
+it('reports dangling speaker names, artwork the game cannot draw, and line emotions their speaker lacks', function () {
+    [$root] = createDialogueProject();
+    unlink($root . '/assets/Graphics/Faces/MiraWorried.png');
+    file_put_contents($root . '/assets/Data/Skits/breakfast-banter.php', <<<'PHP'
+ 'breakfast-banter',
+  'title' => 'Breakfast Banter',
+  'where' => 'happyville/town-center',
+  'beats' => [
+    ['actor' => 'Kaelion', 'text' => 'Again?', 'emotion' => 'Angry'],
+    ['speaker' => 'Mom', 'text' => 'Eat first.', 'emotion' => 'Furious'],
+  ],
+];
+PHP);
+    $workspace = Ichiloto\Editor\ProjectWorkspace::fromProject($root);
+    $speaker = $workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY)->requireNewRecord('Guard');
+    $workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY)->getRecordByIndex($speaker)->set('identity', 'Nobody');
+
+    $issues = new Ichiloto\Editor\Validation\DialoguePresentationValidator()->validate($workspace);
+    $messages = array_map(static fn($issue): string => $issue->where . ' | ' . $issue->message, $issues);
+    expect($messages)->toHaveCount(1)->and($messages[0])->toContain('speakers.Guard', 'Speaker name "Guard" shows "Nobody"');
+
+    $workspace->getRecordDatabase(DialogueSpeakerFields::CATEGORY)->getRecordByIndex($speaker)->set('identity', 'Mira');
+    $messages = array_map(static fn($issue): string => $issue->where . ' | ' . $issue->message,
+        new Ichiloto\Editor\Validation\DialoguePresentationValidator()->validate($workspace));
+    expect($messages)->toHaveCount(2)
+        ->and($messages[0])->toContain('Mira Worried\'s dialogue portrait Graphics/Faces/MiraWorried.png cannot be drawn')
+        ->and($messages[1])->toContain('assets/Data/Skits/breakfast-banter.php:beat 2', 'Mira has no "Furious" emotion');
+});
+
+it('chooses a cinematic text line\'s emotion as an event text command\'s', function () {
+    $root = cutsceneProject();
+    mkdir($root . '/assets/Data/Presentation', 0o777, true);
+    file_put_contents($root . '/' . Ichiloto\Editor\Dialogue\DialogueCatalogFile::RELATIVE_PATH, syntheticDialogueCatalogue());
+    $script = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($script, str_replace("  ['type' => 'title_card'", "  ['type' => 'text', 'name' => 'Mom', 'text' => 'Look at them.'],\n  ['type' => 'title_card'", (string) file_get_contents($script)));
+    $session = EditorSession::open($root);
+    $category = Ichiloto\Editor\Cutscenes\CutsceneType::CINEMATIC->getRecordCategory();
+    $emotion = findDialogueRow($session, $category, 0, 'command4Emotion', ['commands']);
+    expect($emotion['options'])->toBe(['Neutral', 'Worried']);
+    $session->applyDatabaseRecord($category, 0, $emotion['key'], 'Worried');
+    $session->saveAll();
+    expect((require $script)[4])->toBe(['type' => 'text', 'name' => 'Mom', 'text' => 'Look at them.', 'emotion' => 'Worried']);
+});
diff --git a/tests/Unit/Session/DrawLayerTilesSessionTest.php b/tests/Unit/Session/DrawLayerTilesSessionTest.php
new file mode 100644
index 00000000..50433b05
--- /dev/null
+++ b/tests/Unit/Session/DrawLayerTilesSessionTest.php
@@ -0,0 +1,97 @@
+readTiles('test-map')['layers'], static fn(array $found): bool => $found['name'] === $layer)['rows'] ?? [];
+}
+
+it('draws every glyph its piece\'s tiles in one undo step, and nothing more when they are already there', function () {
+    $session = drawLayerTilesSession([
+        'ramp' => ['name' => 'Ramp', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['60']]],
+        'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]],
+    ]);
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect($session->drawLayerTiles('test-map', $revision, 'map:4'))->toMatchArray(['status' => 'applied', 'cells' => 3])
+        ->and(readDrawnTiles($session, 'furniture'))->toBe([[0, 60, 0, 0], [0, 61, 61, 0]])
+        ->and($session->drawLayerTiles('test-map', $session->readMap('test-map')['revision'], 'map:4'))->toMatchArray(['cells' => 0]);
+
+    $session->undo();
+    expect(readDrawnTiles($session, 'furniture'))->toBe([]);
+});
+
+it('asks which piece a glyph is when several draw it, and draws with the answer', function () {
+    $session = drawLayerTilesSession([
+        'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]],
+        'barrel' => ['name' => 'Barrel', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['62']]],
+    ]);
+    $revision = $session->readMap('test-map')['revision'];
+    $question = $session->drawLayerTiles('test-map', $revision, 'map:4');
+
+    expect($question['status'])->toBe('question')
+        ->and($question['glyph'])->toBe('x')
+        ->and(array_column($question['roles'], 'label'))->toHaveCount(2)
+        ->and(readDrawnTiles($session, 'furniture'))->toBe([]);
+    $barrel = array_find($question['roles'], static fn(array $role): bool => str_contains($role['label'], 'Barrel'))['key'];
+    expect($session->drawLayerTiles('test-map', $revision, 'map:4', ['x' => $barrel]))->toMatchArray(['status' => 'applied', 'cells' => 2])
+        ->and(readDrawnTiles($session, 'furniture')[1])->toBe([0, 62, 62, 0]);
+});
+
+it('refuses a layer no piece draws, or one the map does not have, changing nothing', function () {
+    $session = drawLayerTilesSession([]);
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->drawLayerTiles('test-map', $revision, 'map:4'))->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->drawLayerTiles('test-map', $revision, 'map:99'))->toThrow(SessionRefusal::class, 'has no layer')
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse();
+});
+
+it('lists the cells still showing glyphs, and they go once their tiles are drawn', function () {
+    $session = drawLayerTilesSession([
+        'ramp' => ['name' => 'Ramp', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['60']]],
+    ]);
+    // Without its floor and decor tiles, every glyph on the map shows.
+    foreach (['floor', 'decor'] as $layer) {
+        $session->removeTileLayer('test-map', $session->readMap('test-map')['revision'], $layer);
+    }
+    $before = $session->readCoverage('test-map');
+    $shownRamps = static fn(array $coverage): array => array_values(array_filter($coverage['cells'], static fn(array $cell): bool => $cell[2] === '/'));
+
+    expect($before['available'])->toBeTrue()
+        ->and($shownRamps($before))->toBe([[1, 0, '/', 'buildings']]);
+    $session->drawLayerTiles('test-map', $before['revision'], 'map:4', ['x' => null]);
+    expect($shownRamps($session->readCoverage('test-map')))->toBe([]);
+});
+
+it('says which piece a glyph plays at a cell, from the tiles around it, for the eyedropper', function () {
+    $session = drawLayerTilesSession([
+        'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]],
+        'barrel' => ['name' => 'Barrel', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['62']]],
+    ]);
+    expect($session->readPieceAt('test-map', 'map:4', 1, 1))->toBe(['glyph' => 'x', 'role' => null]);
+
+    $revision = $session->readMap('test-map')['revision'];
+    $barrel = array_find($session->drawLayerTiles('test-map', $revision, 'map:4')['roles'], static fn(array $role): bool => str_contains($role['label'], 'Barrel'))['key'];
+    $session->drawLayerTiles('test-map', $revision, 'map:4', ['x' => $barrel]);
+    expect($session->readPieceAt('test-map', 'map:4', 1, 1)['role']['key'])->toBe($barrel);
+});
diff --git a/tests/Unit/Session/EditorSessionTest.php b/tests/Unit/Session/EditorSessionTest.php
new file mode 100644
index 00000000..10c23b62
--- /dev/null
+++ b/tests/Unit/Session/EditorSessionTest.php
@@ -0,0 +1,509 @@
+ $candidate['id'] === $layer);
+
+    return $found['rows'][$y][$x] ?? throw new RuntimeException("No cell {$x},{$y} on {$layer}.");
+}
+
+it('describes the project: its maps and every database category the graphical editor offers', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $project = $session->describeProject();
+    $keys = array_column($project['databases'], 'key');
+
+    expect(array_column($project['maps'], 'id'))->toContain('test-map')
+        ->and($project['maps'][0])->toHaveKeys(['id', 'name', 'dirty', 'readOnly', 'revision'])
+        ->and($keys)->toBe(array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories()))
+        // Every terminal category is offered, in the terminal's order, before the graphical ones.
+        ->and(array_slice($keys, 0, count(DatabaseCatalog::all())))->toBe(array_map(static fn($category): string => $category->key, DatabaseCatalog::all()));
+});
+
+it('reads a map as an interface draws it: layers, glyphs, events and NPCs', function () {
+    $root = makeTemporaryProject();
+    $map = EditorSession::open($root)->readMap('test-map');
+    $authored = ProjectWorkspace::fromProject($root)->maps[0];
+
+    expect($map['width'])->toBe($authored->getWidth())
+        ->and($map['height'])->toBe($authored->getHeight())
+        ->and(array_column($map['layers'], 'id'))->toContain($map['baseLayer'], 'event')
+        ->and(sessionCell($map, $map['baseLayer'], 0, 0))->toBe($authored->getLayerSymbol($map['baseLayer'], 0, 0))
+        ->and(array_column($map['events'], 'marker'))->toBe(array_keys($authored->getEventDefinitions()))
+        ->and($map['dirty'])->toBeFalse();
+});
+
+it('paints as one undo step, undoes and redoes it, and refuses an edit made against an older revision', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $map = $session->readMap('test-map');
+    $layer = $map['baseLayer'];
+    $before = sessionCell($map, $layer, 2, 2);
+
+    $result = $session->paint('test-map', $map['revision'], $layer, [[2, 2], [3, 2]], '%', 'red');
+    $painted = $session->readMap('test-map');
+
+    expect($result['status'])->toBe('applied')
+        ->and($result['changed'])->toBe(2)
+        ->and($result['revision'])->toBe($painted['revision'])
+        ->and(sessionCell($painted, $layer, 2, 2))->toBe('%')
+        ->and(array_find(array_find($painted['layers'], static fn(array $l): bool => $l['id'] === $layer)['colors'],
+            static fn(array $c): bool => $c[0] === 2 && $c[1] === 2)[2] ?? null)->toBe('red')
+        ->and($painted['dirty'])->toBeTrue();
+
+    // The same request again carries a revision that is no longer current.
+    expect(fn() => $session->paint('test-map', $map['revision'], $layer, [[4, 2]], '%'))
+        ->toThrow(SessionRefusal::class, 'changed since revision');
+
+    expect($session->undo())->toMatchArray(['label' => 'Paint', 'maps' => ['test-map']])
+        ->and(sessionCell($session->readMap('test-map'), $layer, 2, 2))->toBe($before);
+    $redone = $session->redo();
+    // Each changed map's revision comes back, so the next edit can name it.
+    expect($redone['maps'])->toBe(['test-map'])
+        ->and($redone['revisions'])->toBe(['test-map' => $session->readMap('test-map')['revision']])
+        ->and(sessionCell($session->readMap('test-map'), $layer, 2, 2))->toBe('%');
+    expect($session->undo()['label'])->toBe('Paint')
+        ->and($session->undo())->toMatchArray(['label' => null, 'maps' => []]);
+});
+
+it('saves a painted map through its transaction, so a fresh load sees the edit', function () {
+    $root = makeTemporaryProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], $map['baseLayer'], [[1, 1]], '#');
+
+    $saved = $session->saveMap('test-map');
+
+    expect($saved['saved'])->toBe('test-map')
+        ->and($session->hasUnsavedChanges())->toBeFalse()
+        ->and(ProjectWorkspace::fromProject($root)->maps[0]->getLayerSymbol($map['baseLayer'], 1, 1))->toBe('#');
+});
+
+it('refuses an unknown map or layer with a reason an author reads', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $map = $session->readMap('test-map');
+
+    expect(fn() => $session->readMap('no-such-map'))->toThrow(SessionRefusal::class, 'There is no map no-such-map.')
+        ->and(fn() => $session->paint('test-map', $map['revision'], 'no-layer', [[0, 0]], '#'))
+            ->toThrow(SessionRefusal::class, 'test-map has no layer no-layer.');
+});
+
+it('serves the session over its line protocol, answering refusals and bad requests by kind', function () {
+    $root = makeTemporaryProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+
+    expect($request(1, 'maps.list')['error']['kind'])->toBe('request')
+        ->and($request(2, 'hello', ['protocol' => 99, 'project' => $root])['error']['message'])->toContain('protocol 1')
+        ->and($host->handle('not json')['error']['kind'])->toBe('request');
+
+    $hello = $request(3, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    expect($hello['id'])->toBe(3)
+        ->and($hello['result']['protocol'])->toBe(SessionHost::PROTOCOL)
+        ->and(array_column($hello['result']['project']['maps'], 'id'))->toContain('test-map');
+
+    $map = $request(4, 'map.read', ['map' => 'test-map'])['result'];
+    expect($request(5, 'map.paint', ['map' => 'test-map', 'revision' => $map['revision'], 'layer' => $map['baseLayer'],
+        'cells' => [[0, 0]], 'symbol' => '%'])['result']['status'])->toBe('applied')
+        ->and($request(6, 'map.paint', ['map' => 'test-map', 'revision' => $map['revision'], 'layer' => $map['baseLayer'],
+            'cells' => [[0, 0]], 'symbol' => '#'])['error']['kind'])->toBe('refusal')
+        ->and($request(7, 'map.paint', ['map' => 'test-map', 'revision' => 1, 'layer' => 'tile', 'cells' => [[0]], 'symbol' => '#'])['error'])
+            ->toBe(['kind' => 'request', 'message' => '"cells" must be a list of [x, y] integer pairs.'])
+        ->and($request(8, 'no.such')['error']['message'])->toBe('Unknown method "no.such".')
+        ->and($request(9, 'history.undo')['result']['maps'])->toBe(['test-map']);
+});
+
+/** The inspector row with a label, the first when several share it. */
+function inspectorRow(array $inspector, string $label, ?string $after = null): array
+{
+    $rows = $inspector['rows'];
+    $start = $after === null ? 0 : array_search($after, array_column($rows, 'label'), true) + 1;
+    foreach (array_slice($rows, $start) as $row) {
+        if (trim($row['label']) === $label) {
+            return $row;
+        }
+    }
+
+    throw new RuntimeException("No inspector row {$label}.");
+}
+
+it('lists a map\'s inspector rows with how each is edited and the key that names it', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $inspector = $session->readInspector('test-map');
+
+    expect(inspectorRow($inspector, 'Name'))->toMatchArray(['kind' => 'text', 'key' => ['target' => 'map', 'field' => 'name']])
+        ->and(inspectorRow($inspector, 'X', 'Size')['kind'])->toBe('integer')
+        ->and(inspectorRow($inspector, 'Events')['kind'])->toBe('info')
+        ->and(inspectorRow($inspector, 'Kind'))->toMatchArray(['kind' => 'reference', 'reference' => 'tilesets'])
+        ->and(inspectorRow($inspector, 'Background Music'))->toMatchArray(['kind' => 'reference', 'reference' => 'bgm'])
+        ->and($inspector['revision'])->toBe($session->readMap('test-map')['revision']);
+});
+
+it('chooses a map\'s region from the names the project\'s maps use, never by typing, and leaves the map where it is', function () {
+    $root = makeTemporaryProject();
+    $maps = $root . '/assets/Maps';
+    $data = $maps . '/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'region' => ''", "'region' => 'Happyville'", (string) file_get_contents($data)));
+    mkdir($maps . '/somewheretown/inn', 0o777, true);
+    foreach (['data', 'event', 'map'] as $kind) {
+        $source = (string) file_get_contents("{$maps}/test-map/test-map.{$kind}.php");
+        file_put_contents("{$maps}/somewheretown/inn/inn.{$kind}.php",
+            $kind === 'data' ? str_replace("'region' => 'Happyville'", "'region' => 'Somewheretown'", $source) : $source);
+    }
+    $session = EditorSession::open($root);
+    $region = inspectorRow($session->readInspector('test-map'), 'Region');
+
+    expect($region)->toMatchArray(['kind' => 'reference', 'reference' => 'map_regions', 'value' => 'Happyville'])
+        ->and(array_column($session->listReferences('test-map', 'map_regions'), 'value'))->toBe(['Happyville', 'Somewheretown']);
+
+    $session->applyInspector('test-map', $session->readInspector('test-map')['revision'], $region['key'], 'Somewheretown');
+    expect(inspectorRow($session->readInspector('test-map'), 'Region')['value'])->toBe('Somewheretown')
+        ->and(array_column($session->describeMaps(), 'id'))->toContain('test-map', 'somewheretown/inn')
+        ->and($session->undo()['maps'])->toBe(['test-map'])
+        ->and(inspectorRow($session->readInspector('test-map'), 'Region')['value'])->toBe('Happyville');
+});
+
+it('applies an inspector edit as one undo step and refuses a stale or read-only row', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $inspector = $session->readInspector('test-map');
+    $name = inspectorRow($inspector, 'Name');
+
+    $applied = $session->applyInspector('test-map', $inspector['revision'], $name['key'], 'Renamed Map');
+
+    expect($applied['changed'])->toBeTrue()
+        ->and($session->readMap('test-map')['name'])->toBe('Renamed Map')
+        ->and(fn() => $session->applyInspector('test-map', $inspector['revision'], $name['key'], 'Again'))
+            ->toThrow(SessionRefusal::class, 'changed since revision');
+
+    expect($session->undo()['maps'])->toBe(['test-map'])
+        ->and($session->readMap('test-map')['name'])->toBe($name['value']);
+
+    $fresh = $session->readInspector('test-map');
+    expect(fn() => $session->applyInspector('test-map', $fresh['revision'], ['target' => 'map', 'field' => 'nope'], 'x'))
+        ->toThrow(SessionRefusal::class, 'no longer in the inspector');
+});
+
+it('moves an event through its position row and lists the choices of a reference row', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $event = $session->readInspector('test-map', 'E');
+    $x = inspectorRow($event, 'X', 'Position');
+    $before = (int) $x['value'];
+
+    expect(inspectorRow($event, 'Type')['kind'])->toBe('options')
+        ->and($x['key'])->toBe(['target' => 'event-bounds', 'field' => 'x', 'marker' => 'E']);
+
+    $session->applyInspector('test-map', $event['revision'], $x['key'], (string) ($before + 1));
+    $moved = array_find($session->readMap('test-map')['events'], static fn(array $candidate): bool => $candidate['marker'] === 'E');
+
+    expect($moved['cells'][0][0])->toBe($before + 1)
+        ->and(fn() => $session->readInspector('test-map', 'Q'))->toThrow(SessionRefusal::class, 'test-map has no event Q.')
+        ->and(array_column($session->listReferences('test-map', 'bgm'), 'value'))->toBeArray()
+        ->and(fn() => $session->listReferences('test-map', 'no-such-category'))->toThrow(SessionRefusal::class);
+});
+
+it('lists a schema database\'s records and reads one record\'s rows', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $items = $session->listDatabaseRecords('items');
+    $record = $session->readDatabaseRecord('items', 0);
+
+    expect($items['records'])->not->toBe([])
+        ->and($items['editable'])->toBeTrue()
+        ->and(array_column($record['rows'], 'label'))->toContain('Name')
+        ->and(array_find($record['rows'], static fn(array $row): bool => $row['label'] === 'Name')['value'])->toBe($items['records'][0])
+        ->and(fn() => $session->readDatabaseRecord('items', 999))->toThrow(SessionRefusal::class, 'items has no record 999.')
+        ->and(fn() => $session->listDatabaseRecords('nope'))->toThrow(SessionRefusal::class, 'There is no database category nope.');
+});
+
+it('reads a map\'s world as the game uploads it, its glyph layers named by the editor\'s layer ids', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $world = $session->readWorld('test-map');
+    $put = $world['operations'][0];
+
+    expect($put['op'])->toBe('put')
+        ->and($put['value']['tileset']['sheets'])->toBe(['Graphics/Tilesets/Home_A2.png', 'Graphics/Tilesets/Home_B.png'])
+        ->and(array_column($put['value']['layers'], 'id'))->toContain('tiles:floor', 'tiles:decor')
+        ->and(array_values($world['layerIds']))->toBe(array_values(array_filter(array_column($put['value']['layers'], 'id'),
+            static fn(string $id): bool => str_starts_with($id, 'map:'))))
+        ->and(array_keys($world['layerIds']))->toBe(array_values(array_filter(array_column($session->readMap('test-map')['layers'], 'id'),
+            static fn(string $id): bool => $id !== 'event')))
+        ->and($world['assetRoot'])->toBe($root . '/assets')
+        ->and($world['graphicsIssue'])->toBeNull();
+});
+
+it('still draws a map\'s glyphs when the game would refuse its graphics, saying why', function () {
+    $root = mapGraphicsProject();
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'tileset' => 'home'", "'tileset' => 'missing'", (string) file_get_contents($data)));
+    $world = EditorSession::open($root)->readWorld('test-map');
+
+    expect($world['graphicsIssue'])->toContain('missing')
+        ->and($world['operations'][0]['value'])->not->toHaveKey('tileset')
+        ->and(array_column($world['operations'], 'op'))->toContain('worldRows');
+});
+
+it('lays out the tile palette as RPG Maker MZ does, drawn by the same world', function () {
+    $session = EditorSession::open(mapGraphicsProject());
+    $palette = $session->readTilePalette('test-map');
+    [$a, $b] = $palette['tabs'];
+
+    // A2's 32 autotile kinds, one entry each; B tile by tile from the empty tile.
+    expect(array_column($palette['tabs'], 'name'))->toBe(['A', 'B'])
+        ->and($a['ids'][0])->toBe([2816, 2864, 2912, 2960, 3008, 3056, 3104, 3152])
+        ->and(count($a['ids']))->toBe(4)
+        ->and($b['ids'][0])->toBe([0, 1, 2, 3, 4, 5, 6, 7])
+        ->and(count($b['ids']))->toBe(32)
+        ->and($b['operations'][0]['id'])->toBe('palette:B')
+        ->and($b['operations'][0]['value']['columns'])->toBe(8)
+        ->and(fn() => EditorSession::open(makeTemporaryProject())->readTilePalette('test-map'))
+        ->toThrow(SessionRefusal::class, 'names no tileset');
+});
+
+it('places, erases and undoes tiles as one step each, drawn from the unsaved layer and never touching glyphs', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $floor = static fn(array $tiles): array => array_find($tiles['layers'], static fn(array $layer): bool => $layer['name'] === 'floor')['rows'];
+
+    $placed = $session->paintTiles('test-map', $map['revision'], 'floor', [[0, 0], [1, 0]], 5);
+    expect($placed['changed'])->toBe(2)
+        ->and($floor($session->readTiles('test-map'))[0])->toBe([5, 5, 2816, 2816])
+        ->and($session->readMap('test-map')['layers'])->toBe(array_map(static fn(array $layer): array => $layer, $map['layers']))
+        ->and(array_filter($session->readWorld('test-map')['operations'], static fn(array $operation): bool
+            => $operation['op'] === 'worldTiles' && $operation['layerId'] === 'tiles:floor' && $operation['rows'][0]['row'] === 0))->not->toBe([]);
+
+    // Placing what is already there changes nothing and records no step.
+    $unchanged = $session->paintTiles('test-map', $placed['revision'], 'floor', [[0, 0]], 5);
+    expect($unchanged['changed'])->toBe(0);
+    $erased = $session->paintTiles('test-map', $unchanged['revision'], 'floor', [[0, 0]], 0, 'Erase tiles');
+    expect($floor($session->readTiles('test-map'))[0])->toBe([0, 5, 2816, 2816])
+        ->and($session->undo()['label'])->toBe('Erase tiles')
+        ->and($session->undo()['label'])->toBe('Place tiles')
+        ->and($floor($session->readTiles('test-map'))[0])->toBe([2816, 2816, 2816, 2816])
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse();
+
+    // A new tile layer is created for its first tile; a stale revision, a cell off the map and a non-tile are refused.
+    $current = $session->readMap('test-map')['revision'];
+    $session->paintTiles('test-map', $current, 'rugs', [[3, 1]], 2864);
+    expect(array_column($session->readTiles('test-map')['layers'], 'name'))->toContain('rugs')
+        ->and(fn() => $session->paintTiles('test-map', $erased['revision'], 'floor', [[0, 0]], 5))->toThrow(SessionRefusal::class, 'changed since revision')
+        ->and(fn() => $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[9, 9]], 5))->toThrow(SessionRefusal::class, 'no cell at (9, 9)')
+        ->and(fn() => $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[0, 0]], 9000))->toThrow(SessionRefusal::class, "'9000' is not an RPG Maker tile identity");
+});
+
+it('redraws wall shadows from the tiles after every stroke, with no shadow data to place and none in the palette', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, shadows: ['casters' => [5], 'width' => 0.5, 'opacity' => 0.4]);
+    $session = EditorSession::open($root);
+    $shadows = static function () use ($session): array {
+        $cells = [];
+        foreach ($session->readWorld('test-map', tileShadows: true)['operations'] as $operation) {
+            if ($operation['op'] === 'worldTiles' && str_ends_with($operation['layerId'], ':shadows')) {
+                $cells[$operation['rows'][0]['row']] = array_column($operation['rows'][0]['cells'], 'column');
+            }
+        }
+        ksort($cells);
+        return $cells;
+    };
+
+    // Decor casters at (1, 0) and (3, 1): the first shades the floor to its right; the second stands at the map edge.
+    expect($shadows())->toBe([0 => [2]])
+        // An interface whose renderer does not paint shadows never receives them.
+        ->and(json_encode($session->readWorld('test-map')))->not->toContain('shadows');
+    $extended = $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'decor', [[2, 0]], 5);
+    expect($shadows())->toBe([0 => [3]]);
+    $session->paintTiles('test-map', $extended['revision'], 'decor', [[1, 0], [2, 0]], 0, 'Erase tiles');
+    expect($shadows())->toBe([])
+        ->and($session->undo()['label'])->toBe('Erase tiles')
+        ->and($shadows())->toBe([0 => [3]])
+        ->and(json_encode($session->readTilePalette('test-map')))->not->toContain('shadows');
+});
+
+it('asks for wall shadows over the line protocol only by an explicit boolean', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, shadows: ['casters' => [5], 'width' => 0.5, 'opacity' => 0.4]);
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+
+    expect(json_encode($request(2, 'map.world', ['map' => 'test-map'])['result']))->not->toContain('shadows')
+        ->and(json_encode($request(3, 'map.world', ['map' => 'test-map', 'tileShadows' => true])['result']))->toContain('tiles:decor:shadows')
+        ->and($request(4, 'map.world', ['map' => 'test-map', 'tileShadows' => 'yes'])['error'])
+        ->toBe(['kind' => 'request', 'message' => '"tileShadows" must be a boolean.']);
+});
+
+it('saves placed tiles into the tile layer file the Engine reads', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'decor', [[0, 1]], 7);
+    $session->saveMap('test-map');
+
+    expect(readTileRows($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php'))->toBe([[0, 5, 0, 0], [7, 0, 0, 5]]);
+});
+
+it('creates a map named and sized by the author, keeping every open map\'s unsaved changes and undo', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $session->paintTiles('test-map', $map['revision'], 'floor', [[0, 0]], 5);
+
+    $created = $session->createMap('Old Mill', 'home', 6, 4);
+    $mill = $session->readMap('old-mill');
+
+    expect($created['map'])->toBe('old-mill')
+        ->and(array_column($created['maps'], 'id'))->toContain('old-mill', 'test-map')
+        ->and([$mill['name'], $mill['width'], $mill['height']])->toBe(['Old Mill', 6, 4])
+        ->and($session->listMapKinds())->toBe([['value' => 'home', 'label' => 'Home']])
+        ->and($session->readMap('test-map')['dirty'])->toBeTrue()
+        ->and($session->undo()['label'])->toBe('Place tiles')
+        ->and($session->createMap('Old Mill', null, 2, 2)['map'])->toBe('old-mill-2')
+        ->and(fn() => $session->createMap('!!!', null, 2, 2))->toThrow(SessionRefusal::class, 'no letters or digits')
+        ->and(fn() => $session->createMap(null, 'castle', 2, 2))->toThrow(SessionRefusal::class, 'no map kind castle')
+        ->and(fn() => $session->createMap(null, null, 0, 2))->toThrow(SessionRefusal::class, 'at least 1 x 1');
+});
+
+it('asks before deleting a map something still transfers to, then deletes it and keeps other unsaved work', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $session->createMap('Cellar', null, 3, 3);
+    $session->createMap('Attic', null, 3, 3);
+    $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5);
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'events' => [],", "'events' => ['D' => ['class' => 'Door', 'data' => ['destinationMap' => 'cellar']]],",
+        (string) file_get_contents($data)));
+    $session = EditorSession::open($root);
+    $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5);
+
+    $asked = $session->deleteMap('cellar');
+    expect($asked)->toBe(['status' => 'question', 'map' => 'cellar', 'references' => ['event D on test-map']])
+        ->and(is_dir($root . '/assets/Maps/cellar'))->toBeTrue()
+        ->and($session->deleteMap('attic')['status'])->toBe('deleted')
+        ->and(is_dir($root . '/assets/Maps/attic'))->toBeFalse();
+
+    $deleted = $session->deleteMap('cellar', confirmed: true);
+    expect($deleted['status'])->toBe('deleted')
+        ->and(array_column($deleted['maps'], 'id'))->not->toContain('cellar')
+        ->and(is_dir($root . '/assets/Maps/cellar'))->toBeFalse()
+        ->and($session->readMap('test-map')['dirty'])->toBeTrue()
+        ->and(fn() => $session->deleteMap('cellar'))->toThrow(SessionRefusal::class);
+});
+
+it('plays a saved map from a cell in the background and refuses an unsaved one', function () {
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/Data/system.php', " ['player' => []]];\n");
+    $console = $root . '/fake-console.php';
+    file_put_contents($console, 'readMap('test-map');
+        $session->paint('test-map', $map['revision'], $map['baseLayer'], [[1, 1]], '%');
+        expect(fn() => $session->startPlaytest('test-map', 1, 1))->toThrow(SessionRefusal::class, 'Save test-map before playtesting')
+            ->and(fn() => $session->startPlaytest('test-map', 999, 1))->toThrow(SessionRefusal::class);
+        $session->saveMap('test-map');
+
+        $started = $session->startPlaytest('test-map', 1, 1);
+        expect($started)->toMatchArray(['map' => 'test-map', 'x' => 1, 'y' => 1]);
+        $deadline = microtime(true) + 5;
+        while ($session->describePlaytest()['running'] && microtime(true) < $deadline) {
+            usleep(20000);
+        }
+        expect($session->describePlaytest())->toMatchArray(['running' => false, 'exitCode' => 2])
+            ->and($session->describePlaytest()['log'])->toContain('no renderer installed')
+            ->and($session->stopPlaytest()['running'])->toBeFalse();
+    } finally {
+        putenv('ICHILOTO_CONSOLE_BIN');
+    }
+});
+
+it('plays from the title with the project\'s own start, and only with renderers it can launch', function () {
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/Data/system.php', " ['player' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1]]]];\n");
+    // The fake console lists GPUI as usable and the terminal as not; the fake game reports the launch context it
+    // was given and how it was launched, then ends.
+    $console = $root . '/fake-console.php';
+    file_put_contents($console, ' "ichiloto.renderers/1", "preferredId" => "gpui",'
+        . ' "renderers" => [["id" => "gpui", "displayName" => "GPUI", "available" => true, "reason" => null],'
+        . ' ["id" => "terminal", "displayName" => "Terminal", "available" => false, "reason" => "no terminal application found"]]]); exit(0); }'
+        . ' fwrite(STDERR, implode(" ", array_slice($argv, 1)) . "\n"); exit(3);');
+    putenv('ICHILOTO_CONSOLE_BIN=' . $console);
+
+    try {
+        $session = EditorSession::open($root);
+        expect($session->describePlaytestOptions())->toBe(['starts' => ['cell', 'title'], 'renderers' => [
+                ['id' => 'auto', 'label' => 'Automatic (GPUI)', 'available' => true, 'reason' => null],
+                ['id' => 'gpui', 'label' => 'GPUI', 'available' => true, 'reason' => null],
+                ['id' => 'terminal', 'label' => 'Terminal', 'available' => false, 'reason' => 'no terminal application found'],
+            ], 'defaultRenderer' => 'auto', 'issue' => null])
+            ->and(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE, 'terminal'))
+            ->toThrow(SessionRefusal::class, 'Terminal cannot be used for a playtest: no terminal application found.')
+            ->and(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE, 'vulkan'))
+            ->toThrow(SessionRefusal::class, 'cannot use the vulkan renderer');
+        $map = $session->readMap('test-map');
+        $session->paint('test-map', $map['revision'], $map['baseLayer'], [[1, 1]], '%');
+        expect(fn() => $session->startPlaytest(null, null, null, PlaytestStart::TITLE))
+            ->toThrow(SessionRefusal::class, 'Save test-map before playing from the title');
+        $session->saveMap('test-map');
+
+        expect($session->startPlaytest(null, null, null, PlaytestStart::TITLE))->toMatchArray(['start' => 'title', 'map' => null]);
+        $deadline = microtime(true) + 5;
+        while ($session->describePlaytest()['running'] && microtime(true) < $deadline) {
+            usleep(20000);
+        }
+        // The game plays the project's own root, at its title, with its data kept apart.
+        expect($session->describePlaytest()['log'])->toContain('{"mode":"title","dataRoot":"')
+            ->toContain('--renderer=auto')->toContain('--terminal-window')->toContain('-d ' . realpath($root))
+            ->not->toContain('--start');
+
+        // From a cell, a new game starts on that cell, skipping the title.
+        $session->startPlaytest('test-map', 1, 1);
+        $deadline = microtime(true) + 5;
+        while ($session->describePlaytest()['running'] && microtime(true) < $deadline) {
+            usleep(20000);
+        }
+        expect($session->describePlaytest()['log'])->toContain('{"mode":"new-game","start":{"mapId":"test-map","x":1,"y":1},"dataRoot":"');
+    } finally {
+        putenv('ICHILOTO_CONSOLE_BIN');
+    }
+});
+
+it('stamps a block of tiles as one undo step, each cell its own tile', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $floor = static fn(): array => array_find($session->readTiles('test-map')['layers'], static fn(array $layer): bool => $layer['name'] === 'floor')['rows'];
+
+    $stamped = $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 0, 5], [2, 0, 6], [1, 1, 0], [2, 1, 7]]);
+    expect($stamped['changed'])->toBe(4)
+        ->and($floor())->toBe([[2816, 5, 6, 2816], [2816, 0, 7, 2816]])
+        ->and($session->undo()['label'])->toBe('Place tiles')
+        ->and($floor())->toBe([[2816, 2816, 2816, 2816], [2816, 2816, 2816, 2816]])
+        ->and(fn() => $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[0, 0, 5], [7, 0, 5]]))
+        ->toThrow(SessionRefusal::class, 'no cell at (7, 0)')
+        ->and($floor()[0][0])->toBe(2816);
+});
+
+it('gives an interface each row\'s depth, its section headings and its coordinate pairs', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $rows = $session->readInspector('test-map')['rows'];
+    $size = array_search('Size', array_column($rows, 'label'), true);
+
+    expect($rows[$size])->toMatchArray(['heading' => true, 'depth' => 0])
+        ->and([$rows[$size + 1]['label'], $rows[$size + 1]['axis'], $rows[$size + 1]['depth']])->toBe(['X', 'x', 1])
+        ->and([$rows[$size + 2]['label'], $rows[$size + 2]['axis']])->toBe(['Y', 'y'])
+        ->and(array_filter($rows, static fn(array $row): bool => str_starts_with($row['label'], ' ')))->toBe([]);
+});
diff --git a/tests/Unit/Session/EffectBattlePreviewSessionTest.php b/tests/Unit/Session/EffectBattlePreviewSessionTest.php
new file mode 100644
index 00000000..f99f564b
--- /dev/null
+++ b/tests/Unit/Session/EffectBattlePreviewSessionTest.php
@@ -0,0 +1,61 @@
+ emberSparkTimeline(), 'dusk-slash' => duskSlashTimeline()] as $id => $source) {
+        @mkdir($root . '/assets/Animations/' . $id, 0o777, true);
+        file_put_contents($root . '/assets/Animations/' . $id . '/' . $id . '.timeline.php', $source);
+    }
+    @mkdir($root . '/assets/Graphics/Effects', 0o777, true);
+    writeTilesetTestPng($root . '/assets/Graphics/Effects/dusk-slash.png', 32, 16);
+    // Dusk Slash is what an unarmed attack plays on its target; Ember Spark nothing plays.
+    file_put_contents($root . '/assets/Data/animations.php', " 1, 'name' => 'Dusk Slash', 'roles' => ['attack', 'attack-unarmed'], 'targetEffect' => 'dusk-slash'],
+    ], true) . ";\n");
+
+    return $root;
+}
+
+/** @return int The effect's place among the effect records. */
+function findEffectIndex(EditorSession $session, string $id): int
+{
+    foreach ($session->listDatabaseRecords('cutscenes/effect')['records'] as $index => $label) {
+        if (str_contains(strval($label), $id)) {
+            return $index;
+        }
+    }
+    throw new RuntimeException("No effect {$id}.");
+}
+
+it('previews an effect in the action that plays it, as the battle draws it, for both presentations', function () {
+    $session = EditorSession::open(effectBattlePreviewProject());
+    $index = findEffectIndex($session, 'dusk-slash');
+    $graphical = $session->readEffectBattlePreview($index, 0);
+    $atLane = $session->readEffectBattlePreview($index, 0, authoredFrame: 1);
+    $terminal = $session->readEffectBattlePreview($index, 0, presentation: EffectPresentation::TERMINAL, authoredFrame: 0);
+
+    expect($graphical['contexts'])->toBe([['caster' => 'Kaelion', 'action' => $graphical['contexts'][0]['action'], 'lane' => 'target']])
+        ->and($graphical)->toMatchArray(['lane' => 'target', 'binding' => 0, 'caster' => 'Kaelion', 'troop' => 'Pair'])
+        ->and($atLane['phase'])->toBe('target')
+        ->and($atLane['authoredFrames']['target'])->toBe(1)
+        ->and(array_find($atLane['canvas']['images'] ?? [], static fn(array $image): bool => $image['asset'] === 'Graphics/Effects/dusk-slash.png'))->not->toBeNull()
+        ->and($terminal['canvas'])->toBeNull()
+        ->and($terminal['terminalLines'])->not->toBe([]);
+});
+
+it('says so when no action of the battle test party plays an effect, inventing none', function () {
+    $session = EditorSession::open(effectBattlePreviewProject());
+
+    expect(fn() => $session->readEffectBattlePreview(findEffectIndex($session, 'ember-spark'), 0))
+        ->toThrow(SessionRefusal::class, 'No action of the battle test party plays ember-spark in battle');
+});
diff --git a/tests/Unit/Session/EffectScaleAuthoringTest.php b/tests/Unit/Session/EffectScaleAuthoringTest.php
new file mode 100644
index 00000000..a0a4bac8
--- /dev/null
+++ b/tests/Unit/Session/EffectScaleAuthoringTest.php
@@ -0,0 +1,55 @@
+ The record row a field id names. */
+function findEffectScaleRow(EditorSession $session, string $category): array
+{
+    return array_find($session->readDatabaseRecord($category, 0)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'animationScale')
+        ?? throw new RuntimeException("No Effect Scale row in {$category}.");
+}
+
+it('authors a skill effect scale as one undoable edit the Engine reads, and clears it back to the timeline size', function () {
+    $root = makeTemporaryProject();
+    writeSkillRecords($root, new MagicSkill('Grade I', '', '', 1, 0, animationId: 1));
+    $path = $root . '/assets/Data/Skills/0001-grade-i.php';
+    $session = EditorSession::open($root);
+    $row = findEffectScaleRow($session, 'skills');
+
+    expect($row['label'])->toBe('Effect Scale')->and($row['kind'])->toBe('float');
+    $session->applyDatabaseRecord('skills', 0, $row['key'], '0.75');
+    $session->saveDatabase('skills');
+    expect(SkillCatalog::load($root . '/assets')->findSkill('Grade I')?->animationScale)->toBe(.75)
+        ->and(file_get_contents($path))->toContain("'animationScale' => 0.75");
+
+    $session->applyDatabaseRecord('skills', 0, findEffectScaleRow($session, 'skills')['key'], '');
+    $session->saveDatabase('skills');
+    expect(SkillCatalog::load($root . '/assets')->findSkill('Grade I')?->animationScale)->toBe(1.0)
+        ->and(file_get_contents($path))->not->toContain('animationScale');
+});
+
+it('reports a scale the Engine cannot play as the Engine reads it', function () {
+    $root = makeTemporaryProject();
+    writeSkillRecords($root, new MagicSkill('Grade I', '', '', 1, 0, animationId: 1));
+    $workspace = ProjectWorkspace::fromProject($root);
+    $workspace->getRecordDatabase('skills')?->setField(0, 'animationScale', '9');
+
+    expect(implode("\n", $workspace->loadSkillCatalog()->getProblems()))->toContain('animationScale must be a number from 0.25 to 4');
+});
+
+it('authors an item effect scale through the same binding field', function () {
+    $root = makeTemporaryProject();
+    $database = loadRecordDatabase($root, 'items');
+    $database->setField(0, 'animationId', '1');
+    $database->setField(0, 'animationScale', '1.5');
+    $database->save();
+    expect(loadRecordDatabase($root, 'items')->getRecords()[0]->get('animationScale'))->toBe(1.5);
+});
diff --git a/tests/Unit/Session/EnemyAttackSkillSessionTest.php b/tests/Unit/Session/EnemyAttackSkillSessionTest.php
new file mode 100644
index 00000000..5dea7242
--- /dev/null
+++ b/tests/Unit/Session/EnemyAttackSkillSessionTest.php
@@ -0,0 +1,73 @@
+readDatabaseRecord('enemies', 0)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'attackSkill');
+}
+
+it('offers an enemy the battle-usable basic skills an actor may attack with, and the built-in attack', function () {
+    $session = EditorSession::open(enemyAttackSkillProject());
+    [$map] = $session->describeMaps();
+
+    expect(array_column($session->listReferences($map['id'], 'attack_skills'), 'value'))->toBe(['Tail Lash'])
+        ->and(enemyAttackSkillRow($session))->toMatchArray([
+            'kind' => 'reference',
+            'reference' => 'attack_skills',
+            'value' => ActorAuthoring::BUILT_IN_ATTACK,
+        ]);
+});
+
+it('gives an enemy an attack skill the Engine reads, and takes it back without leaving a key behind', function () {
+    $root = enemyAttackSkillProject();
+    $session = EditorSession::open($root);
+    $file = $root . '/assets/Data/Enemies/regular-bat.php';
+    $before = (string) file_get_contents($file);
+
+    $session->applyDatabaseRecord('enemies', 0, enemyAttackSkillRow($session)['key'], 'Tail Lash');
+    $session->saveDatabase('enemies');
+    // The Engine reads enemy sprites from the project's working directory.
+    $previous = getcwd();
+    chdir($root);
+    try {
+        $catalog = EnemyCatalog::load($root . '/assets', SkillCatalog::load($root . '/assets'));
+    } finally {
+        chdir((string) $previous);
+    }
+    $enemy = $catalog->findEnemy('Regular Bat');
+
+    expect((require $file)['data']['attackSkill'])->toBe('Tail Lash')
+        ->and($enemy?->attackSkill?->name)->toBe('Tail Lash');
+
+    $session->applyDatabaseRecord('enemies', 0, enemyAttackSkillRow($session)['key'], '');
+    $session->saveDatabase('enemies');
+
+    expect((require $file)['data'])->not->toHaveKey('attackSkill')
+        ->and(file_get_contents($file))->toBe($before);
+});
diff --git a/tests/Unit/Session/EnemyPreviewSessionTest.php b/tests/Unit/Session/EnemyPreviewSessionTest.php
new file mode 100644
index 00000000..3d44d72b
--- /dev/null
+++ b/tests/Unit/Session/EnemyPreviewSessionTest.php
@@ -0,0 +1,71 @@
+readDatabaseRecord('enemies', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'imagePath');
+    $session->applyDatabaseRecord('enemies', 0, $row['key'], $sprite);
+}
+
+it('previews an enemy as its terminal sprite and at battle scale where the battle stands it alone', function () {
+    $session = EditorSession::open(enemyPreviewProject());
+    $preview = $session->readEnemyPreview(0);
+    $bat = $preview['formation']['members'][0];
+
+    expect($preview['name'])->toBe('Regular Bat')
+        ->and($preview['sprite'])->toBe(['lines' => ['/\\o/\\', " ' '"]])
+        ->and($preview['formationIssue'])->toBeNull()
+        ->and($preview['formation']['arena'])->toBe('arena.cave')
+        ->and($preview['formation']['party'][0])->toMatchArray(['name' => 'Kaelion', 'ground' => ['x' => 1180.0, 'y' => 560.0]])
+        // A troop of it alone: centred on the enemies' half, facing the middle of the party's formation (depths 180 and 40).
+        ->and($bat)->toMatchArray(['enemy' => 'Regular Bat', 'placement' => null, 'place' => ['x' => 337.5, 'depth' => 110.0]])
+        ->and($bat['battler']['ground'])->toBe(['x' => 337.5, 'y' => 490.0])
+        ->and($bat['battler']['diagnostics'])->toBe(['Base battler artwork unavailable: Regular Bat'])
+        ->and($session->readEnemyPreview(0, 'arena.road')['formation']['arena'])->toBe('arena.road')
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('shows the sprite the record holds now, and never a file outside the enemy sprites', function () {
+    $session = EditorSession::open(enemyPreviewProject());
+
+    chooseEnemySprite($session, 'wisp');
+    expect($session->readEnemyPreview(0)['sprite'])->toBe(['lines' => ['~*~']]);
+
+    chooseEnemySprite($session, '../../secret');
+    expect($session->readEnemyPreview(0)['sprite'])->toBe(['issue' => 'Graphics/Enemies/../../secret.txt is not a sprite in this project.']);
+});
+
+it('still shows the sprite when the project has no graphical battle, saying why there is no art', function () {
+    $root = enemyPreviewProject();
+    unlink($root . '/assets/Data/Presentation/battle.php');
+    $preview = EditorSession::open($root)->readEnemyPreview(0);
+
+    expect($preview['sprite'])->toBe(['lines' => ['/\\o/\\', " ' '"]])
+        ->and($preview['formation'])->toBeNull()
+        ->and($preview['formationIssue'])->toBe('This project has no graphical battle layout to preview enemies at battle scale on.');
+});
+
+it('stands a tall enemy whole on the ground, at battle scale, where the battle stands it alone', function () {
+    $root = enemyPreviewProject();
+    writeTilesetTestPng($root . '/assets/Graphics/Actors/Kaelion.png', 10, 10);
+    writeTilesetTestPng($root . '/assets/Graphics/Enemies/Bat.png', 4, 10);
+    $battle = (string) file_get_contents($root . '/assets/Data/Presentation/battle.php');
+    $battle = str_replace(['use Ichiloto\Engine\Battle\Presentation\BattleStageSlot;', "    actors: [],\n    enemies: [],"], [
+        "use Ichiloto\\Engine\\Battle\\Presentation\\BattleStageSlot;\nuse Ichiloto\\Engine\\Battle\\Presentation\\{BattlerArtwork, BattleScale, BattlerScale};",
+        // At three times the party's height the bat stands 450 tall, 180 wide: whole from its feet facing the party's middle at 490.
+        "    actors: ['Kaelion' => new BattlerArtwork('Graphics/Actors/Kaelion.png', 10, 10, 5, 10)],\n"
+        . "    enemies: ['Regular Bat' => new BattlerArtwork('Graphics/Enemies/Bat.png', 4, 10, 2, 10)],\n"
+        . "    scale: new BattleScale('Kaelion', 150, actors: ['Kaelion' => new BattlerScale(1, 1)], enemies: ['Regular Bat' => new BattlerScale(3, 1)]),",
+    ], $battle);
+    file_put_contents($root . '/assets/Data/Presentation/battle.php', $battle);
+
+    $preview = EditorSession::open($root)->readEnemyPreview(0);
+
+    expect($preview['formationIssue'])->toBeNull()
+        ->and($preview['formation']['members'][0]['battler']['ground'])->toBe(['x' => 337.5, 'y' => 490.0])
+        ->and($preview['formation']['members'][0]['battler']['bounds'])->toBe(['x' => 247.5, 'y' => 40.0, 'width' => 180.0, 'height' => 450.0]);
+});
diff --git a/tests/Unit/Session/EventAuthoringSessionTest.php b/tests/Unit/Session/EventAuthoringSessionTest.php
new file mode 100644
index 00000000..39044384
--- /dev/null
+++ b/tests/Unit/Session/EventAuthoringSessionTest.php
@@ -0,0 +1,277 @@
+ array_slice($inspector['rows'], $start)], static fn(array $row): bool => trim($row['label']) === $label);
+}
+
+/** The event a map read lists under a marker. */
+function eventSessionEvent(array $map, string $marker): ?array
+{
+    return array_find($map['events'], static fn(array $event): bool => $event['marker'] === $marker);
+}
+
+/** Free event cells in one row of a map read, found rather than assumed. */
+function eventSessionFreeCells(array $map, int $count): array
+{
+    $layer = array_find($map['layers'], static fn(array $layer): bool => $layer['event']);
+    foreach ($layer['rows'] as $y => $row) {
+        for ($x = 0; $x + $count <= count($row); $x++) {
+            if (array_all(array_slice($row, $x, $count), static fn(string $symbol): bool => trim($symbol) === '')) {
+                return array_map(static fn(int $offset): array => [$x + $offset, $y], range(0, $count - 1));
+            }
+        }
+    }
+
+    throw new RuntimeException('No free event cells.');
+}
+
+it('lists every placed event, saying which has no definition yet', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $map = $session->readMap('test-map');
+    [$cell] = eventSessionFreeCells($map, 1);
+    $session->paint('test-map', $map['revision'], 'event', [$cell], '@');
+
+    $read = $session->readMap('test-map');
+
+    expect(eventSessionEvent($read, '@'))->toBe(['marker' => '@', 'type' => 'Unset', 'defined' => false, 'cells' => [$cell]])
+        ->and(array_filter($read['events'], static fn(array $event): bool => $event['marker'] !== '@'))
+            ->each->toHaveKey('defined', true);
+});
+
+it('creates, moves, reshapes and deletes an event over the protocol, one undo step each, refusing stale and overlapping edits', function () {
+    $root = makeTemporaryProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params]));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $map = $request('map.read', ['map' => 'test-map'])['result'];
+    $cells = eventSessionFreeCells($map, 2);
+
+    expect(array_column($request('event.types')['result'], 'label'))
+        ->toBe(array_map(static fn($type): string => $type->label, EventTypeCatalog::all()));
+
+    $created = $request('event.create', ['map' => 'test-map', 'revision' => $map['revision'], 'cells' => $cells, 'type' => 'Dialogue'])['result'];
+    $marker = $created['marker'];
+    $read = $request('map.read', ['map' => 'test-map'])['result'];
+
+    expect(eventSessionEvent($read, $marker))->toBe(['marker' => $marker, 'type' => 'Dialogue', 'defined' => true, 'cells' => $cells])
+        ->and($created['revision'])->toBe($read['revision'])
+        ->and($request('event.create', ['map' => 'test-map', 'revision' => $map['revision'], 'cells' => $cells, 'type' => 'Dialogue'])['error']['message'])
+            ->toContain('changed since revision')
+        ->and($request('event.create', ['map' => 'test-map', 'revision' => $read['revision'], 'cells' => $cells, 'type' => 'Dialogue'])['error']['message'])
+            ->toContain("Marker {$marker} already holds")
+        ->and($request('event.create', ['map' => 'test-map', 'revision' => $read['revision'], 'cells' => [[0, 0]], 'type' => 'Nope'])['error'])
+            ->toBe(['kind' => 'refusal', 'message' => 'There is no event type Nope.']);
+
+    // Another event's cells are never covered: a move onto them is refused.
+    $other = array_find($read['events'], static fn(array $event): bool => $event['marker'] !== $marker);
+    [$ox, $oy] = $other['cells'][0];
+    [$mx, $my] = $cells[0];
+    expect($request('event.move', ['map' => 'test-map', 'revision' => $read['revision'], 'marker' => $marker, 'dx' => $ox - $mx, 'dy' => $oy - $my])['error']['message'])
+        ->toContain("would cover marker {$other['marker']}");
+
+    $height = $read['height'];
+    $moved = $request('event.move', ['map' => 'test-map', 'revision' => $read['revision'], 'marker' => $marker, 'dx' => 0, 'dy' => $my + 1 < $height ? 1 : -1])['result'];
+    expect($moved['changed'])->toBeTrue()
+        ->and($request('event.bounds', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => $marker,
+            'x' => 0, 'y' => 0, 'width' => $read['width'] + 1, 'height' => 1])['error']['message'])->toContain('would leave the map');
+
+    $deleted = $request('event.delete', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => $marker])['result'];
+    expect(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker))->toBeNull()
+        ->and($deleted['revision'])->toBeInt();
+
+    expect($request('history.undo')['result']['label'])->toBe('Event delete')
+        ->and(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker)['type'])->toBe('Dialogue')
+        ->and($request('history.undo')['result']['label'])->toBe('Event move')
+        ->and($request('history.undo')['result']['label'])->toBe('Event create')
+        ->and(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker))->toBeNull()
+        ->and($request('history.redo')['result']['label'])->toBe('Event create')
+        ->and(eventSessionEvent($request('map.read', ['map' => 'test-map'])['result'], $marker)['cells'])->toBe($cells);
+});
+
+it('refuses event edits on a read-only map', function () {
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', "readMap('test-map');
+
+    expect(fn() => $session->createEvent('test-map', $map['revision'], [[0, 0]], 'Dialogue'))->toThrow(SessionRefusal::class, 'read-only')
+        ->and(fn() => $session->deleteEvent('test-map', $map['revision'], '@'))->toThrow(SessionRefusal::class, 'read-only');
+});
+
+it('retypes an event from its Type row, one of the catalog\'s labels, and picks chest and loot types from theirs', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $map = $session->readMap('test-map');
+    $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Dialogue');
+    $marker = $created['marker'];
+    $type = eventSessionRowLabelled($session->readInspector('test-map', $marker), 'Type');
+
+    expect($type)->toMatchArray(['kind' => 'options', 'value' => 'Dialogue'])
+        ->and($type['options'])->toBe(array_map(static fn($definition): string => $definition->label, EventTypeCatalog::all()))
+        ->and(fn() => $session->applyInspector('test-map', $created['revision'], $type['key'], 'Nope'))
+            ->toThrow(SessionRefusal::class, 'choose one of');
+
+    $applied = $session->applyInspector('test-map', $created['revision'], $type['key'], 'Chest');
+    $chest = $session->readInspector('test-map', $marker);
+    $chestType = eventSessionRowLabelled($chest, 'Chest Type');
+
+    expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true])
+        ->and(eventSessionEvent($session->readMap('test-map'), $marker)['type'])->toBe('Chest')
+        ->and($chestType['kind'])->toBe('options')
+        ->and($chestType['optionLabels'])->toContain('Common', 'Legendary')
+        ->and(eventSessionRowLabelled($chest, 'Loot Type')['options'])->toContain('item', 'gold')
+        ->and(fn() => $session->applyInspector('test-map', $chest['revision'], $chestType['key'], 'shiny'))
+            ->toThrow(SessionRefusal::class, 'choose one of');
+
+    $session->applyInspector('test-map', $chest['revision'], $chestType['key'], 'rare');
+    expect(eventSessionRowLabelled($session->readInspector('test-map', $marker), 'Chest Type')['value'])->toBe('rare')
+        ->and($session->undo()['label'])->toBe('Chest Type edit')
+        ->and($session->undo()['label'])->toBe('Event type change')
+        ->and(eventSessionEvent($session->readMap('test-map'), $marker)['type'])->toBe('Dialogue');
+});
+
+it('sets a transfer\'s destination map and spawn point together, as one undo step', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+    $map = $session->readMap('test-map');
+    $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Transfer Player');
+    $marker = $created['marker'];
+    $inspector = $session->readInspector('test-map', $marker);
+    $destination = eventSessionRowLabelled($inspector, 'Destination Map');
+
+    expect($destination)->toMatchArray(['kind' => 'destination', 'reference' => 'maps'])
+        ->and(fn() => $session->applyInspector('test-map', $created['revision'], $destination['key'], 'test-map'))
+            ->toThrow(SessionRefusal::class, 'set with its spawn point')
+        ->and(fn() => $session->setEventDestination('test-map', $created['revision'], $marker, 'test-map', $map['width'], 0))
+            ->toThrow(SessionRefusal::class, 'is outside test-map')
+        ->and(fn() => $session->setEventDestination('test-map', $created['revision'], $marker, 'nowhere', 0, 0))
+            ->toThrow(SessionRefusal::class, 'There is no map nowhere.');
+
+    $set = $session->setEventDestination('test-map', $created['revision'], $marker, 'test-map', $map['width'] - 1, $map['height'] - 1);
+    $after = $session->readInspector('test-map', $marker);
+
+    expect($set['changed'])->toBeTrue()
+        ->and(eventSessionRowLabelled($after, 'Destination Map')['value'])->toBe('test-map')
+        ->and(eventSessionRowLabelled($after, 'X', 'Spawn Point')['value'])->toBe((string) ($map['width'] - 1))
+        ->and(eventSessionRowLabelled($after, 'Y', 'Spawn Point')['value'])->toBe((string) ($map['height'] - 1))
+        ->and($session->undo()['label'])->toBe('Destination change');
+
+    $undone = $session->readInspector('test-map', $marker);
+    expect(eventSessionRowLabelled($undone, 'Destination Map')['value'])->toBe('')
+        ->and(eventSessionRowLabelled($undone, 'X', 'Spawn Point')['value'])->toBe('0');
+});
+
+it('adds and removes entries of an event\'s list, the map\'s encounters and its music variants', function () {
+    $session = EditorSession::open(metadataProject());
+    $map = $session->readMap('test-map');
+    // A shop's stock is a list of the event's own data. (A Dialogue event's pages are its record rows.)
+    $created = $session->createEvent('test-map', $map['revision'], eventSessionFreeCells($map, 1), 'Shop');
+    $marker = $created['marker'];
+    $isStock = static fn(array $row): bool => ($row['key']['list'] ?? null) === ['data', 'items'];
+    $heading = eventSessionRow($session->readInspector('test-map', $marker), $isStock);
+
+    $first = $session->addInspectorListEntry('test-map', $created['revision'], $heading['key']);
+    $item = eventSessionRow($session->readInspector('test-map', $marker), static fn(array $row): bool => $isStock($row) && $row['kind'] === 'integer');
+    $added = $session->addInspectorListEntry('test-map', $first['revision'], $item['key']);
+    $rows = array_filter($session->readInspector('test-map', $marker)['rows'], $isStock);
+
+    expect($heading['list'])->toBe(['index' => 0])
+        ->and($added)->toMatchArray(['changed' => true, 'message' => 'Added item 2.'])
+        ->and(array_column($rows, 'list'))->toContain(['index' => 1]);
+
+    $second = array_find($rows, static fn(array $row): bool => ($row['list'] ?? null) === ['index' => 1] && $row['kind'] === 'integer');
+    $removed = $session->removeInspectorListEntry('test-map', $added['revision'], $second['key']);
+    expect($removed['message'])->toBe('Removed item 2.')
+        ->and(fn() => $session->addInspectorListEntry('test-map', $removed['revision'], eventSessionRowLabelled($session->readInspector('test-map'), 'Name')['key']))
+            ->toThrow(SessionRefusal::class, 'Nothing here is a list to add to.');
+
+    // The map's encounter table: the first troop enables encounters.
+    $troops = eventSessionRow($session->readInspector('test-map'), static fn(array $row): bool => str_starts_with(trim($row['label']), 'Troops'));
+    $enabled = $session->addInspectorListEntry('test-map', $removed['revision'], $troops['key']);
+    $troop = eventSessionRowLabelled($session->readInspector('test-map'), 'Troop');
+    expect($enabled['message'])->toStartWith('Added ')
+        ->and($troop['list'])->toBe(['index' => 0]);
+    $disabled = $session->removeInspectorListEntry('test-map', $enabled['revision'], $troop['key']);
+    expect($disabled['message'])->toContain('no longer has random encounters');
+
+    // Music variants, and a variant's conditions as the line that holds them.
+    $variants = eventSessionRowLabelled($session->readInspector('test-map'), 'Music Variants');
+    $variant = $session->addInspectorListEntry('test-map', $disabled['revision'], $variants['key']);
+    $when = eventSessionRowLabelled($session->readInspector('test-map'), 'Variant 1 When');
+
+    expect($when)->toMatchArray(['kind' => 'conditions', 'list' => ['index' => 0]])
+        ->and(fn() => $session->applyInspector('test-map', $variant['revision'], $when['key'], 'switch:door_open; nonsense'))
+            ->toThrow(SessionRefusal::class, 'Condition "nonsense" cannot be read');
+
+    $session->applyInspector('test-map', $variant['revision'], $when['key'], 'switch:door_open');
+    expect(eventSessionRowLabelled($session->readInspector('test-map'), 'Variant 1 When')['raw'])->toBe('switch:door_open');
+});
+
+it('asks before a kind change clears the map\'s tiles, and applies the answer as one undo step', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, 'cave');
+    $session = EditorSession::open($root);
+    $kind = eventSessionRowLabelled($session->readInspector('test-map'), 'Kind');
+    $revision = $session->readMap('test-map')['revision'];
+    $tileLayers = static fn(): array => array_values(array_filter(array_column($session->readWorld('test-map')['operations'][0]['value']['layers'], 'id'),
+        static fn(string $id): bool => str_starts_with($id, 'tiles:')));
+    $before = $tileLayers();
+
+    $question = $session->applyInspector('test-map', $revision, $kind['key'], 'cave');
+
+    expect($kind)->toMatchArray(['kind' => 'reference', 'reference' => 'tilesets'])
+        ->and($question['status'])->toBe('question')
+        ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'clear'])
+        ->and($session->readMap('test-map')['revision'])->toBe($revision)
+        ->and($session->applyInspector('test-map', $revision, $kind['key'], 'cave', 'cancel'))
+            ->toBe(['status' => 'applied', 'revision' => $revision, 'changed' => false])
+        ->and(fn() => $session->applyInspector('test-map', $revision, $kind['key'], 'nope'))->toThrow(SessionRefusal::class, 'no tileset nope')
+        ->and($before)->not->toBe([]);
+
+    // The key as an interface may store it: members in another order.
+    $changed = $session->applyInspector('test-map', $revision, array_reverse($kind['key'], true), 'cave', 'clear');
+    expect($changed['changed'])->toBeTrue()
+        ->and(eventSessionRowLabelled($session->readInspector('test-map'), 'Kind')['value'])->toBe('Cave')
+        ->and($tileLayers())->toBe([])
+        ->and($session->undo()['label'])->toBe('Kind change')
+        ->and($tileLayers())->toBe($before);
+});
+
+it('gives a map without a kind one without asking, keeping its tiles whatever the answer', function () {
+    $root = mapGraphicsProject();
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'tileset' => 'home',", '', (string) file_get_contents($data)));
+    $session = EditorSession::open($root);
+    $kind = eventSessionRowLabelled($session->readInspector('test-map'), 'Kind');
+    $layers = static fn(): array => array_values(array_filter(array_column($session->readWorld('test-map')['operations'][0]['value']['layers'], 'id'),
+        static fn(string $id): bool => str_starts_with($id, 'tiles:')));
+
+    $applied = $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $kind['key'], 'home', 'clear');
+
+    expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true])
+        ->and($layers())->not->toBe([]);
+});
diff --git a/tests/Unit/Session/EventRecordAuthoringTest.php b/tests/Unit/Session/EventRecordAuthoringTest.php
new file mode 100644
index 00000000..6edb5a41
--- /dev/null
+++ b/tests/Unit/Session/EventRecordAuthoringTest.php
@@ -0,0 +1,364 @@
+ ($row['key']['field'] ?? null) === $field
+        || implode('.', $row['key']['path'] ?? []) === $field)
+        ?? throw new RuntimeException(sprintf('No row %s among: %s', $field, implode(', ', array_map(
+            static fn(array $row): string => (string) ($row['key']['field'] ?? implode('.', $row['key']['path'] ?? [])), $read['rows']))));
+}
+
+/** A JSON-line host over a project, and a request function. */
+function eventRecordHost(string $root): Closure
+{
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+
+    return $request;
+}
+
+it('offers every trigger\'s conditions, writes, blocked message and cue conditions on a transfer, written only once set', function () {
+    $root = eventRecordProject();
+    $request = eventRecordHost($root);
+    $before = file_get_contents(eventRecordDataPath($root));
+    $read = $request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'];
+
+    expect(eventRecordRow($read, 'conditions'))->toMatchArray(['kind' => 'conditions', 'label' => 'Available When', 'raw' => ''])
+        ->and(eventRecordRow($read, 'sets'))->toMatchArray(['kind' => 'writes', 'label' => 'Then Set'])
+        ->and(eventRecordRow($read, 'whenBlocked'))->toMatchArray(['kind' => 'text', 'label' => 'When Blocked'])
+        ->and(eventRecordRow($read, 'cue.conditions'))->toMatchArray(['kind' => 'conditions', 'label' => 'Cue When'])
+        ->and(eventRecordRow($read, 'conditions')['key'])->toBe(['target' => 'event-record', 'field' => 'conditions', 'marker' => 'T', 'frame' => []])
+        ->and(file_get_contents(eventRecordDataPath($root)))->toBe($before);
+
+    $revision = $read['revision'];
+    foreach ([
+        'conditions' => '!event:door_open; quest:intro:active; map_event:test-map:E; variable:count:>=:3',
+        'sets' => 'switch:lit:false; quest:intro:grant',
+        'whenBlocked' => 'The door is shut.',
+        'cue.conditions' => 'event:door_open',
+    ] as $field => $value) {
+        $applied = $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($read, $field)['key'], 'value' => $value])['result'];
+        expect($applied['changed'])->toBeTrue();
+        $revision = $applied['revision'];
+    }
+    $request('map.save', ['map' => 'test-map']);
+    $source = file_get_contents(eventRecordDataPath($root));
+    $event = (require eventRecordDataPath($root))['events']['T'];
+
+    expect($event['conditions'])->toBe([
+        ['type' => 'event', 'name' => 'door_open', 'negate' => true],
+        ['type' => 'quest', 'name' => 'intro', 'status' => 'active'],
+        ['type' => 'map_event', 'name' => 'test-map', 'marker' => 'E'],
+        ['type' => 'variable', 'name' => 'count', 'op' => '>=', 'value' => 3],
+    ])
+        ->and($event['sets'])->toBe([['type' => 'switch', 'name' => 'lit', 'value' => false], ['type' => 'quest', 'name' => 'intro', 'confirm' => false]])
+        ->and($event['whenBlocked'])->toBe('The door is shut.')
+        ->and($event['cue'])->toBe(['conditions' => [['type' => 'event', 'name' => 'door_open']]])
+        ->and($event['data'])->toBe(['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['@']])
+        ->and($source)->toContain('// Synthetic map for event record authoring.', '// The way out.', '// Timed by the author.', "'seconds' => abs(1)");
+
+    // Cleared, a field leaves the event as it was before it was set.
+    $read = $request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'];
+    $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => eventRecordRow($read, 'conditions')['key'], 'value' => ''])['result']['revision'];
+    $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($read, 'cue.conditions')['key'], 'value' => '']);
+    $request('map.save', ['map' => 'test-map']);
+    expect((require eventRecordDataPath($root))['events']['T'])->not->toHaveKeys(['conditions', 'cue'])
+        ->and($request('history.undo')['result']['label'])->toBe('Cue When edit');
+});
+
+it('refuses condition and write lines the Engine would not read, and lists the line cannot carry', function () {
+    $root = eventRecordProject();
+    $session = EditorSession::open($root);
+    $before = file_get_contents(eventRecordDataPath($root));
+    $read = $session->readInspector('test-map', 'D');
+
+    expect(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'conditions')['key'], 'switch:open; nonsense'))
+        ->toThrow(SessionRefusal::class, 'Condition "nonsense" cannot be read')
+        ->and(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'conditions')['key'], 'variable:count:~:3'))
+        ->toThrow(SessionRefusal::class, 'a variable compares with == != > >= < <=, not "~"')
+        ->and(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'variant0Sets')['key'], 'teleport:home'))
+        ->toThrow(SessionRefusal::class, 'Write "teleport:home" cannot be read');
+
+    // An authored key outside the write line's vocabulary is kept, never dropped by an edit through the line.
+    $sets = eventRecordRow($read, 'sets');
+    expect($sets['kind'])->toBe('info')
+        ->and($sets['value'])->toContain('write 1 carries "note"')
+        ->and(fn() => $session->applyInspector('test-map', $read['revision'], $sets['key'], 'event:met_guide'))->toThrow(SessionRefusal::class, 'cannot be edited here')
+        ->and($session->undo()['label'])->toBeNull();
+    $session->saveMap('test-map');
+    expect(file_get_contents(eventRecordDataPath($root)))->toBe($before);
+});
+
+it('leaves authored empty lists alone, writes no empty defaults on new events, and keeps trigger fields through a retype', function () {
+    $root = eventRecordProject();
+    $session = EditorSession::open($root);
+    $read = $session->readInspector('test-map', 'S');
+    $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'whenBlocked')['key'], 'Not yet.');
+    $session->saveMap('test-map');
+    $event = (require eventRecordDataPath($root))['events']['S'];
+
+    expect($event['conditions'])->toBe([])
+        ->and($event['whenBlocked'])->toBe('Not yet.')
+        ->and(file_get_contents(eventRecordDataPath($root)))->toContain("'seconds' => abs(1)");
+
+    $map = $session->readMap('test-map');
+    foreach (['Story Script', 'Cinematic', 'Dialogue'] as $index => $type) {
+        $marker = $session->createEvent('test-map', $session->readMap('test-map')['revision'], [[1 + $index, 3]], $type)['marker'];
+        expect(getSessionMap($session)->getEventDefinition($marker))->not->toHaveKeys(['conditions', 'sets', 'whenBlocked']);
+    }
+    expect($map['revision'])->toBeInt();
+
+    // Conditions belong to the trigger, not its type: a retype keeps them and starts the data again.
+    $dialogue = $session->readInspector('test-map', 'D');
+    $revision = $session->applyInspector('test-map', $dialogue['revision'], eventRecordRow($dialogue, 'conditions')['key'], 'event:door_open')['revision'];
+    $type = array_find($session->readInspector('test-map', 'D')['rows'], static fn(array $row): bool => ($row['key']['target'] ?? null) === 'event-type');
+    $session->applyInspector('test-map', $revision, $type['key'], 'Transfer Player');
+    $retyped = getSessionMap($session)->getEventDefinition('D');
+
+    expect($retyped['class'])->toBe('Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger')
+        ->and($retyped['conditions'])->toBe([['type' => 'event', 'name' => 'door_open']])
+        ->and($retyped['sets'])->toBe([['type' => 'event', 'name' => 'met_guide', 'note' => 'kept by the author']])
+        ->and($retyped['data'])->toHaveKeys(['destinationMap', 'spawnPoint', 'spawnSprite'])
+        ->and($retyped['data'])->not->toHaveKey('dialogue');
+});
+
+it('creates and edits a Story Script\'s inline commands with the shared command editor, frame by frame over the protocol', function () {
+    $root = eventRecordProject();
+    $request = eventRecordHost($root);
+    $map = $request('map.read', ['map' => 'test-map'])['result'];
+    $marker = $request('event.create', ['map' => 'test-map', 'revision' => $map['revision'], 'cells' => [[2, 3]], 'type' => 'Story Script'])['result']['marker'];
+    $script = eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => $marker])['result'], 'commandListScript');
+
+    expect($script)->toMatchArray(['kind' => 'info', 'label' => 'Script', 'value' => '0', 'frame' => ['script']]);
+
+    $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result'];
+    expect($frame['frameLabel'])->toBe('Script')
+        ->and(array_column($frame['rows'], 'label'))->toBe(['No commands yet']);
+    $added = $request('inspector.add', ['map' => 'test-map', 'revision' => $frame['revision'], 'key' => $frame['rows'][0]['key']])['result'];
+    expect($added['message'])->toBe('Added a command.');
+
+    $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result'];
+    $type = eventRecordRow($frame, 'command0Type');
+    expect($type['kind'])->toBe('options')
+        ->and($type['options'])->toContain('text', 'branch', 'set_variable', 'move_player')
+        ->and($type['key']['frame'])->toBe(['script']);
+    $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $frame['revision'], 'key' => eventRecordRow($frame, 'command0Text')['key'], 'value' => 'Halt.'])['result']['revision'];
+    $revision = $request('inspector.add', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($frame, 'command0Text')['key']])['result']['revision'];
+    $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result'];
+    $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($frame, 'command1Type')['key'], 'value' => 'branch'])['result']['revision'];
+
+    // A branch's arm is a frame of its own.
+    $frame = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script']])['result'];
+    $then = eventRecordRow($frame, 'command1Then');
+    expect($then['frame'])->toBe(['script', 1, 'then']);
+    $arm = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => $then['frame']])['result'];
+    $revision = $request('inspector.add', ['map' => 'test-map', 'revision' => $revision, 'key' => $arm['rows'][0]['key']])['result']['revision'];
+    $arm = $request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => $then['frame']])['result'];
+    $request('inspector.apply', ['map' => 'test-map', 'revision' => $revision, 'key' => eventRecordRow($arm, 'command0Type')['key'], 'value' => 'set_switch']);
+    $request('map.save', ['map' => 'test-map']);
+    $saved = (require eventRecordDataPath($root))['events'][$marker]['data'];
+
+    expect($saved['script'][0])->toBe(['type' => 'text', 'name' => '', 'text' => 'Halt.'])
+        ->and($saved['script'][1]['type'])->toBe('branch')
+        ->and($saved['script'][1]['then'][0]['type'])->toBe('set_switch')
+        ->and($saved)->not->toHaveKey('scriptId')
+        ->and($request('inspector.read', ['map' => 'test-map', 'event' => $marker, 'frame' => ['script', 9, 'then']])['error']['message'])
+            ->toContain('no longer on the event');
+});
+
+it('drops an existing Story Script\'s empty script id when the event is edited, leaving untouched events as authored', function () {
+    $root = eventRecordProject();
+    $path = eventRecordDataPath($root);
+    file_put_contents($path, str_replace("'scriptId' => 'harbour-watch',", "'scriptId' => '',", file_get_contents($path)));
+    $session = EditorSession::open($root);
+    $row = static function (string $label) use ($session): array {
+        $read = $session->readInspector('test-map', 'S');
+        return ['revision' => $read['revision'], 'key' => array_find($read['rows'], static fn(array $row): bool
+            => ($row['key']['target'] ?? null) === 'event' && ($row['label'] ?? null) === $label)['key']
+            ?? throw new RuntimeException('Missing row ' . $label)];
+    };
+
+    // Saving another event leaves this one's source alone.
+    $dialogue = $session->readInspector('test-map', 'D');
+    $session->applyInspector('test-map', $dialogue['revision'], array_find($dialogue['rows'], static fn(array $row): bool
+        => ($row['key']['target'] ?? null) === 'event' && ($row['key']['marker'] ?? null) === 'D'
+        && ($row['key']['path'] ?? null) !== null && ($row['kind'] ?? null) === 'text')['key'], 'Hello.');
+    $session->saveMap('test-map');
+    expect((require $path)['events']['S']['data'])->toHaveKey('scriptId');
+
+    ['revision' => $revision, 'key' => $key] = $row('Mode');
+    $session->applyInspector('test-map', $revision, $key, 'action');
+    $session->saveMap('test-map');
+    $saved = (require $path)['events']['S'];
+    expect($saved['data'])->not->toHaveKey('scriptId')
+        ->and($saved['data']['mode'])->toBe('action')
+        ->and($saved['data']['script'])->toHaveCount(2)
+        ->and(file_get_contents($path))->toContain('// Timed by the author.');
+});
+
+it('edits an authored inline script in place, keeping its comments and expressions, and refuses what the source cannot take', function () {
+    $root = eventRecordProject();
+    $session = EditorSession::open($root);
+    $rootRows = $session->readInspector('test-map', 'S');
+
+    expect(array_column($rootRows['rows'], 'label'))->toContain('! Inline script replaces Event Script');
+
+    $frame = $session->readInspector('test-map', 'S', ['script']);
+    $before = file_get_contents(eventRecordDataPath($root));
+    expect(fn() => $session->applyInspector('test-map', $frame['revision'], eventRecordRow($frame, 'command1Seconds')['key'], '3'))
+        ->toThrow(SessionRefusal::class)
+        ->and($session->undo()['label'])->toBeNull();
+
+    $session->applyInspector('test-map', $frame['revision'], eventRecordRow($frame, 'command0Text')['key'], 'Stop right there.');
+    $session->saveMap('test-map');
+    $after = file_get_contents(eventRecordDataPath($root));
+
+    expect($after)->toBe(str_replace("'text' => 'Stop there.'", "'text' => 'Stop right there.'", $before))
+        ->and($before)->not->toBe($after);
+});
+
+it('turns a Dialogue event\'s pages into conditional variants without losing a line, and names who is talked to', function () {
+    $root = eventRecordProject();
+    $session = EditorSession::open($root);
+    $read = $session->readInspector('test-map', 'D');
+    $pages = [['name' => 'Guide', 'text' => 'First line.'], ['name' => '', 'text' => 'Second line.']];
+
+    expect(eventRecordRow($read, 'npcId'))->toMatchArray(['kind' => 'text', 'label' => 'Talk-To Id', 'value' => '', 'raw' => ''])
+        ->and(eventRecordRow($read, 'variantList'))->toMatchArray(['listHeading' => true, 'value' => '1'])
+        ->and(eventRecordRow($read, 'variant0Line1Text')['value'])->toBe('Second line.');
+
+    // Pages stay pages while nothing makes them a variant.
+    $revision = $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'npcId')['key'], 'guide-post')['revision'];
+    expect(getSessionMap($session)->getEventDefinition('D')['data'])->toBe(['dialogue' => $pages, 'npcId' => 'guide-post']);
+
+    $revision = $session->applyInspector('test-map', $revision, eventRecordRow($read, 'variant0Conditions')['key'], 'event:met_guide')['revision'];
+    expect(getSessionMap($session)->getEventDefinition('D')['data']['dialogue'])->toBe([
+        ['lines' => $pages, 'conditions' => [['type' => 'event', 'name' => 'met_guide']]],
+    ]);
+
+    $read = $session->readInspector('test-map', 'D');
+    $revision = $session->addInspectorListEntry('test-map', $revision, eventRecordRow($read, 'variant0Line1Text')['key'])['revision'];
+    $added = $session->addInspectorListEntry('test-map', $revision, eventRecordRow($read, 'variantList')['key']);
+    expect($added['message'])->toBe('Added a dialogue variant.');
+    $read = $session->readInspector('test-map', 'D');
+    // A new variant goes last, where the unconditioned fallback belongs: the first variant that holds is spoken.
+    $session->applyInspector('test-map', $added['revision'], eventRecordRow($read, 'variant1Line0Text')['key'], 'Before we met.');
+    $read = $session->readInspector('test-map', 'D');
+
+    expect(fn() => $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'variant1Conditions')['key'], 'nonsense'))
+        ->toThrow(SessionRefusal::class, 'cannot be read');
+
+    $revision = $session->applyInspector('test-map', $read['revision'], eventRecordRow($read, 'npcId')['key'], '')['revision'];
+    $session->saveMap('test-map');
+    $saved = (require eventRecordDataPath($root))['events']['D'];
+
+    expect($saved['data'])->not->toHaveKey('npcId')
+        ->and($saved['data']['dialogue'][0]['conditions'])->toBe([['type' => 'event', 'name' => 'met_guide']])
+        ->and(array_column($saved['data']['dialogue'][0]['lines'], 'text'))->toBe(['First line.', 'Second line.', 'Something to say.'])
+        ->and($saved['data']['dialogue'][1])->toBe(['lines' => [['name' => '', 'text' => 'Before we met.']]])
+        ->and($saved['sets'])->toBe([['type' => 'event', 'name' => 'met_guide', 'note' => 'kept by the author']])
+        ->and($revision)->toBeInt();
+});
+
+it('pins a map to a cell of its region map as one x, y pair, and takes the pin away', function () {
+    $root = eventRecordProject();
+    $request = eventRecordHost($root);
+    $read = $request('inspector.read', ['map' => 'test-map'])['result'];
+    $station = eventRecordRow($read, 'station');
+
+    expect($station)->toMatchArray(['kind' => 'text', 'label' => 'Region Map Station', 'raw' => '', 'value' => '(placed from its doors)']);
+    foreach (['3', '3, 4, 5', 'a, b', '1.5, 2'] as $bad) {
+        expect($request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $station['key'], 'value' => $bad])['error']['message'])
+            ->toContain('two whole numbers');
+    }
+
+    $applied = $request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $station['key'], 'value' => ' -2, 7 '])['result'];
+    expect(eventRecordRow($request('inspector.read', ['map' => 'test-map'])['result'], 'station')['raw'])->toBe('-2, 7');
+    $request('map.save', ['map' => 'test-map']);
+    expect((require eventRecordDataPath($root))['station'])->toBe(['x' => -2, 'y' => 7])
+        ->and(file_get_contents(eventRecordDataPath($root)))->toContain('// Synthetic map for event record authoring.');
+
+    $request('inspector.apply', ['map' => 'test-map', 'revision' => $applied['revision'], 'key' => $station['key'], 'value' => '']);
+    $request('map.save', ['map' => 'test-map']);
+    expect(require eventRecordDataPath($root))->not->toHaveKey('station');
+});
+
+it('picks the heading a transfer arrives facing, with its destination or alone, from the Engine\'s headings', function () {
+    $root = eventRecordProject();
+    $request = eventRecordHost($root);
+    $read = $request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'];
+    $facing = eventRecordRow($read, 'data.spawnSprite');
+
+    expect($facing)->toMatchArray(['kind' => 'options', 'label' => 'Spawn Facing', 'value' => '(sprite art: @)', 'options' => ['North', 'East', 'South', 'West']])
+        ->and($request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $facing['key'], 'value' => 'Up'])['error']['message'])
+            ->toContain('choose one of');
+
+    $revision = $request('inspector.apply', ['map' => 'test-map', 'revision' => $read['revision'], 'key' => $facing['key'], 'value' => 'North'])['result']['revision'];
+    expect(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'], 'data.spawnSprite')['value'])->toBe('North');
+
+    // With the destination: omitted, the facing stays; given, it is checked and set with the spawn point as one step.
+    $moved = $request('event.destination', ['map' => 'test-map', 'revision' => $revision, 'marker' => 'T', 'destination' => 'test-map', 'x' => 2, 'y' => 2])['result'];
+    expect($moved['changed'])->toBeTrue()
+        ->and(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'], 'data.spawnSprite')['value'])->toBe('North');
+    expect($request('event.destination', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => 'T', 'destination' => 'test-map', 'x' => 3, 'y' => 2, 'heading' => 'None'])['error']['message'])
+        ->toContain('not a spawn facing');
+    $request('event.destination', ['map' => 'test-map', 'revision' => $moved['revision'], 'marker' => 'T', 'destination' => 'test-map', 'x' => 3, 'y' => 2, 'heading' => 'West']);
+    $request('map.save', ['map' => 'test-map']);
+
+    expect((require eventRecordDataPath($root))['events']['T']['data'])->toBe(['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 3, 'y' => 2], 'spawnSprite' => ['West']])
+        ->and($request('history.undo')['result']['label'])->toBe('Destination change')
+        ->and(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => 'T'])['result'], 'data.spawnSprite')['value'])->toBe('North');
+
+    $created = $request('event.create', ['map' => 'test-map', 'revision' => $request('map.read', ['map' => 'test-map'])['result']['revision'], 'cells' => [[2, 3]], 'type' => 'Transfer Player'])['result'];
+    expect(eventRecordRow($request('inspector.read', ['map' => 'test-map', 'event' => $created['marker']])['result'], 'data.spawnSprite')['value'])->toBe('South');
+});
+
+it('places a Story Script\'s inline route on the map through its frame rows, at the commands\' place in the event data', function () {
+    $root = eventRecordProject();
+    $path = eventRecordDataPath($root);
+    file_put_contents($path, str_replace("          ['type' => 'text', 'name' => 'Observer', 'text' => 'Stop there.'],",
+        "          ['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 1, 'faceOnly' => false]]],",
+        (string) file_get_contents($path)));
+    $session = EditorSession::open($root);
+    $read = $session->readInspector('test-map', 'S', ['script']);
+    $row = array_find($read['rows'], static fn(array $row): bool => ($row['mapPlacement']['kind'] ?? null) === 'route')
+        ?? throw new RuntimeException('No route placement row.');
+
+    expect($row['key']['frame'])->toBe(['script'])
+        ->and($row['mapPlacement']['issue'] ?? null)->toBeNull();
+
+    $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 3, [1, 1], $read['revision']);
+    $session->saveMap('test-map');
+    $script = (require $path)['events']['S']['data']['script'];
+
+    expect($script[0]['steps'])->toBe([['direction' => 'down', 'count' => 2, 'faceOnly' => false]])
+        ->and(file_get_contents($path))->toContain("'seconds' => abs(1)", '// Timed by the author.');
+});
+
+it('offers no row for the subject an event is bound to or its area, which their own operations set', function () {
+    $root = eventRecordProject();
+    $path = eventRecordDataPath($root);
+    file_put_contents($path, str_replace("    'D' => [\n      'class'", "    'D' => [\n      'subject' => ['kind' => 'world_object', 'id' => 'guide-post'],\n      'class'", (string) file_get_contents($path)));
+    $rows = EditorSession::open($root)->readInspector('test-map', 'D')['rows'];
+
+    expect(array_filter($rows, static fn(array $row): bool => in_array($row['key']['path'][0] ?? $row['key']['field'] ?? null, ['subject', 'area'], true)))->toBe([]);
+});
diff --git a/tests/Unit/Session/EventScriptNameTest.php b/tests/Unit/Session/EventScriptNameTest.php
new file mode 100644
index 00000000..53cb065a
--- /dev/null
+++ b/tests/Unit/Session/EventScriptNameTest.php
@@ -0,0 +1,47 @@
+createDatabaseRecord('common_events', 'payday-notice');
+    $session->saveDatabase('common_events');
+
+    expect($created['records'])->toContain('payday-notice')
+        ->and(is_file($root . '/assets/Events/payday-notice.php'))->toBeTrue()
+        ->and(require $root . '/assets/Events/payday-notice.php')->toBe([['type' => 'text', 'name' => '', 'text' => 'Something happens.']]);
+});
+
+it('refuses a name that is not in id form or that a script already has', function () {
+    $root = makeTemporaryProject();
+    $session = EditorSession::open($root);
+
+    expect(fn() => $session->createDatabaseRecord('common_events', 'Payday Notice'))
+        ->toThrow(SessionRefusal::class, 'written in lower case with hyphens, such as "payday-notice"')
+        ->and(fn() => $session->createDatabaseRecord('common_events', 'dresser-note'))
+        ->toThrow(SessionRefusal::class, 'There is already an event script named dresser-note.');
+});
+
+it('authors which event script a common_event command runs, picked from the project\'s scripts', function () {
+    $root = makeTemporaryProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord('common_events', 'payday-notice')['index'];
+    $key = static fn(string $field): array => ['target' => 'record', 'field' => $field, 'frame' => []];
+
+    $session->applyDatabaseRecord('common_events', $index, $key('command0Type'), 'common_event');
+    $session->applyDatabaseRecord('common_events', $index, $key('command0Id'), 'dresser-note');
+    $session->saveDatabase('common_events');
+
+    expect(require $root . '/assets/Events/payday-notice.php')->toBe([['type' => 'common_event', 'id' => 'dresser-note']])
+        ->and(array_column($session->listReferences(null, 'common_events'), 'value'))->toContain('dresser-note', 'payday-notice');
+});
diff --git a/tests/Unit/Session/EventSpeakerSessionTest.php b/tests/Unit/Session/EventSpeakerSessionTest.php
new file mode 100644
index 00000000..b4458925
--- /dev/null
+++ b/tests/Unit/Session/EventSpeakerSessionTest.php
@@ -0,0 +1,103 @@
+ [],", "'events' => [
+        'E' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\ScriptEventTrigger', 'data' => ['mode' => 'auto', 'script' => [['type' => 'text', 'name' => 'Clerk', 'text' => 'Next.']]]],
+    ], 'npcs' => [
+        ['id' => 'guard', 'name' => 'Guard', 'sprite' => '', 'x' => 0, 'y' => 1],
+        ['id' => 'clerk', 'name' => 'Clerk', 'sprite' => '', 'x' => 2, 'y' => 1],
+        ['name' => 'Passer-by', 'sprite' => '', 'x' => 1, 'y' => 0],
+    ],", (string) file_get_contents($path)));
+
+    return $root;
+}
+
+/** @return list> The event's speaker rows, heading first. */
+function readSpeakerRows(EditorSession $session): array
+{
+    return array_values(array_filter($session->readInspector('test-map', 'E')['rows'],
+        static fn(array $row): bool => ($row['key']['target'] ?? null) === MapSpeakerFields::TARGET));
+}
+
+/** @return list */
+function readSpeakerCodes(EditorSession $session): array
+{
+    return array_values(array_map(static fn(array $issue): string => (string) $issue['code'], array_filter(
+        $session->validateProject(['test-map'])['issues'], static fn(array $issue): bool => $issue['code'] === 'event.speaker')));
+}
+
+it('adds the nearest NPC as a speaker, lets the author choose another from the map, and removes one', function () {
+    $session = EditorSession::open(eventSpeakerProject());
+    [$heading] = readSpeakerRows($session);
+    expect($heading['label'])->toBe('Speakers · 0')->and($heading['value'])->toBe('No one speaks it')->and($heading['list'])->toBe(['index' => 0]);
+
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']);
+    $rows = readSpeakerRows($session);
+    expect(getSessionMap($session)->getEventDefinition('E')['speakers'])->toBe(['clerk'])
+        ->and($rows[1]['value'])->toBe('clerk')
+        // NPCs without an id cannot be named, so they are not offered.
+        ->and($rows[1]['options'])->toBe(['guard', 'clerk'])
+        ->and($rows[1]['optionLabels'])->toBe(['Guard (guard)', 'Clerk (clerk)']);
+
+    $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $rows[1]['key'], 'guard');
+    expect(getSessionMap($session)->getEventDefinition('E')['speakers'])->toBe(['guard'])
+        ->and(fn() => $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $rows[1]['key'], 'nobody'))
+            ->toThrow(SessionRefusal::class, 'choose one of: guard, clerk');
+
+    $session->removeInspectorListEntry('test-map', $session->readMap('test-map')['revision'], readSpeakerRows($session)[1]['key']);
+    expect(getSessionMap($session)->getEventDefinition('E'))->not->toHaveKey('speakers');
+    $session->undo();
+    expect(getSessionMap($session)->getEventDefinition('E')['speakers'])->toBe(['guard']);
+});
+
+it('reports a speaker standing away from the event through the Engine rule, and clears once they stand beside it', function () {
+    $session = EditorSession::open(eventSpeakerProject());
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], readSpeakerRows($session)[0]['key']);
+    expect(readSpeakerCodes($session))->toBe([]);
+    $session->applyInspector('test-map', $session->readMap('test-map')['revision'], readSpeakerRows($session)[1]['key'], 'guard');
+    expect(readSpeakerCodes($session))->toBe(['event.speaker']);
+    $session->moveNpc('test-map', $session->readMap('test-map')['revision'], 0, 3, 1);
+    expect(readSpeakerCodes($session))->toBe([]);
+});
+
+it('protects a speaker id from rename and deletion while an event names it', function () {
+    $session = EditorSession::open(eventSpeakerProject());
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], readSpeakerRows($session)[0]['key']);
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->deleteNpc('test-map', $revision, 1))->toThrow(SessionRefusal::class, 'test-map event E (speaker)')
+        ->and(fn() => $session->applyNpc('test-map', $revision, 1, npcSessionRow($session->readNpc('test-map', 1), 'id')['key'], 'registrar'))
+            ->toThrow(SessionRefusal::class, 'test-map event E (speaker)');
+});
+
+it('names speakers through a batch, which writes them as authored and refuses a speaker left away from the event', function () {
+    $root = eventSpeakerProject();
+    $key = ['target' => MapSpeakerFields::TARGET, 'marker' => 'E'];
+    $away = ChangeBatch::apply($root, [['method' => 'inspector.add', 'params' => ['map' => 'test-map', 'key' => $key]],
+        ['method' => 'inspector.apply', 'params' => ['map' => 'test-map', 'key' => [...$key, 'field' => MapSpeakerFields::SPEAKER_FIELD, 'index' => 0], 'value' => 'guard']]]);
+    expect($away['status'])->toBe('refused')->and(array_column($away['issues'], 'code'))->toContain('event.speaker')
+        ->and((string) file_get_contents($root . '/assets/Maps/test-map/test-map.data.php'))->not->toContain("'speakers'");
+
+    $beside = ChangeBatch::apply($root, [['method' => 'inspector.add', 'params' => ['map' => 'test-map', 'key' => $key]]]);
+    expect($beside['status'])->toBe('saved');
+    $source = (string) file_get_contents($root . '/assets/Maps/test-map/test-map.data.php');
+    expect($source)->toContain("'speakers'")
+        ->and(getSessionMap(EditorSession::open($root))->getEventDefinition('E')['speakers'])->toBe(['clerk']);
+});
diff --git a/tests/Unit/Session/EventSubjectSessionTest.php b/tests/Unit/Session/EventSubjectSessionTest.php
new file mode 100644
index 00000000..7072358c
--- /dev/null
+++ b/tests/Unit/Session/EventSubjectSessionTest.php
@@ -0,0 +1,168 @@
+ [],", "'events' => [
+        'E' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\DialogueEventTrigger', 'data' => ['dialogue' => [['name' => '', 'text' => 'Lockers.']]]],
+    ], 'worldObjects' => [
+        ['id' => 'lockers', 'anchor' => ['x' => 1, 'y' => 1], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null,
+            'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []]],
+    ],", (string) file_get_contents($file)));
+
+    return $root;
+}
+
+/** Where the Engine resolves an event's area on the map as it stands in the session. @return list */
+function readResolvedEventCells(EditorSession $session, string $marker): array
+{
+    $map = getSessionMap($session);
+    $resolution = MapEventResolver::inspect($map->getEditableData(), $map->getEventLayerCells(), 'test-map');
+    $event = array_find($resolution->events, static fn(array $event): bool => ($event['marker'] ?? null) === $marker);
+
+    return $event === null ? [] : \Ichiloto\Engine\Core\CellArea::fromArray($event['area'], 'area')->cells;
+}
+
+it('binds an event to the world object it describes, so the event moves with the object', function () {
+    $root = eventSubjectProject();
+    $session = EditorSession::open($root);
+
+    $bound = $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers');
+
+    expect($bound['changed'])->toBeTrue()
+        ->and(getSessionMap($session)->getEventArea('E'))->toBeNull()
+        ->and(getSessionMap($session)->getEventDefinition('E')['subject'])->toBe(['kind' => 'world_object', 'id' => 'lockers'])
+        ->and(readResolvedEventCells($session, 'E'))->toBe([[1, 1], [2, 1]]);
+
+    $session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'lockers', 1, 0);
+    expect(readResolvedEventCells($session, 'E'))->toBe([[2, 1], [3, 1]]);
+
+    $session->saveMap('test-map');
+    $saved = EditorSession::open($root);
+    expect(getSessionMap($saved)->getEventDefinition('E')['subject'])->toBe(['kind' => 'world_object', 'id' => 'lockers'])
+        ->and(array_filter($saved->validateProject(['test-map'])['issues'], static fn(array $issue): bool => str_starts_with((string) $issue['code'], 'event.')))
+        ->toBe([]);
+});
+
+it('refuses to move a bound event apart from its subject, or to delete a subject an event names', function () {
+    $session = EditorSession::open(eventSubjectProject());
+    $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers');
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->moveEvent('test-map', $revision, 'E', 1, 0))
+        ->toThrow(SessionRefusal::class, 'Event E moves with its subject lockers; move the world object, or unbind the event first.')
+        ->and(fn() => $session->setEventBounds('test-map', $revision, 'E', 0, 0, 1, 1))->toThrow(SessionRefusal::class, 'moves with its subject')
+        ->and(fn() => $session->deleteWorldObject('test-map', $revision, 'lockers'))->toThrow(SessionRefusal::class, 'test-map event E')
+        ->and(fn() => $session->setEventSubject('test-map', $revision, 'E', 'no-such-object'))
+            ->toThrow(SessionRefusal::class, 'test-map has no world object no-such-object.');
+});
+
+it('unbinds an event onto cells of its own, and undo puts the subject back', function () {
+    $session = EditorSession::open(eventSubjectProject());
+    $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers');
+
+    $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', null, [[0, 0]]);
+    expect(getSessionMap($session)->getEventDefinition('E'))->not->toHaveKey('subject')
+        ->and(readResolvedEventCells($session, 'E'))->toBe([[0, 0]])
+        ->and(fn() => $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', null, [[1, 0]]))
+            ->toThrow(SessionRefusal::class, 'Event E has no subject to unbind.');
+
+    $session->undo();
+    expect(getSessionMap($session)->getEventDefinition('E')['subject']['id'])->toBe('lockers')
+        ->and(getSessionMap($session)->getEventArea('E'))->toBeNull();
+});
+
+it('binds an event through a batch and writes it once the map is valid', function () {
+    $root = eventSubjectProject();
+
+    $outcome = ChangeBatch::apply($root, [
+        ['method' => 'event.subject', 'params' => ['map' => 'test-map', 'marker' => 'E', 'subject' => 'lockers']],
+        ['method' => 'worldObjects.move', 'params' => ['map' => 'test-map', 'id' => 'lockers', 'dx' => 1, 'dy' => 0]],
+    ]);
+
+    expect($outcome['status'])->toBe('saved')
+        ->and((require $root . '/assets/Maps/test-map/test-map.data.php')['events']['E']['subject'])->toBe(['kind' => 'world_object', 'id' => 'lockers'])
+        ->and(array_filter($outcome['issues'], static fn(array $issue): bool => str_starts_with((string) $issue['code'], 'event.')))->toBe([]);
+});
+
+/**
+ * The subject project with event E as the given trigger, its data and any
+ * extra root fields.
+ *
+ * @param array $data
+ * @param array $root
+ */
+function eventSubjectProjectAs(string $className, array $data, array $root = []): string
+{
+    $root_ = eventSubjectProject();
+    $file = $root_ . '/assets/Maps/test-map/test-map.data.php';
+    $map = require $file;
+    $map['events']['E'] = ['class' => $className, 'data' => $data, ...$root];
+    file_put_contents($file, " */
+function readEventSubjectErrors(EditorSession $session): array
+{
+    return array_values(array_map(
+        static fn(array $issue): string => $issue['where'] . ': ' . $issue['message'],
+        array_filter($session->validateProject(['test-map'])['issues'],
+            static fn(array $issue): bool => $issue['severity'] === 'error' && str_contains((string) $issue['where'] . ' ' . $issue['message'], 'E'),
+        ),
+    ));
+}
+
+it('binds a Story Script event to its object and saves, reloads and validates it clean', function (string $className, array $data) {
+    $root = eventSubjectProjectAs($className, $data);
+    $session = EditorSession::open($root);
+    $errorsBefore = readEventSubjectErrors($session);
+
+    $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers');
+    $session->saveMap('test-map');
+    $saved = EditorSession::open($root);
+
+    expect(getSessionMap($saved)->getEventDefinition('E'))->toMatchArray(['class' => $className, 'subject' => ['kind' => 'world_object', 'id' => 'lockers']])
+        ->and(readResolvedEventCells($saved, 'E'))->toBe([[1, 1], [2, 1]])
+        ->and(readEventSubjectErrors($saved))->toBe($errorsBefore);
+})->with([
+    'Story Script' => ['Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', ['script' => [['type' => 'text', 'text' => 'Lockers.']], 'mode' => 'action', 'reusable' => true]],
+    'Dialogue' => ['Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', ['dialogue' => [['name' => '', 'text' => 'Lockers.']]]],
+]);
+
+it('still refuses a Story Script event\'s unknown root and data fields and a subject naming no object', function () {
+    $root = eventSubjectProjectAs(
+        'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger',
+        ['script' => [['type' => 'text', 'text' => 'Lockers.']], 'mode' => 'sometimes', 'colour' => 'red'],
+        ['trigger' => 'step'],
+    );
+    $session = EditorSession::open($root);
+    $session->setEventSubject('test-map', $session->readMap('test-map')['revision'], 'E', 'lockers');
+    $session->saveMap('test-map');
+    // The object the subject names is gone from the source.
+    $file = $root . '/assets/Maps/test-map/test-map.data.php';
+    $map = require $file;
+    $map['events']['E']['subject']['id'] = 'no-such-object';
+    file_put_contents($file, "toContain('ScriptEventTrigger uses unsupported root field "trigger".')
+        ->and($errors)->toContain('ScriptEventTrigger uses unsupported data field "colour".')
+        ->and($errors)->toContain('ScriptEventTrigger uses unsupported mode "sometimes".')
+        ->and($errors)->toContain("subject 'no-such-object' was not found")
+        ->and($errors)->not->toContain('unsupported root field "subject"');
+});
diff --git a/tests/Unit/Session/FieldResourceAuthoringTest.php b/tests/Unit/Session/FieldResourceAuthoringTest.php
new file mode 100644
index 00000000..5b18271f
--- /dev/null
+++ b/tests/Unit/Session/FieldResourceAuthoringTest.php
@@ -0,0 +1,161 @@
+ [],
+  'resources' => [
+    'oak' => [
+      'name' => 'Oak',
+      'pivot' => ['x' => 0.5, 'y' => 0.9],
+      'sprites2d' => ['asset' => 'Graphics/Props/Oak.png'],
+      'occupancyStamp' => [['x' => 0, 'y' => 0, 'type' => \Ichiloto\Engine\Events\Enumerations\CollisionType::SOLID]],
+    ],
+  ],
+];
+PHP_SOURCE);
+
+    return [$root, $path, EditorSession::open($root)];
+}
+
+function findResourceRow(EditorSession $session, int $index, string $field): ?array
+{
+    return array_find($session->readDatabaseRecord(FieldResourceFields::CATEGORY, $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+function applyResourceRow(EditorSession $session, int $index, string $field, string $value): array
+{
+    $row = findResourceRow($session, $index, $field) ?? throw new RuntimeException('Missing row ' . $field);
+
+    return $session->applyDatabaseRecord(FieldResourceFields::CATEGORY, $index, $row['key'], $value);
+}
+
+it('offers field resources only to the graphical editor', function () {
+    [, , $session] = createFieldResourceProject();
+
+    expect(array_column(DatabaseCatalog::all(), 'key'))->not->toContain(FieldResourceFields::CATEGORY)
+        ->and(array_column($session->describeProject()['databases'], 'key'))->toContain(FieldResourceFields::CATEGORY)
+        ->and($session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records'])->toBe(['Oak']);
+});
+
+it('reads a resource with its key as its id, its art, ground contact and stamp', function () {
+    [, , $session] = createFieldResourceProject();
+
+    expect(findResourceRow($session, 0, 'id')['value'])->toBe('oak')
+        ->and(findResourceRow($session, 0, 'pivot.y')['value'])->toBe('0.9')
+        ->and(findResourceRow($session, 0, 'sprites2d.asset')['value'])->toBe('Graphics/Props/Oak.png')
+        ->and(findResourceRow($session, 0, 'stamp0Type')['value'] ?? null)->toBe('SOLID');
+});
+
+it('adds and edits a resource, saving the catalogue the Engine admits and keeping what it does not own', function () {
+    [$root, $path, $session] = createFieldResourceProject();
+    $session->createDatabaseRecord(FieldResourceFields::CATEGORY);
+    $index = count($session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records']) - 1;
+    applyResourceRow($session, $index, 'id', 'pine');
+    applyResourceRow($session, $index, 'name', 'Pine');
+    applyResourceRow($session, $index, 'sprites2d.asset', 'Graphics/Props/Pine.png');
+    $session->saveDatabase(FieldResourceFields::CATEGORY);
+
+    $catalog = FieldPresentationCatalog::load($root . '/assets');
+    expect(array_keys($catalog->resources))->toBe(['oak', 'pine'])
+        ->and($catalog->getResource('pine')->name)->toBe('Pine')
+        ->and($catalog->getResource('oak')->occupancyStamp)->toBe([['x' => 0, 'y' => 0, 'type' => CollisionType::SOLID]])
+        ->and((string) file_get_contents($path))->toContain('// Authored cue bindings stay as written.')
+        ->and((require $path)['cues'])->toBe([]);
+});
+
+it('refuses an id the Engine would refuse as it is typed', function () {
+    [, , $session] = createFieldResourceProject();
+
+    expect(fn() => applyResourceRow($session, 0, 'id', 'Oak Tree'))->toThrow(SessionRefusal::class)
+        ->and(findResourceRow($session, 0, 'id')['value'])->toBe('oak');
+});
+
+it('refuses to save a resource the Engine would refuse, writing nothing', function () {
+    [, $path, $session] = createFieldResourceProject();
+    $before = (string) file_get_contents($path);
+    applyResourceRow($session, 0, 'pivot.x', '2');
+
+    expect(fn() => $session->saveDatabase(FieldResourceFields::CATEGORY))->toThrow(SessionRefusal::class)
+        ->and((string) file_get_contents($path))->toBe($before);
+});
+
+it('authors a resource doorway as cells relative to its anchor with their entrance, which the Engine reads back', function () {
+    [, $path, $session] = createFieldResourceProject();
+    $heading = array_find($session->readDatabaseRecord(FieldResourceFields::CATEGORY, 0)['rows'],
+        static fn(array $row): bool => ($row['listHeading'] ?? false) === true && str_starts_with((string) $row['label'], 'Doorway'));
+    expect($heading)->not->toBeNull();
+    $session->addDatabaseItem(FieldResourceFields::CATEGORY, 0, $heading['key'], true);
+    $session->addDatabaseItem(FieldResourceFields::CATEGORY, 0, $heading['key'], true);
+    applyResourceRow($session, 0, 'doorway1X', '1');
+    applyResourceRow($session, 0, 'doorway1Y', '-1');
+    applyResourceRow($session, 0, 'doorway1Entrance', 'front');
+    $session->saveDatabase(FieldResourceFields::CATEGORY);
+
+    $written = require $path;
+    expect($written['resources']['oak']['doorway'])->toBe([['x' => 0, 'y' => 0], ['x' => 1, 'y' => -1, 'entrance' => 'front']])
+        ->and((string) file_get_contents($path))->toContain('// Authored cue bindings stay as written.')
+        ->and(FieldPresentationCatalog::fromArray($written, dirname($path, 3))->getResource('oak')->doorway)
+            ->toBe([['x' => 0, 'y' => 0, 'entrance' => DoorwayEntrance::ON], ['x' => 1, 'y' => -1, 'entrance' => DoorwayEntrance::FRONT]]);
+    // The first door shows its default relation; a relation the Engine does not know refuses at the save, unwritten.
+    $rows = $session->readDatabaseRecord(FieldResourceFields::CATEGORY, 0)['rows'];
+    $entrance = array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === 'doorway0Entrance');
+    expect($entrance['value'] ?? $entrance['displayDefault'] ?? null)->toBe('on');
+    $before = (string) file_get_contents($path);
+    applyResourceRow($session, 0, 'doorway0Entrance', 'behind');
+    expect(fn() => $session->saveDatabase(FieldResourceFields::CATEGORY))->toThrow(SessionRefusal::class)
+        ->and((string) file_get_contents($path))->toBe($before);
+});
+
+it('authors resource attachments as anchor-relative terminal cells and art points, which the Engine reads back', function () {
+    [, $path, $session] = createFieldResourceProject();
+    $heading = array_find($session->readDatabaseRecord(FieldResourceFields::CATEGORY, 0)['rows'],
+        static fn(array $row): bool => ($row['listHeading'] ?? false) === true && str_starts_with((string) $row['label'], 'Attachments'));
+    expect($heading)->not->toBeNull();
+    $session->addDatabaseItem(FieldResourceFields::CATEGORY, 0, $heading['key'], true);
+    applyResourceRow($session, 0, 'attachment0Id', 'aerial');
+    applyResourceRow($session, 0, 'attachment0Cellx', '5');
+    applyResourceRow($session, 0, 'attachment0Celly', '-4');
+    applyResourceRow($session, 0, 'attachment0Pointx', '0.78');
+    applyResourceRow($session, 0, 'attachment0Pointy', '0.05');
+    $session->saveDatabase(FieldResourceFields::CATEGORY);
+
+    $written = require $path;
+    expect($written['resources']['oak']['attachments'])->toBe([['id' => 'aerial', 'cell' => ['x' => 5, 'y' => -4], 'point' => ['x' => 0.78, 'y' => 0.05]]])
+        ->and((string) file_get_contents($path))->toContain('// Authored cue bindings stay as written.')
+        ->and(FieldPresentationCatalog::fromArray($written, dirname($path, 3))->getResource('oak')->findAttachment('aerial')?->toArray())
+            ->toBe(['id' => 'aerial', 'cell' => ['x' => 5, 'y' => -4], 'point' => ['x' => 0.78, 'y' => 0.05]]);
+    // A point off the art refuses at the save, unwritten.
+    $before = (string) file_get_contents($path);
+    applyResourceRow($session, 0, 'attachment0Pointy', '1.5');
+    expect(fn() => $session->saveDatabase(FieldResourceFields::CATEGORY))->toThrow(SessionRefusal::class)
+        ->and((string) file_get_contents($path))->toBe($before);
+});
diff --git a/tests/Unit/Session/FieldRoleAuthoringTest.php b/tests/Unit/Session/FieldRoleAuthoringTest.php
new file mode 100644
index 00000000..7a18f8e3
--- /dev/null
+++ b/tests/Unit/Session/FieldRoleAuthoringTest.php
@@ -0,0 +1,350 @@
+ Character::class, 'data' => [
+    'id' => 'actor-stable', 'name' => 'Different Display Name', 'level' => 1,
+    'future' => abs(7), 'images' => ['face' => 'unrelated-face',
+        'field2d' => ['sheet' => 'Graphics/Characters/Cast.png', /* chosen cell */ 'index' => 2, 'layer' => 100]],
+]];
+PHP);
+    $player = $root . '/assets/Data/Entities/player.php';
+    file_put_contents($player, <<<'PHP'
+ '@', 'north' => '^', 'south' => 'v', 'east' => '>', 'west' => '<',
+    'future' => abs(9),
+    'sprites2d' => ['sheet' => 'Graphics/Characters/Cast.png', /* fixed cell */ 'index' => 3, 'layer' => 50]];
+PHP);
+    writeTilesetTestPng($root . '/assets/Graphics/Characters/Cast.png', 48, 32);
+    writeTilesetTestPng($root . '/assets/Graphics/Characters/$Solo.png', 12, 16);
+    writeTilesetTestPng($root . '/assets/Graphics/Characters/Uneven.png', 50, 50);
+
+    return [$root, $actor, $player, EditorSession::open($root)];
+}
+
+function getFieldRoleRow(EditorSession $session, string $category, string $field): ?array
+{
+    return array_find($session->readDatabaseRecord($category, 0)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+function applyFieldRole(EditorSession $session, string $category, string $field, string $value): array
+{
+    $row = getFieldRoleRow($session, $category, $field) ?? throw new RuntimeException('Missing row ' . $field);
+
+    return $session->applyDatabaseRecord($category, 0, $row['key'], $value);
+}
+
+it('exposes graphical ownership and actor-role pickers only through the graphical session', function () {
+    [$root, , , $session] = createFieldRoleProject();
+    $terminal = ProjectWorkspace::fromProject($root);
+    $actor = $terminal->actorDatabase->getActorByIndex(0);
+    expect(array_column(DatabaseCatalog::all(), 'key'))->not->toContain(PlayerPresentationFields::CATEGORY)
+        ->and($terminal->getRecordDatabase(PlayerPresentationFields::CATEGORY))->toBeNull()
+        ->and(json_encode((new ActorAuthoring())->describeFields($terminal, $actor)))->not->toContain('field2d', 'imagePreview')
+        ->and(array_column($session->describeProject()['databases'], 'key'))->toContain(PlayerPresentationFields::CATEGORY);
+    foreach (['actors' => 'images.field2d.sheet', PlayerPresentationFields::CATEGORY => 'sprites2d.sheet'] as $category => $field) {
+        $row = getFieldRoleRow($session, $category, $field);
+        expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets', 'media' => ['kind' => 'image', 'root' => 'assets']])
+            ->and(array_column($row['imagePreview']['frames'], 'label'))->toBe(['South', 'West', 'East', 'North']);
+    }
+});
+
+it('preserves legacy fixed art and terminal data across explicit mode changes save reopen undo and redo', function () {
+    [$root, , $path, $session] = createFieldRoleProject();
+    $source = file_get_contents($path);
+    $before = require $path;
+    $category = PlayerPresentationFields::CATEGORY;
+    expect(getFieldRoleRow($session, $category, 'graphicalSubject')['value'])->toBe(PlayerPresentationFields::LEGACY)
+        ->and(PlayerPresentationConfig::fromArray($before)->graphicalSubject)->toBe(PlayerGraphicalSubject::FIXED_PLAYER);
+    $sheetKey = getFieldRoleRow($session, $category, 'sprites2d.sheet')['key'];
+    applyFieldRole($session, $category, 'graphicalSubject', 'party-leader');
+    expect(getFieldRoleRow($session, $category, 'sprites2d.sheet')['kind'])->toBe('info')
+        ->and(fn() => $session->applyDatabaseRecord($category, 0, $sheetKey, ''))->toThrow(SessionRefusal::class)
+        ->and($session->listDatabaseRecords($category)['unsavedCategories'])->toContain($category);
+    $session->saveDatabase($category);
+    $after = require $path;
+    expect($after['sprites2d'])->toBe($before['sprites2d'])
+        ->and(PlayerPresentationConfig::fromArray($after)->graphicalSubject)->toBe(PlayerGraphicalSubject::PARTY_LEADER)
+        ->and(PlayerPresentationConfig::fromArray($after)->graphical)->toBeNull();
+    unset($after['graphicalSubject']);
+    expect($after)->toBe($before)->and(file_get_contents($path))->toContain('// Terminal art', "'future' => abs(9)");
+    $session = EditorSession::open($root);
+    applyFieldRole($session, $category, 'graphicalSubject', 'fixed-player');
+    expect(getFieldRoleRow($session, $category, 'sprites2d.index')['value'])->toBe('3');
+    $session->undo();
+    expect(getFieldRoleRow($session, $category, 'sprites2d.sheet')['kind'])->toBe('info');
+    $session->redo();
+    applyFieldRole($session, $category, 'graphicalSubject', PlayerPresentationFields::LEGACY);
+    $session->saveDatabase($category);
+    expect(require $path)->toBe($before)
+        ->and(file_get_contents($path))->toContain('// Terminal art', "'future' => abs(9)", '/* fixed cell */');
+});
+
+it('authors the stable actor field role without changing other roles or identity and roundtrips exact literal edits', function () {
+    [$root, $path, , $session] = createFieldRoleProject();
+    $source = file_get_contents($path);
+    $before = (require $path)['data'];
+    applyFieldRole($session, 'actors', 'images.field2d.index', '6');
+    applyFieldRole($session, 'actors', 'images.field2d.layer', '999');
+    expect(file_get_contents($path))->toBe($source);
+    $session->undo();
+    expect(getFieldRoleRow($session, 'actors', 'images.field2d.layer')['value'])->toBe('100');
+    $session->redo();
+    $session->saveDatabase('actors');
+    expect(file_get_contents($path))->toBe(str_replace(["'index' => 2", "'layer' => 100"], ["'index' => 6", "'layer' => 999"], $source));
+    $after = (require $path)['data'];
+    unset($after['images']['field2d'], $before['images']['field2d']);
+    expect($after)->toBe($before)
+        ->and(getFieldRoleRow(EditorSession::open($root), 'actors', 'images.field2d.index')['value'])->toBe('6');
+});
+
+it('clears the whole optional sheet and restores its settings through history for each owner', function (string $category, string $prefix) {
+    [$root, $actor, $player, $session] = createFieldRoleProject();
+    $original = getFieldRoleRow($session, $category, $prefix . '.sheet');
+    $key = getFieldRoleRow($session, $category, $prefix . '.index')['key'];
+    applyFieldRole($session, $category, $prefix . '.sheet', '');
+    expect(getFieldRoleRow($session, $category, $prefix . '.sheet'))->not->toHaveKey('imagePreview')
+        ->and(getFieldRoleRow($session, $category, $prefix . '.index'))->toBeNull()
+        ->and(fn() => $session->applyDatabaseRecord($category, 0, $key, '0'))->toThrow(SessionRefusal::class);
+    $session->undo();
+    expect(getFieldRoleRow($session, $category, $prefix . '.sheet'))->toBe($original);
+    $session->redo();
+    $session->saveDatabase($category);
+    expect($category === 'actors' ? (require $actor)['data']['images'] : require $player)
+        ->not->toHaveKey($category === 'actors' ? 'field2d' : 'sprites2d');
+    $session->undo();
+    $session->saveDatabase($category);
+    expect(getFieldRoleRow(EditorSession::open($root), $category, $prefix . '.sheet'))->toBe($original);
+})->with([['actors', 'images.field2d'], [PlayerPresentationFields::CATEGORY, 'sprites2d']]);
+
+it('refuses invalid mode choices without altering data source or history', function (string $value) {
+    [$root, , , $session] = createFieldRoleProject();
+    $files = sourceHashTree($root);
+    $before = $session->readDatabaseRecord(PlayerPresentationFields::CATEGORY, 0);
+    expect(fn() => applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', $value))->toThrow(SessionRefusal::class)
+        ->and($session->readDatabaseRecord(PlayerPresentationFields::CATEGORY, 0))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files);
+})->with(['', 'Party-Leader', ' party-leader', 'protagonist']);
+
+it('refuses invalid sheets indices layers and identity substitutions before mutation', function (string $field, string $value) {
+    [$root, , , $session] = createFieldRoleProject();
+    $files = sourceHashTree($root);
+    $before = $session->readDatabaseRecord('actors', 0);
+    expect(fn() => applyFieldRole($session, 'actors', 'images.field2d.' . $field, $value))->toThrow(SessionRefusal::class)
+        ->and($session->readDatabaseRecord('actors', 0))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files);
+})->with([['sheet', '../Outside.png'], ['sheet', 'Graphics/Characters/Missing.png'],
+    ['sheet', 'Graphics/Characters/Uneven.png'], ['sheet', 'Graphics/Characters/$Solo.png'],
+    ['index', '8'], ['index', '-1'], ['index', '1.5'], ['index', ''], ['layer', '-1'], ['layer', '1000'], ['layer', '2147483648']]);
+
+it('does not reset sheet selection metadata when choosing another layout', function () {
+    [, , , $session] = createFieldRoleProject();
+    applyFieldRole($session, 'actors', 'images.field2d.index', '0');
+    applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png');
+    expect(getFieldRoleRow($session, 'actors', 'images.field2d.index')['options'])->toBe(['0'])
+        ->and(getFieldRoleRow($session, 'actors', 'images.field2d.layer')['value'])->toBe('100');
+});
+
+it('reads replaceable current assets and diagnoses missing actor art without a substitute or source mutation', function (string $replacement) {
+    [$root, , , $session] = createFieldRoleProject();
+    $path = $root . '/assets/Graphics/Characters/Cast.png';
+    $original = getFieldRoleRow($session, 'actors', 'images.field2d.sheet')['imagePreview'];
+    if ($replacement === 'valid') {
+        writeTilesetTestPng($path, 96, 64);
+    } elseif ($replacement === 'uneven') {
+        writeTilesetTestPng($path, 50, 50);
+    } elseif ($replacement === 'malformed') {
+        file_put_contents($path, 'not PNG');
+    } else {
+        unlink($path);
+    }
+    $files = sourceHashTree($root);
+    $row = getFieldRoleRow($session, 'actors', 'images.field2d.sheet');
+    if ($replacement === 'valid') {
+        expect($row['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(2 * $original['frames'][0]['sourceRect']['width']);
+    } else {
+        expect($row['imagePreview']['frames'])->toBe([])->and($row['imagePreview']['issue'])->not->toBeEmpty();
+    }
+    expect($row['value'])->toBe('Graphics/Characters/Cast.png')->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($files);
+})->with(['valid', 'uneven', 'malformed', 'missing']);
+
+it('refuses opaque and externally conflicting actor sources before an undo step', function (string $mode) {
+    [$root, $path, , $session] = createFieldRoleProject();
+    if ($mode === 'external') {
+        file_put_contents($path, file_get_contents($path) . "\n// Other author.\n");
+    } else {
+        file_put_contents($path, str_replace("'index' => 2", "'index' => abs(2)", file_get_contents($path)));
+        $session = EditorSession::open($root);
+    }
+    $files = sourceHashTree($root);
+    expect(fn() => applyFieldRole($session, 'actors', 'images.field2d.index', '6'))->toThrow(SessionRefusal::class)
+        ->and(getFieldRoleRow($session, 'actors', 'images.field2d.index')['value'])->toBe('2')
+        ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files);
+})->with(['expression', 'external']);
+
+it('refuses opaque player selector source and can recover by reopening externally edited source', function () {
+    [$root, , $path] = createFieldRoleProject();
+    file_put_contents($path, str_replace("'future' => abs(9)", "'graphicalSubject' => strtolower('FIXED-PLAYER'), 'future' => abs(9)", file_get_contents($path)));
+    $session = EditorSession::open($root);
+    $files = sourceHashTree($root);
+    expect(fn() => applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', 'party-leader'))->toThrow(SessionRefusal::class)
+        ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files);
+    file_put_contents($path, str_replace("strtolower('FIXED-PLAYER')", "'party-leader'", file_get_contents($path)));
+    expect(getFieldRoleRow(EditorSession::open($root), PlayerPresentationFields::CATEGORY, 'sprites2d.sheet')['kind'])->toBe('info');
+});
+
+it('serves the player mode and actor role through the existing GUI record and picker protocol', function () {
+    [$root] = createFieldRoleProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array => $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $hello = $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    expect($hello)->not->toHaveKey('error');
+    $read = $request(2, 'database.record', ['category' => 'actors', 'index' => 0])['result'];
+    $row = array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'images.field2d.sheet');
+    expect($row['reference'])->toBe('png_assets')
+        ->and(array_column(EditorSession::open($root)->listReferences(null, 'png_assets'), 'value'))->toContain('Graphics/Characters/Cast.png')
+        ->and($request(3, 'database.apply', ['category' => 'actors', 'index' => 0, 'key' => $row['key'], 'value' => ''])['result']['changed'])->toBeTrue()
+        ->and($request(4, 'history.undo')['result']['databases'])->toContain('actors');
+});
+
+it('refuses externally changed player source during edits and Save All without overwriting it', function (bool $editFirst) {
+    [$root, , $path, $session] = createFieldRoleProject();
+    $category = PlayerPresentationFields::CATEGORY;
+    if ($editFirst) {
+        applyFieldRole($session, $category, 'graphicalSubject', 'party-leader');
+    }
+    file_put_contents($path, str_replace("'index' => 3", "'index' => 4", file_get_contents($path)));
+    $source = file_get_contents($path);
+    if ($editFirst) {
+        expect(fn() => $session->saveDatabase($category))->toThrow(SessionRefusal::class)
+            ->and($session->saveAll()['failures'])->not->toBeEmpty()
+            ->and($session->hasUnsavedChanges())->toBeTrue();
+    } else {
+        expect(fn() => applyFieldRole($session, $category, 'graphicalSubject', 'party-leader'))->toThrow(SessionRefusal::class)
+            ->and($session->undo()['label'])->toBeNull();
+    }
+    expect(file_get_contents($path))->toBe($source)
+        ->and(getFieldRoleRow(EditorSession::open($root), $category, 'sprites2d.index')['value'])->toBe('4');
+})->with([false, true]);
+
+it('binds a previously absent actor role without storing runtime sheet facts or inferred identities', function () {
+    [$root, $path] = createFieldRoleProject();
+    $before = require $path;
+    unset($before['data']['images']['field2d']);
+    file_put_contents($path, 'not->toHaveKey('imagePreview')
+        ->and(getFieldRoleRow($session, 'actors', 'images.field2d.index'))->toBeNull();
+    applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png');
+    $session->saveAll();
+    $after = (require $path)['data'];
+    expect($after['images']['field2d'])->toBe(['sheet' => 'Graphics/Characters/$Solo.png'])
+        ->and($after['id'])->toBe('actor-stable')->and($after['images']['face'])->toBe('unrelated-face')
+        ->and(getFieldRoleRow(EditorSession::open($root), 'actors', 'images.field2d.index')['value'])->toBe('0');
+});
+
+it('diagnoses invalid authored selectors without presenting inactive fixed art as a substitute', function (mixed $mode) {
+    [$root, , $path] = createFieldRoleProject();
+    $data = require $path;
+    $data['graphicalSubject'] = $mode;
+    file_put_contents($path, 'readDatabaseRecord(PlayerPresentationFields::CATEGORY, 0);
+    expect(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'sprites2d.sheet'))->toMatchArray(['kind' => 'info', 'label' => 'Inactive Fixed Player Field Sheet'])
+        ->and(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'sprites2d.sheet'))->not->toHaveKey('imagePreview')
+        ->and(json_encode($read))->toContain('Invalid graphicalSubject');
+    expect(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject')['value'])->not->toBe(PlayerPresentationFields::LEGACY);
+    applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', 'fixed-player');
+    expect(getFieldRoleRow($session, PlayerPresentationFields::CATEGORY, 'sprites2d.sheet')['kind'])->toBe('reference');
+})->with([null, 'unknown', 42]);
+
+it('refuses invalid fixed-player art through the normal optional-presentation authoring refusal', function (string $field, string $value) {
+    [$root, , , $session] = createFieldRoleProject();
+    $files = sourceHashTree($root);
+    expect(fn() => applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'sprites2d.' . $field, $value))->toThrow(SessionRefusal::class)
+        ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files);
+})->with([['sheet', 'Graphics/Characters/Missing.png'], ['sheet', 'Graphics/Characters/Uneven.png'], ['index', '8'], ['layer', '1.5']]);
+
+it('allows an absent player presentation file to be authored without manufacturing terminal data', function () {
+    [$root, , $path] = createFieldRoleProject();
+    unlink($path);
+    $session = EditorSession::open($root);
+    applyFieldRole($session, PlayerPresentationFields::CATEGORY, 'graphicalSubject', 'party-leader');
+    $saved = $session->saveAll();
+    expect($saved['failures'])->toBe([])->and(require $path)->toBe(['graphicalSubject' => 'party-leader']);
+});
+
+it('preserves actor roles and the player presentation file when the TUI edits ordinary actor data', function () {
+    [$root, $path, $player] = createFieldRoleProject();
+    $workspace = ProjectWorkspace::fromProject($root);
+    $before = (require $path)['data'];
+    $source = file_get_contents($player);
+    $authoring = new ActorAuthoring();
+    $authoring->applyField($workspace, 0, 'description', 'Terminal-only ordinary edit', 'Description');
+    $workspace->actorDatabase->save();
+    expect((require $path)['data']['images'])->toBe($before['images'])->and(file_get_contents($player))->toBe($source)
+        ->and(fn() => $authoring->applyField($workspace, 0, 'images.field2d.sheet', '', 'Field Sheet'))
+        ->toThrow(\Ichiloto\Editor\Database\RecordRefusal::class);
+});
+
+it('requires a stable actor identity before field art authoring and never infers it from a sheet or filename', function () {
+    [$root, $path] = createFieldRoleProject();
+    file_put_contents($path, str_replace("'id' => 'actor-stable', ", '', file_get_contents($path)));
+    $session = EditorSession::open($root);
+    $row = getFieldRoleRow($session, 'actors', 'images.field2d.sheet');
+    $files = sourceHashTree($root);
+    expect($row['kind'])->toBe('info')
+        ->and($row)->not->toHaveKey('imagePreview')
+        ->and(fn() => $session->applyDatabaseRecord('actors', 0, $row['key'], 'Graphics/Characters/$Solo.png'))->toThrow(SessionRefusal::class)
+        ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files);
+});
+
+it('reads the saved role with the current Engine actor contract', function () {
+    [$root, , , $session] = createFieldRoleProject();
+    applyFieldRole($session, 'actors', 'images.field2d.index', '0');
+    applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png');
+    $session->saveDatabase('actors');
+    $actor = ProjectWorkspace::fromProject($root)->actorDatabase->createActorStore()->get('actor-stable');
+    expect($actor->getGraphicalCharacterSheet()->asset)->toBe('Graphics/Characters/$Solo.png')
+        ->and($actor->getGraphicalCharacterSheet()->index)->toBe(0)
+        ->and($actor->id)->toBe('actor-stable');
+});
+
+it('diagnoses malformed actor roles and clears only that optional role without substituting artwork', function () {
+    [$root, $path] = createFieldRoleProject();
+    $data = require $path;
+    $data['data']['images']['field2d'] = 'not a sheet definition';
+    file_put_contents($path, 'toBe([])->and($row['imagePreview']['issue'])->not->toBeEmpty()
+        ->and(fn() => applyFieldRole($session, 'actors', 'images.field2d.sheet', 'Graphics/Characters/$Solo.png'))->toThrow(SessionRefusal::class);
+    applyFieldRole($session, 'actors', 'images.field2d.sheet', '');
+    $session->saveDatabase('actors');
+    expect((require $path)['data']['images'])->toBe(['face' => 'unrelated-face']);
+    $session->undo();
+    expect(getFieldRoleRow($session, 'actors', 'images.field2d.sheet')['imagePreview']['issue'])->toBe($row['imagePreview']['issue']);
+});
diff --git a/tests/Unit/Session/FormationClearanceSessionTest.php b/tests/Unit/Session/FormationClearanceSessionTest.php
new file mode 100644
index 00000000..c90052b8
--- /dev/null
+++ b/tests/Unit/Session/FormationClearanceSessionTest.php
@@ -0,0 +1,106 @@
+ 300,", "            ['enemy' => 'Regular Bat', 'position' => [15, 20]],\n"], ["'depth' => {$depth},", ""], (string) file_get_contents($troops)));
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord('battler_enemies', 'Regular Bat')['index'];
+    setBattlerRow($session, $index, 'artwork.image', 'Graphics/Enemies/Bat.png');
+    setBattlerRow($session, $index, 'artwork.pivot', '0.5, 1');
+    $session->saveDatabase('battler_enemies');
+
+    return $root;
+}
+
+/** @return list The validator's issues about troops. */
+function readTroopClearanceIssues(string $root): array
+{
+    return array_values(array_filter(new TroopFormationValidator()->validate(ProjectWorkspace::fromProject($root)),
+        static fn(Issue $issue): bool => str_starts_with($issue->where, 'assets/Data/troops.php')));
+}
+
+it('reports nothing for a troop member standing clear of the UI and the party', function () {
+    $root = formationClearanceProject(300);
+    $member = EditorSession::open($root)->readTroopFormation(0)['members'][0]['battler'];
+
+    expect($member['diagnostics'])->toBe([])
+        ->and(readTroopClearanceIssues($root))->toBe([]);
+});
+
+it('reports a troop member whose art reaches above the battler area, and leaves it where it stands', function () {
+    $root = formationClearanceProject(200);
+    $troops = (string) file_get_contents($root . '/assets/Data/troops.php');
+    $member = EditorSession::open($root)->readTroopFormation(0)['members'][0];
+    $issues = readTroopClearanceIssues($root);
+
+    expect($member['placement'])->toMatchArray(['x' => 260, 'depth' => 400])
+        ->and($member['battler']['ground'])->toEqual(['x' => 260, 'y' => 200])
+        ->and(implode(' ', $member['battler']['diagnostics']))->toContain('outside the authored battler area')
+        ->and($issues)->not->toBe([])
+        ->and($issues[0]->where)->toBe('assets/Data/troops.php: Pair')
+        ->and($issues[0]->message)->toContain('Regular Bat (member 1)')
+        ->and(file_get_contents($root . '/assets/Data/troops.php'))->toBe($troops);
+});
+
+it('shows an enemy preview beside a party member it stands clear of, with what it does not clear', function () {
+    $root = formationClearanceProject(300);
+    $battler = EditorSession::open($root)->readEnemyPreview(0)['formation']['members'][0]['battler'];
+
+    expect($battler['diagnostics'])->toBe([])
+        ->and($battler['image']['asset'])->toBe('Graphics/Enemies/Bat.png');
+});
+
+it('arranges a member with no battle placement instead of refusing the troop, and reports an unreadable one', function () {
+    $root = troopFormationProject();
+    $issues = array_values(array_filter(new TroopFormationValidator()->validate(ProjectWorkspace::fromProject($root)),
+        static fn(Issue $issue): bool => $issue->severity === \Ichiloto\Editor\Validation\Severity::ERROR));
+    expect($issues)->toBe([]);
+
+    $troops = $root . '/assets/Data/troops.php';
+    file_put_contents($troops, str_replace("'depth' => 300", "'y' => 300", (string) file_get_contents($troops)));
+    $issues = array_values(array_filter(new TroopFormationValidator()->validate(ProjectWorkspace::fromProject($root)),
+        static fn(Issue $issue): bool => str_starts_with($issue->where, 'assets/Data/troops.php')));
+    expect($issues)->toHaveCount(1)
+        ->and($issues[0]->message)->toContain('Regular Bat (member 1)', 'y is a fixed canvas height');
+});
+
+it('warns of a terminal position that leaves out a member\'s target badge, and leaves it where it stands', function () {
+    $root = enemyPreviewProject();
+    // Only the terminal field's issues: this fixture's bat has no graphical battle art.
+    $terminal = static fn(string $root): array => array_values(array_filter(readTroopClearanceIssues($root),
+        static fn(Issue $issue): bool => str_contains($issue->message, 'on the terminal field')));
+    expect($terminal($root))->toBe([]);
+
+    $troops = $root . '/assets/Data/troops.php';
+    file_put_contents($troops, str_replace("'position' => [15, 20]", "'position' => [15, 1]", (string) file_get_contents($troops)));
+    $source = (string) file_get_contents($troops);
+    $issues = $terminal($root);
+
+    expect($issues)->toHaveCount(1)
+        ->and($issues[0]->severity)->toBe(\Ichiloto\Editor\Validation\Severity::WARNING)
+        ->and($issues[0]->where)->toBe('assets/Data/troops.php: Pair')
+        ->and($issues[0]->message)->toBe('Regular Bat (member 2) on the terminal field leaves no room above it for its target badge.')
+        ->and(file_get_contents($troops))->toBe($source);
+});
diff --git a/tests/Unit/Session/IncomeSessionTest.php b/tests/Unit/Session/IncomeSessionTest.php
new file mode 100644
index 00000000..ce29a7ce
--- /dev/null
+++ b/tests/Unit/Session/IncomeSessionTest.php
@@ -0,0 +1,41 @@
+ ($row['key']['field'] ?? null) === $fieldId)
+        ?? throw new RuntimeException("No record row {$fieldId}.");
+}
+
+it('authors a schedule the GUI lists, edits, saves and offers to the income commands', function () {
+    $root = makeTemporaryProject();
+    $session = EditorSession::open($root);
+
+    expect(array_column($session->describeProject()['databases'], 'label', 'key'))->toHaveKey('income_schedules', 'Income');
+
+    $index = $session->createDatabaseRecord('income_schedules')['index'];
+    $session->applyDatabaseRecord('income_schedules', $index, incomeSessionRow($session->readDatabaseRecord('income_schedules', $index), 'name')['key'], 'Rent');
+    $cadence = incomeSessionRow($session->readDatabaseRecord('income_schedules', $index), 'cadence');
+    $session->applyDatabaseRecord('income_schedules', $index, $cadence['key'], 'event');
+    $session->applyDatabaseRecord('income_schedules', $index, incomeSessionRow($session->readDatabaseRecord('income_schedules', $index), 'amount')['key'], '75');
+    $session->saveDatabase('income_schedules');
+
+    $rows = array_map(static fn(array $row): ?string => $row['key']['field'] ?? null, $session->readDatabaseRecord('income_schedules', $index)['rows']);
+    $schedule = IncomeCatalog::fromProject($root . '/assets/' . IncomeCatalog::FILE)->findSchedule('rent');
+
+    expect($rows)->not->toContain('interval', 'idleTimeout')
+        ->and($schedule?->cadence->value)->toBe('event')
+        ->and($schedule?->amount)->toBe(75)
+        ->and($session->listReferences(null, 'income_schedules'))->toBe([['value' => 'rent', 'label' => 'Rent (rent)']]);
+});
diff --git a/tests/Unit/Session/LayerSessionTest.php b/tests/Unit/Session/LayerSessionTest.php
new file mode 100644
index 00000000..31834174
--- /dev/null
+++ b/tests/Unit/Session/LayerSessionTest.php
@@ -0,0 +1,106 @@
+): array */
+function layerSessionHost(string $root): callable
+{
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $request(0, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+
+    return $request;
+}
+
+it('lists a map\'s tile layers with their files and settings as the Engine reads them', function () {
+    $root = mapGraphicsProject();
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'tileLayers' => ['decor' => ['offset' => [0, -0.5], 'movesWith' => 'buildings']],", (string) file_get_contents($data)));
+
+    $map = EditorSession::open($root)->readMap('test-map');
+
+    expect($map['tileLayers'])->toBe(['layers' => [
+        ['name' => 'floor', 'order' => 1, 'file' => 'graphics/01.floor.tiles.php', 'offset' => [0.0, 0.0], 'movesWith' => null, 'owner' => null],
+        ['name' => 'decor', 'order' => 2, 'file' => 'graphics/02.decor.tiles.php', 'offset' => [0.0, -0.5], 'movesWith' => 'buildings', 'owner' => 'buildings'],
+    ], 'issue' => null]);
+});
+
+it('asks before a glyph layer edit that changes collisions, then applies it confirmed as one undo step', function () {
+    $root = layeredMapProject();
+    file_put_contents($root . '/assets/Maps/collisions.php', ' CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::SOLID];');
+    $session = EditorSession::open($root);
+    $revision = $session->readMap('test-map')['revision'];
+
+    $asked = $session->setLayerDecoration('test-map', $revision, 'map:4', true);
+    expect($asked['status'])->toBe('question')
+        ->and($asked['question'])->toContain('Making buildings decoration changes collision at 3 cell(s)')
+        ->and($session->readMap('test-map')['revision'])->toBe($revision);
+
+    $applied = $session->setLayerDecoration('test-map', $revision, 'map:4', true, true);
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4');
+
+    expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true, 'layer' => 'map:4'])
+        ->and($layer['decoration'])->toBeTrue()
+        ->and(fn() => $session->renameLayer('test-map', $revision, 'map:4', 'houses'))->toThrow(SessionRefusal::class, 'changed since revision')
+        ->and($session->undo())->toMatchArray(['label' => 'Layer decoration', 'maps' => ['test-map']]);
+});
+
+it('creates, renames, reorders and removes glyph layers, saving the layer files as one set', function () {
+    $root = layeredMapProject();
+    $session = EditorSession::open($root);
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+
+    $created = $session->createLayer('test-map', $revision(), 'fixtures');
+    $session->renameLayer('test-map', $revision(), $created['layer'], 'props');
+    $session->moveLayer('test-map', $revision(), $created['layer'], null, 'below');
+    $session->removeLayer('test-map', $revision(), 'map:7');
+    $session->saveMap('test-map');
+
+    $files = array_map('basename', glob($root . '/assets/Maps/test-map/layers/*.php') ?: []);
+    expect($files)->toBe(['01.terrain.map.php', '04.buildings.map.php', '07.props.map.php'])
+        ->and(array_column(ProjectWorkspace::fromProject($root)->maps[0]->getLayers(), 'name'))->toBe(['terrain', 'buildings', 'props', 'Events'])
+        ->and(fn() => $session->moveLayer('test-map', $revision(), 'map:1', 3, 'above'))->toThrow(SessionRefusal::class, 'not both or neither')
+        ->and(fn() => $session->removeLayer('test-map', $revision(), 'event', true))->toThrow(SessionRefusal::class, 'Only authored layers');
+});
+
+it('serves tile layer editing over the line protocol, refusing malformed requests by kind', function () {
+    $root = mapGraphicsProject();
+    $request = layerSessionHost($root);
+    $revision = static fn(): int => $request(1, 'map.read', ['map' => 'test-map'])['result']['revision'];
+
+    expect($request(2, 'tileLayer.create', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'rugs'])['result'])
+        ->toMatchArray(['status' => 'applied', 'changed' => true, 'layer' => 'rugs'])
+        ->and($request(3, 'tileLayer.rename', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'rugs', 'newName' => 'carpets'])['result']['layer'])
+            ->toBe('carpets')
+        ->and($request(4, 'tileLayer.reorder', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets', 'order' => 0])['result']['status'])
+            ->toBe('applied')
+        ->and($request(5, 'tileLayer.settings', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets',
+            'offset' => [0.5, 0], 'movesWith' => 'buildings'])['result']['status'])->toBe('applied')
+        ->and($request(6, 'tileLayer.settings', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets',
+            'offset' => [0.25, 0], 'movesWith' => null])['error']['kind'])->toBe('refusal')
+        ->and($request(7, 'tileLayer.settings', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'carpets', 'offset' => [0]])['error'])
+            ->toBe(['kind' => 'request', 'message' => '"offset" must be two numbers, across and down.'])
+        ->and($request(8, 'tileLayer.remove', ['map' => 'test-map', 'revision' => $revision(), 'name' => 'decor'])['result']['layer'])->toBeNull()
+        ->and($request(9, 'layer.decoration', ['map' => 'test-map', 'revision' => $revision(), 'layer' => 'map:4'])['error']['kind'])->toBe('request')
+        ->and($request(10, 'map.save', ['map' => 'test-map'])['result']['saved'])->toBe('test-map');
+
+    $map = ProjectWorkspace::fromProject($root)->maps[0];
+    expect(array_map('basename', glob($map->directory . '/graphics/*.php') ?: []))->toBe(['00.carpets.tiles.php', '01.floor.tiles.php'])
+        ->and($map->getMapDataField(['tileLayers']))->toBe(['carpets' => ['offset' => [0.5, 0], 'movesWith' => 'buildings']])
+        ->and($map->loadGraphics()->offsets)->toBe(['carpets' => [0.5, 0.0]]);
+});
diff --git a/tests/Unit/Session/MapAreaSessionTest.php b/tests/Unit/Session/MapAreaSessionTest.php
new file mode 100644
index 00000000..2c05714f
--- /dev/null
+++ b/tests/Unit/Session/MapAreaSessionTest.php
@@ -0,0 +1,239 @@
+> */
+function readSessionAreaCells(EditorSession $session): array
+{
+    return array_column(array_map(static fn(array $area): array => ['id' => $area['id'], 'cells' => $area['cells']],
+        $session->readAreas('test-map')['areas']), 'cells', 'id');
+}
+
+/** The map's areas as its data file holds them. */
+function readSavedAreas(string $file): mixed
+{
+    return (require $file)['areas'] ?? null;
+}
+
+it('names the subject and anchor kinds the Engine reads', function () {
+    expect(MapEventDefinition::SUBJECT_KINDS)->toContain(MapAreaAuthoring::SUBJECT_KIND)
+        ->and(MapLandmarkCatalog::ANCHOR_KINDS)->toContain(MapAreaAuthoring::ANCHOR_KIND);
+});
+
+it('creates, reshapes, renames and deletes areas as undo steps, and saves them as written', function () {
+    [, $file, $session] = createLandmarkProject();
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+
+    $session->createArea('test-map', $revision(), 'yard', ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]);
+    $session->createArea('test-map', $revision(), 'porch', ['cells' => [[1, 0], [2, 0]]]);
+    expect(readSessionAreaCells($session))->toBe(['yard' => [[0, 1], [1, 1]], 'porch' => [[1, 0], [2, 0]]])
+        ->and($session->readMap('test-map')['areas'][0])->toMatchArray(['id' => 'yard', 'references' => []]);
+
+    $session->setArea('test-map', $revision(), 'porch', ['cells' => [[1, 0]]]);
+    $session->renameArea('test-map', $revision(), 'yard', 'garden');
+    expect(readSessionAreaCells($session))->toBe(['garden' => [[0, 1], [1, 1]], 'porch' => [[1, 0]]]);
+
+    $session->deleteArea('test-map', $revision(), 'porch');
+    $session->saveMap('test-map');
+    expect(readSavedAreas($file))->toBe([['id' => 'garden', 'area' => ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]]])
+        ->and(readSessionAreaCells(EditorSession::open(dirname($file, 4))))->toBe(['garden' => [[0, 1], [1, 1]]]);
+
+    // Undo walks back each step; the last one removes the block.
+    expect($session->undo()['label'])->toBe('Delete map area')
+        ->and(array_keys(readSessionAreaCells($session)))->toBe(['garden', 'porch']);
+    $session->undo();
+    $session->undo();
+    $session->undo();
+    $session->undo();
+    expect(readSessionAreaCells($session))->toBe([])
+        ->and(getSessionMap($session)->hasMapDataField(['areas']))->toBeFalse();
+});
+
+it('refuses an area the map would not load, changing nothing', function (string $id, array $area, string $reason, bool $badShape) {
+    [, , $session] = createLandmarkProject();
+    $session->createArea('test-map', $session->readMap('test-map')['revision'], 'yard', ['cells' => [[0, 1]]]);
+    $before = $session->readAreas('test-map');
+
+    expect(fn() => $session->createArea('test-map', $before['revision'], $id, $area))->toThrow(SessionRefusal::class, $reason);
+    if ($badShape) {
+        expect(fn() => $session->setArea('test-map', $before['revision'], 'yard', $area))->toThrow(SessionRefusal::class, $reason);
+    }
+    expect($session->readAreas('test-map'))->toBe($before);
+})->with([
+    'a taken id' => ['yard', ['cells' => [[1, 1]]], 'already has an area yard', false],
+    'an id that is not one' => ['Big Yard', ['cells' => [[1, 1]]], 'unique stable lowercase ids', false],
+    'off the map' => ['far', ['x' => 3, 'y' => 1, 'width' => 2, 'height' => 1], 'outside the physical map', true],
+    'no cells' => ['empty', ['cells' => []], 'test-map', true],
+    'a repeated cell' => ['twice', ['cells' => [[1, 1], [1, 1]]], 'duplicate cells', true],
+]);
+
+it('renames an area with the events and landmarks naming it, and refuses to delete it while they do', function () {
+    [, $file, $session] = createLandmarkProject("'landmarks' => [['id' => 'yard-sign', 'kind' => 'landmark', 'anchor' => ['area' => 'yard']]],");
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+    $session->createArea('test-map', $revision(), 'yard', ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]);
+    $session->setEventSubject('test-map', $revision(), 'E', ['kind' => 'area', 'id' => 'yard']);
+
+    expect(getSessionMap($session)->getEventTriggerArea('E')?->cells)->toBe([[0, 1], [1, 1]])
+        ->and($session->readAreas('test-map')['areas'][0]['references'])->toBe(['test-map event E', 'test-map landmark yard-sign (landmark)'])
+        ->and(fn() => $session->deleteArea('test-map', $revision(), 'yard'))
+            ->toThrow(SessionRefusal::class, "Area yard is named by test-map event E, test-map landmark yard-sign (landmark); change those first.")
+        ->and(fn() => $session->moveEvent('test-map', $revision(), 'E', 1, 0))->toThrow(SessionRefusal::class, 'Event E covers area yard');
+
+    $session->renameArea('test-map', $revision(), 'yard', 'garden');
+    expect(getSessionMap($session)->getEventDefinition('E')['subject'])->toBe(['kind' => 'area', 'id' => 'garden'])
+        ->and(readLandmarkEntries($session)[0]['anchor'])->toBe(['area' => 'garden'])
+        ->and(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 0])['options'])->toContain('area:garden');
+
+    $session->saveMap('test-map');
+    $saved = require $file;
+    expect($saved['events']['E']['subject'])->toBe(['kind' => 'area', 'id' => 'garden'])
+        ->and($saved['landmarks'][0]['anchor'])->toBe(['area' => 'garden'])
+        ->and(array_filter($session->validateProject(['test-map'])['issues'], static fn(array $issue): bool => $issue['severity'] === 'error'))->toBe([]);
+
+    $session->undo();
+    expect(getSessionMap($session)->getEventDefinition('E')['subject'])->toBe(['kind' => 'area', 'id' => 'yard'])
+        ->and(readLandmarkEntries($session)[0]['anchor'])->toBe(['area' => 'yard']);
+});
+
+it('binds an event to an area made in the same change set, and writes both together', function () {
+    [, $file, $session] = createLandmarkProject();
+    $session->beginChanges();
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+    $session->createArea('test-map', $revision(), 'yard', ['cells' => [[0, 1], [1, 1]]]);
+    $session->setEventSubject('test-map', $revision(), 'E', ['kind' => 'area', 'id' => 'yard']);
+    $cells = getSessionMap($session)->getEventTriggerArea('E')?->cells;
+
+    $outcome = $session->commitChanges();
+
+    $saved = require $file;
+    expect($cells)->toBe([[0, 1], [1, 1]])
+        ->and($outcome['status'])->toBe('saved', json_encode($outcome['issues']))
+        ->and($saved['areas'])->toBe([['id' => 'yard', 'area' => ['cells' => [[0, 1], [1, 1]]]]])
+        ->and($saved['events']['E']['subject'])->toBe(['kind' => 'area', 'id' => 'yard']);
+});
+
+it('refuses a subject naming no area, or a kind the Engine does not read', function () {
+    [, , $session] = createLandmarkProject();
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->setEventSubject('test-map', $revision, 'E', ['kind' => 'area', 'id' => 'nowhere']))
+        ->toThrow(SessionRefusal::class, 'test-map has no area nowhere.')
+        ->and(fn() => $session->setEventSubject('test-map', $revision, 'E', ['kind' => 'npc', 'id' => 'keeper']))
+        ->toThrow(SessionRefusal::class, 'An event subject is one of world_object, area, not npc.');
+});
+
+it('moves area cells with inserted lines, and stops a removal through an area\'s cell', function () {
+    [$root, $file, $session] = createLandmarkProject();
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+    $session->createArea('test-map', $revision(), 'yard', ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]);
+    $session->createArea('test-map', $revision(), 'porch', ['cells' => [[1, 1], [2, 0]]]);
+    $session->saveMap('test-map');
+
+    $question = $session->insertMapLines('test-map', $revision(), 'y', 1, 2);
+    $session->insertMapLines('test-map', $question['revision'], 'y', 1, 2, 'write', $question['confirm']);
+
+    expect(readSavedAreas($file))->toBe([
+        ['id' => 'yard', 'area' => ['x' => 0, 'y' => 3, 'width' => 2, 'height' => 1]],
+        ['id' => 'porch', 'area' => ['cells' => [[1, 3], [2, 0]]]],
+    ]);
+
+    // Column 2 holds only porch's cell.
+    expect(fn() => $session->removeMapLines('test-map', $session->readMap('test-map')['revision'], 'x', 2, 1))
+        ->toThrow(SessionRefusal::class, 'areas.1.area.cells.1.0 (area porch has a cell on a removed line)');
+});
+
+it('asks the author to reshape exact cells that inserted lines leave a gap in, never filling it by guess', function () {
+    [$root, $file, $session] = createLandmarkProject();
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+    // span covers row 1 on both sides of column 1; porch meets column 1 only on another row.
+    $session->createArea('test-map', $revision(), 'span', ['cells' => [[0, 1], [1, 1]]]);
+    $session->createArea('test-map', $revision(), 'porch', ['cells' => [[0, 0], [2, 1]]]);
+    $session->saveMap('test-map');
+
+    $question = $session->insertMapLines('test-map', $revision(), 'x', 1, 2);
+    $reasons = array_column($question['handEdits'], 'reason', 'where');
+    expect($reasons)->toBe(['areas.0.area.cells' => 'area span has cells on both sides of the inserted lines, which leave a gap in it; reshape it over them']);
+
+    $session->insertMapLines('test-map', $question['revision'], 'x', 1, 2, 'write', $question['confirm']);
+    expect(readSavedAreas($file))->toBe([
+        ['id' => 'span', 'area' => ['cells' => [[0, 1], [3, 1]]]],
+        ['id' => 'porch', 'area' => ['cells' => [[0, 0], [4, 1]]]],
+    ]);
+});
+
+it('renames and deletes areas, and binds an event to one, through the rows both editors share', function () {
+    [, , $session] = createLandmarkProject();
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+    $session->createArea('test-map', $revision(), 'yard', ['x' => 0, 'y' => 1, 'width' => 2, 'height' => 1]);
+    $session->createArea('test-map', $revision(), 'porch', ['cells' => [[1, 0]]]);
+    $areaRow = static fn(int $index, string $field): array => findLandmarkRow($session, ['target' => 'map-areas', 'field' => $field, 'index' => $index]);
+
+    expect($areaRow(0, 'id'))->toMatchArray(['kind' => 'text', 'raw' => 'yard', 'depth' => 1, 'list' => ['index' => 0], 'listAddsOnMap' => true])
+        ->and($areaRow(1, 'cells')['value'])->toBe('1 cell in 1 place');
+
+    $session->applyInspector('test-map', $revision(), $areaRow(0, 'id')['key'], 'garden');
+    $subject = findLandmarkRow($session, ['target' => 'event-subject', 'marker' => 'E'], 'E');
+    expect($subject['options'])->toBe(['', 'world_object:old-tower', 'area:garden', 'area:porch'])
+        ->and($subject['optionLabels'])->toBe(['Its own cells', 'World object old-tower', 'Area garden', 'Area porch']);
+
+    $session->applyInspector('test-map', $revision(), $subject['key'], 'area:garden');
+    expect(getSessionMap($session)->getEventTriggerArea('E')?->cells)->toBe([[0, 1], [1, 1]])
+        ->and(fn() => $session->removeInspectorListEntry('test-map', $revision(), $areaRow(0, 'cells')['key']))
+            ->toThrow(SessionRefusal::class, 'Area garden is named by test-map event E');
+
+    // Its own cells again: it keeps triggering where the area put it.
+    $session->applyInspector('test-map', $revision(), $subject['key'], '');
+    expect(getSessionMap($session)->getEventDefinition('E'))->not->toHaveKey('subject')
+        ->and(getSessionMap($session)->getEventArea('E')?->cells)->toBe([[0, 1], [1, 1]]);
+
+    $removed = $session->removeInspectorListEntry('test-map', $revision(), $areaRow(1, 'id')['key']);
+    expect($removed['message'])->toBe('Deleted area porch.')
+        ->and(array_column($session->readAreas('test-map')['areas'], 'id'))->toBe(['garden'])
+        ->and(fn() => $session->addInspectorListEntry('test-map', $revision(), findLandmarkRow($session, ['target' => 'map-areas'])['key']))
+            ->toThrow(SessionRefusal::class, 'Select cells on the map, then make an area of them.');
+});
+
+it('makes an area of selected cells under the first free id, writing a filled rectangle as one', function () {
+    [, , $session] = createLandmarkProject();
+    $revision = static fn(): int => $session->readMap('test-map')['revision'];
+
+    $first = $session->createArea('test-map', $revision(), null, EditorSession::readAreaShape(['cells' => [[1, 1], [0, 1], [0, 0], [1, 0]]]));
+    $second = $session->createArea('test-map', $revision(), null, EditorSession::readAreaShape(['cells' => [[2, 0], [3, 1]]]));
+
+    expect([$first['id'], $second['id']])->toBe(['area-1', 'area-2'])
+        ->and(array_column($session->readAreas('test-map')['areas'], 'area', 'id'))->toBe([
+            'area-1' => ['x' => 0, 'y' => 0, 'width' => 2, 'height' => 2],
+            'area-2' => ['cells' => [[2, 0], [3, 1]]],
+        ])
+        ->and(fn() => EditorSession::readAreaShape(['cells' => 'everywhere']))->toThrow(SessionRefusal::class, 'Name the area');
+});
+
+it('refuses an areas block written as null or as something other than a list, leaving the source as it is', function (string $block) {
+    [, $file, $session] = createLandmarkProject("'areas' => {$block},");
+    $source = (string) file_get_contents($file);
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->readAreas('test-map'))->toThrow(SessionRefusal::class, 'areas must be a list of declarations')
+        ->and(fn() => $session->createArea('test-map', $revision, 'yard', ['cells' => [[0, 1]]]))->toThrow(SessionRefusal::class, 'areas must be a list of declarations')
+        ->and(fn() => $session->createArea('test-map', $revision, null, ['cells' => [[0, 1]]]))->toThrow(SessionRefusal::class, 'areas must be a list of declarations')
+        ->and(array_filter($session->readInspector('test-map')['rows'], static fn(array $row): bool => ($row['key']['target'] ?? null) === 'map-areas'))->toBe([])
+        ->and(array_column(array_filter($session->readInspector('test-map')['rows'], static fn(array $row): bool => $row['label'] === '! Read-only'), 'value'))
+            ->toContain('test-map: areas must be a list of declarations; it cannot be edited as areas.')
+        ->and($session->readMap('test-map'))->toMatchArray(['areas' => [], 'areasIssue' => 'test-map: areas must be a list of declarations; it cannot be edited as areas.'])
+        ->and($session->hasUnsavedChanges())->toBeFalse()
+        ->and((string) file_get_contents($file))->toBe($source);
+})->with(['null' => ['null'], 'a string' => ["'yard'"], 'a keyed array' => ["['yard' => ['id' => 'yard']]"]]);
diff --git a/tests/Unit/Session/MapAtlasAuthoringTest.php b/tests/Unit/Session/MapAtlasAuthoringTest.php
new file mode 100644
index 00000000..d151f651
--- /dev/null
+++ b/tests/Unit/Session/MapAtlasAuthoringTest.php
@@ -0,0 +1,176 @@
+ 'map-atlas', 'field' => $field], null, $mapId);
+}
+
+it('places a local map under the global map by an entry chosen from its landmarks, as one undo step', function () {
+    [, $file, $session] = createAtlasProject();
+    $before = file_get_contents($file);
+    $scope = findAtlasRow($session, 'scope');
+    expect($scope['value'])->toBe('local')->and($scope['options'])->toBe(['local', 'global']);
+    $parent = findAtlasRow($session, 'parent');
+    // The world's gate leads here, so it is offered first and said so.
+    expect($parent['options'])->toBe(['', 'world#town-gate'])
+        ->and($parent['optionLabels'][1])->toBe('World · town-gate');
+
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], 'world#town-gate');
+    expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['parent' => ['map' => 'world', 'landmark' => 'town-gate']])
+        ->and(file_get_contents($file))->toBe($before);
+    $global = array_find($session->readInspector('test-map')['rows'], static fn(array $row): bool => $row['label'] === 'Global Map');
+    expect($global['value'])->toBe('world at town-gate');
+    $session->undo();
+    expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse();
+    $session->redo();
+    $session->saveMap('test-map');
+    expect(file_get_contents($file))->toContain('// Map-owned presentation stays authored.', "'atlas' =>", "'town-gate'");
+
+    // Becoming global drops the parent; becoming local again says nothing, as a map that never chose.
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'global');
+    expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['scope' => 'global']);
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'local');
+    expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse();
+});
+
+it('refuses a parent chain the Engine would refuse, and keeps entries other maps name', function () {
+    [, , $session] = createAtlasProject();
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], 'world#town-gate');
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'passage', 'T');
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'local', null, 'world');
+    expect(fn() => applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], 'test-map#landmark-1', null, 'world'))
+        ->toThrow(SessionRefusal::class, 'cycle');
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'scope'], 'global', null, 'world');
+
+    // The world's gate is test-map's entry: it stays until test-map chooses another.
+    expect(fn() => applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'undescribed', 'T', 'world'))
+        ->toThrow(SessionRefusal::class, 'the atlas parent of test-map (entry town-gate)');
+    $heading = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0], null, 'world');
+    expect(fn() => $session->removeInspectorListEntry('world', $session->readMap('world')['revision'], $heading['key']))
+        ->toThrow(SessionRefusal::class, 'the atlas parent of test-map');
+    expect($session->deleteMap('world')['references'])->toContain('the atlas parent of test-map (entry town-gate)');
+
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'parent'], '');
+    $session->removeInspectorListEntry('world', $session->readMap('world')['revision'], $heading['key']);
+    expect(readLandmarkEntries($session, 'world'))->toBe([]);
+});
+
+it('reports an atlas whose parent no longer exists', function () {
+    [, , $session] = createAtlasProject("'atlas' => ['parent' => ['map' => 'world', 'landmark' => 'old-gate']],");
+    $map = getSessionMap($session);
+    $problems = mapValidatorMessages($map, ['test-map' => $map, 'world' => getSessionMap($session, 'world')]);
+    expect($problems)->toContain('Atlas: Atlas parent landmark does not exist on its referenced map.');
+    expect(findAtlasRow($session, 'parent')['optionLabels'])->toContain('world#old-gate · no longer declared');
+});
+
+it('chooses a landmark category from the Engine generics and the project\'s own, kept while used', function () {
+    [$root, , $session] = createAtlasProject();
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'building', 'E');
+    $category = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0]);
+    expect($category['options'])->toBe(['', ...array_column(MapLandmarkCategory::cases(), 'value')]);
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0], 'inn');
+    expect(readLandmarkEntries($session)[0]['category'])->toBe('inn');
+
+    // A project category, created on its own page and usable before it is saved.
+    $index = $session->createDatabaseRecord(MapCategoryFields::CATEGORY)['index'];
+    $setCategory = static function (string $field, string $value) use ($session, $index): void {
+        $row = array_find($session->readDatabaseRecord(MapCategoryFields::CATEGORY, $index)['rows'],
+            static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+        $session->applyDatabaseRecord(MapCategoryFields::CATEGORY, $index, $row['key'], $value);
+    };
+    expect(fn() => $setCategory('id', 'inn'))->toThrow(SessionRefusal::class, 'not inn, shop, home or organization');
+    $setCategory('id', 'bard-school');
+    $setCategory('name.default', 'Bard School');
+    $setCategory('base', 'organization');
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0], 'bard-school');
+    expect(readLandmarkEntries($session)[0]['category'])->toBe('bard-school');
+    expect(fn() => $session->deleteDatabaseRecord(MapCategoryFields::CATEGORY, $index))
+        ->toThrow(SessionRefusal::class, 'landmark landmark-1 on test-map');
+    expect(fn() => $setCategory('id', 'music-school'))->toThrow(SessionRefusal::class, 'its id stays');
+
+    $session->saveDatabase(MapCategoryFields::CATEGORY);
+    $catalog = MapCategoryCatalog::load($root . '/assets');
+    expect($catalog->getIds())->toContain('bard-school')
+        ->and($catalog->getBase('bard-school'))->toBe(MapLandmarkCategory::ORGANIZATION)
+        ->and($catalog->categories['bard-school']['name'])->toBe(['message' => MapCategoryCatalog::getNameMessageKey('bard-school'), 'default' => 'Bard School'])
+        ->and((require $root . '/assets/' . MapCategoryCatalog::FILE)['schema'])->toBe(MapCategoryCatalog::SCHEMA);
+
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'category', 'index' => 0], '');
+    expect(readLandmarkEntries($session)[0])->not->toHaveKey('category');
+    $session->deleteDatabaseRecord(MapCategoryFields::CATEGORY, $index);
+});
+
+it('chooses how the Terminal draws a map, saying nothing when it is the Engine\'s default for the scope', function () {
+    [, , $session] = createAtlasProject();
+    $view = findAtlasRow($session, 'terminalView');
+    expect($view['options'])->toBe(Ichiloto\Engine\Field\MapAtlasDefinition::TERMINAL_VIEWS)
+        ->and($view['value'])->toBe(Ichiloto\Editor\Field\MapAtlas::readTerminalView([]));
+    $other = array_values(array_diff(Ichiloto\Engine\Field\MapAtlasDefinition::TERMINAL_VIEWS, [$view['value']]))[0];
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'terminalView'], $other);
+    expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['terminalView' => $other]);
+    applyLandmarkRow($session, ['target' => 'map-atlas', 'field' => 'terminalView'], $view['value']);
+    expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse();
+    // A global map reads the Engine's own default for global maps.
+    expect(findAtlasRow($session, 'terminalView', 'world')['value'])->toBe(Ichiloto\Editor\Field\MapAtlas::readTerminalView(['scope' => 'global']));
+    $session->undo();
+    expect(getSessionMap($session)->getMapDataField(['atlas']))->toBe(['terminalView' => $other]);
+});
+
+/** The terrain row editing this field at exactly this layer and glyph path. */
+function findTerrainRow($session, string $field, array $path): array
+{
+    return array_find($session->readInspector('test-map')['rows'], static fn(array $row): bool => ($row['key'] ?? null)
+        == ['target' => 'map-terrain', 'field' => $field, 'path' => $path]) ?? throw new RuntimeException('No terrain row ' . json_encode($path));
+}
+
+it('classifies a map\'s own layer glyphs as terrain for its atlas, never copying a grid', function () {
+    [, $file, $session] = createAtlasProject();
+    $terrainRow = static fn(string $field, array $path): array => findTerrainRow($session, $field, $path);
+    $apply = static fn(string $field, array $path, string $value): array => $session->applyInspector('test-map',
+        $session->readMap('test-map')['revision'], findTerrainRow($session, $field, $path)['key'], $value);
+
+    $dots = $terrainRow('symbol', ['terrain', '.']);
+    expect($dots['label'])->toBe("'.' · 8 cells")
+        ->and($dots['options'])->toBe(['', ...Ichiloto\Engine\Field\MapAtlasTerrain::ROLES]);
+    expect($terrainRow('symbol', ['buildings', ' '])['label'])->toBe('(blank) · 5 cells');
+
+    $apply('symbol', ['terrain', '.'], 'ground');
+    $apply('default', ['buildings'], 'building');
+    $apply('symbol', ['buildings', '/'], 'boundary');
+    expect(getSessionMap($session)->getMapDataField(['atlas', 'terrain']))->toBe([
+        'terrain' => ['symbols' => ['.' => 'ground']],
+        'buildings' => ['default' => 'building', 'symbols' => ['/' => 'boundary']],
+    ]);
+    expect(fn() => $apply('symbol', ['terrain', '.'], 'lava'))->toThrow(SessionRefusal::class);
+
+    $session->saveMap('test-map');
+    $map = getSessionMap($session);
+    $catalog = Ichiloto\Engine\Field\MapAtlasTerrain::readMap(require $file, $map->getLayerSet());
+    expect($catalog->getRole(1, 1))->toBe('building')->and($catalog->getRole(1, 0))->toBe('boundary')
+        ->and(file_get_contents($file))->toContain('// Map-owned presentation stays authored.');
+
+    // Clearing every classification removes the terrain, then the empty atlas.
+    $apply('symbol', ['buildings', '/'], '');
+    $apply('default', ['buildings'], '');
+    $apply('symbol', ['terrain', '.'], '');
+    expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse();
+    $session->undo();
+    expect(getSessionMap($session)->getMapDataField(['atlas', 'terrain']))->toBe(['terrain' => ['symbols' => ['.' => 'ground']]]);
+});
+
+it('shows terrain bound to a layer the map no longer has, and clears it', function () {
+    [, , $session] = createAtlasProject("'atlas' => ['terrain' => ['old' => ['default' => 'water']]],");
+    $rows = $session->readInspector('test-map')['rows'];
+    expect(array_column(array_filter($rows, static fn(array $row): bool => $row['label'] === '! Problem'), 'value'))
+        ->toContain('Atlas terrain references missing source layer old.');
+    $session->applyInspector('test-map', $session->readMap('test-map')['revision'], findTerrainRow($session, 'layer', ['old'])['key'], '');
+    expect(getSessionMap($session)->hasMapDataField(['atlas']))->toBeFalse();
+});
diff --git a/tests/Unit/Session/MapIdentitySessionTest.php b/tests/Unit/Session/MapIdentitySessionTest.php
new file mode 100644
index 00000000..0768e69e
--- /dev/null
+++ b/tests/Unit/Session/MapIdentitySessionTest.php
@@ -0,0 +1,175 @@
+
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result');
+
+    return $request;
+}
+
+it('creates a map at a chosen id inside another map\'s folder and inside folders that are no map', function () {
+    $root = mapGraphicsProject();
+    $parent = sourceHashTree($root . '/assets/Maps/test-map');
+    $request = mapIdentityHost($root);
+
+    $nested = $request('map.create', ['name' => 'Wine Cellar', 'width' => 3, 'height' => 2, 'id' => 'test-map/cellar'])['result'];
+    $deep = $request('map.create', ['width' => 2, 'height' => 2, 'id' => 'harbour/licensing-office/front-desk'])['result'];
+    $derived = $request('map.create', ['name' => 'Wine Cellar', 'width' => 2, 'height' => 2])['result'];
+
+    expect($nested['map'])->toBe('test-map/cellar')
+        ->and($deep['map'])->toBe('harbour/licensing-office/front-desk')
+        // Without an id the map still goes at the root under its name's slug.
+        ->and($derived['map'])->toBe('wine-cellar')
+        ->and(is_file($root . '/assets/Maps/test-map/cellar/cellar.data.php'))->toBeTrue()
+        ->and(is_file($root . '/assets/Maps/harbour/licensing-office/front-desk/front-desk.data.php'))->toBeTrue()
+        ->and(array_column($request('maps.list')['result'], 'name', 'id'))->toMatchArray([
+            'test-map/cellar' => 'Wine Cellar', 'harbour/licensing-office/front-desk' => 'Front Desk', 'test-map' => 'Test Map'])
+        // The parent map's own files are untouched; only the nested folder is new.
+        ->and(array_filter(sourceHashTree($root . '/assets/Maps/test-map'),
+            static fn(string $path): bool => ! str_starts_with($path, 'cellar/'), ARRAY_FILTER_USE_KEY))->toBe($parent);
+
+    // A fresh scan finds every map where it was written, the parent still whole.
+    $loaded = ProjectWorkspace::fromProject($root);
+    expect($loaded->mapIds)->toContain('test-map', 'test-map/cellar', 'harbour/licensing-office/front-desk', 'wine-cellar')
+        ->and(array_find($loaded->maps, static fn($map): bool => $map->mapId === 'test-map')->getGridSourceIssue())->toBeNull()
+        ->and(array_find($loaded->maps, static fn($map): bool => $map->mapId === 'test-map/cellar')->getDisplayName())->toBe('Wine Cellar');
+});
+
+it('refuses a chosen id that is no free map folder path before writing anything', function (string $id, string $reason) {
+    $root = mapGraphicsProject();
+    file_put_contents($root . '/assets/Maps/notes.txt', 'Author notes');
+    $session = EditorSession::open($root);
+    $before = sourceHashTree($root);
+
+    expect(fn() => $session->createMap('Room', null, 2, 2, $id))->toThrow(SessionRefusal::class, $reason)
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and(array_column($session->describeMaps(), 'id'))->toBe(['test-map']);
+})->with([
+    'climbing out' => ['../outside', 'is not a folder path under assets/Maps'],
+    'absolute' => ['/tmp/room', 'is not a folder path under assets/Maps'],
+    'empty segment' => ['town//room', 'is not a folder path under assets/Maps'],
+    'capitals' => ['Town/Room', 'is not a folder path under assets/Maps'],
+    'dot folder' => ['town/.room', 'is not a folder path under assets/Maps'],
+    'backslash' => ['town\\room', 'is not a folder path under assets/Maps'],
+    'blank' => ['  ', 'A map id cannot be empty.'],
+    'an existing map' => ['test-map', 'Map path test-map already exists.'],
+    'a map\'s layers' => ['test-map/layers', 'in the layers folder of the map at test-map'],
+    'a map\'s graphics' => ['test-map/graphics', 'in the graphics folder of the map at test-map'],
+    'a file' => ['notes.txt/room', 'is not a folder path'],
+]);
+
+it('refuses a chosen id that runs through a file or lands on one', function () {
+    $root = mapGraphicsProject();
+    file_put_contents($root . '/assets/Maps/annex', 'Not a folder');
+    $session = EditorSession::open($root);
+    $before = sourceHashTree($root);
+
+    expect(fn() => $session->createMap('Room', null, 2, 2, 'annex'))->toThrow(SessionRefusal::class, 'is a file')
+        ->and(fn() => $session->createMap('Room', null, 2, 2, 'annex/room'))->toThrow(SessionRefusal::class, 'passes through a file, annex')
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('copies a map to a chosen id and refuses a taken one', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $session->createMap('Hall', null, 2, 2);
+    $revision = $session->readMap('test-map')['revision'];
+    $before = sourceHashTree($root);
+
+    expect(fn() => $session->duplicateMap('test-map', $revision, 'hall'))->toThrow(SessionRefusal::class, 'Map path hall already exists.')
+        ->and(fn() => $session->duplicateMap('test-map', $revision, 'hall/layers'))->toThrow(SessionRefusal::class, 'layers folder')
+        ->and(sourceHashTree($root))->toBe($before);
+
+    $request = mapIdentityHost($root);
+    $copy = $request('map.duplicate', ['map' => 'test-map', 'revision' => $request('map.read', ['map' => 'test-map'])['result']['revision'],
+        'to' => 'hall/back-room'])['result'];
+    expect($copy['map'])->toBe('hall/back-room')
+        ->and(file_get_contents($root . '/assets/Maps/hall/back-room/back-room.data.php'))->toContain('// Authored metadata remains authored.', "'Back Room'")
+        ->and(readTileRows($root . '/assets/Maps/hall/back-room/graphics/01.floor.tiles.php'))->toBe(readTileRows($root . '/assets/Maps/test-map/graphics/01.floor.tiles.php'))
+        ->and($request('map.duplicate', ['map' => 'test-map', 'revision' => 0, 'to' => 3])['error']['kind'])->toBe('request');
+});
+
+it('moves a map to a chosen id after the same review, naming references it does not migrate', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $session->createMap('Hall', null, 3, 3);
+    $data = $root . '/assets/Maps/hall/hall.data.php';
+    file_put_contents($data, str_replace("'events' => []", "'events' => ['D' => ['class' => 'Door', 'data' => ['destinationMap' => 'test-map']]]", (string) file_get_contents($data)));
+    $request = mapIdentityHost($root);
+    $revision = $request('map.read', ['map' => 'test-map'])['result']['revision'];
+    $before = sourceHashTree($root);
+
+    $question = $request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop'])['result'];
+    expect($question)->toMatchArray(['status' => 'question', 'destination' => 'hall/workshop', 'to' => 'hall/workshop', 'references' => ['event D on hall']])
+        ->and($question['warning'])->toContain('References are not migrated')
+        ->and(sourceHashTree($root))->toBe($before)
+        // The confirmation names the destination it reviewed, as a derived move's does.
+        ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop', 'confirm' => true])['error']['message'])
+            ->toContain('Review the proposed destination')
+        ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop', 'destination' => 'hall/other', 'confirm' => true])['error']['message'])
+            ->toContain('reviewed destination is not the id to move to')
+        // A destination that cannot be written is refused before any question.
+        ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall'])['error']['message'])->toContain('Map path hall already exists.')
+        ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => '../hall'])['error']['message'])->toContain('not a folder path')
+        ->and($request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'test-map'])['result']['status'])->toBe('unchanged')
+        ->and(sourceHashTree($root))->toBe($before);
+
+    $moved = $request('map.move', ['map' => 'test-map', 'revision' => $revision, 'to' => 'hall/workshop',
+        'destination' => $question['destination'], 'confirm' => true])['result'];
+    expect($moved)->toMatchArray(['status' => 'moved', 'previousMap' => 'test-map', 'map' => 'hall/workshop'])
+        ->and(file_get_contents($root . '/assets/Maps/hall/workshop/workshop.data.php'))->toContain('// Authored metadata remains authored.')
+        ->and(is_dir($root . '/assets/Maps/test-map'))->toBeFalse()
+        ->and(is_file($data))->toBeTrue();
+    $loaded = ProjectWorkspace::fromProject($root);
+    expect($loaded->mapIds)->toBe(['hall', 'hall/workshop'])
+        ->and(array_find($loaded->maps, static fn($map): bool => $map->mapId === 'hall')->getEventDefinitions()['D']['data']['destinationMap'])->toBe('test-map');
+});
+
+it('moves a nested map out of its folders and deletes one, leaving no empty folders behind', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $session->createMap('Front Desk', null, 2, 2, 'harbour/office/front-desk');
+    $session->createMap('Back Room', null, 2, 2, 'harbour/office/back-room');
+    $question = $session->moveMap('harbour/office/front-desk', $session->readMap('harbour/office/front-desk')['revision'], to: 'front-desk');
+    $session->moveMap('harbour/office/front-desk', $question['revision'], $question['destination'], true, 'front-desk');
+
+    expect(is_dir($root . '/assets/Maps/harbour/office/front-desk'))->toBeFalse()
+        ->and(is_dir($root . '/assets/Maps/harbour/office/back-room'))->toBeTrue();
+    $session->deleteMap('harbour/office/back-room');
+    expect(is_dir($root . '/assets/Maps/harbour'))->toBeFalse()
+        ->and(is_dir($root . '/assets/Maps'))->toBeTrue()
+        ->and(array_column($session->describeMaps(), 'id'))->toBe(['front-desk', 'test-map']);
+});
+
+it('takes a nested map made inside a cancelled change set away with its folders', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $before = sourceHashTree($root);
+    $session->beginChanges();
+    $session->createMap('Cellar', null, 2, 2, 'test-map/cellar');
+    $session->createMap('Gate', null, 2, 2, 'harbour/gate');
+    expect(is_dir($root . '/assets/Maps/harbour/gate'))->toBeTrue();
+
+    $session->cancelChanges();
+    expect(sourceHashTree($root))->toBe($before)
+        ->and(is_dir($root . '/assets/Maps/harbour'))->toBeFalse()
+        ->and(is_dir($root . '/assets/Maps/test-map/cellar'))->toBeFalse()
+        ->and(array_column($session->describeMaps(), 'id'))->toBe(['test-map']);
+});
diff --git a/tests/Unit/Session/MapLandmarkAuthoringTest.php b/tests/Unit/Session/MapLandmarkAuthoringTest.php
new file mode 100644
index 00000000..72e39938
--- /dev/null
+++ b/tests/Unit/Session/MapLandmarkAuthoringTest.php
@@ -0,0 +1,171 @@
+ 'event-landmark', 'marker' => 'T'], 'T');
+    expect($row['kind'])->toBe('options')
+        ->and($row['options'])->toBe(['undescribed', 'passage', 'entrance', 'exit'])
+        ->and($row['value'])->toBe('undescribed');
+    // A dialogue is no route: it can only be a building or a landmark.
+    expect(findLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'E')['options'])->toBe(['undescribed', 'building', 'landmark']);
+
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'exit', 'T');
+    expect(readLandmarkEntries($session))->toBe([['id' => 'landmark-1', 'kind' => 'exit', 'anchor' => ['event' => 'T']]])
+        ->and(file_get_contents($file))->toBe($before);
+    $session->undo();
+    expect(readLandmarkEntries($session))->toBe([]);
+    $session->redo();
+
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'name', 'index' => 0], 'Harbour Gate');
+    $session->saveMap('test-map');
+    $saved = file_get_contents($file);
+    expect($saved)->toContain('// Map-owned presentation stays authored.', "'landmarks' =>", "'Harbour Gate'");
+    $reopened = Ichiloto\Editor\Field\MapLandmarks::readMap(getSessionMap(EditorSession::open($root)))->entries;
+    expect($reopened[0]['name'])->toBe(['message' => MapLandmarkCatalog::getNameMessageKey('test-map', 'landmark-1'), 'default' => 'Harbour Gate']);
+
+    // Choosing the first option again removes the description; the route is an unnamed marker once more.
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'undescribed', 'T');
+    expect(readLandmarkEntries($session))->toBe([]);
+    expect(getSessionMap($session)->hasMapDataField(['landmarks']))->toBeFalse();
+});
+
+it('offers only unclaimed anchors and the kinds an anchor may be, keeping the id a save remembers', function () {
+    [, , $session] = createLandmarkProject();
+    $heading = findLandmarkRow($session, ['target' => 'map-landmarks']);
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']);
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']);
+    // Events first, in map order, each starting as what its marker already is.
+    expect(readLandmarkEntries($session))->toBe([
+        ['id' => 'landmark-1', 'kind' => 'passage', 'anchor' => ['event' => 'T']],
+        ['id' => 'landmark-2', 'kind' => 'landmark', 'anchor' => ['event' => 'E']],
+    ]);
+    $anchor = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1]);
+    expect($anchor['options'])->toBe(['event:E', 'object:old-tower'])
+        ->and($anchor['optionLabels'])->toBe(['Event E · Dialogue', 'World object old-tower']);
+    expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0])['options'])->toBe(['passage', 'entrance', 'exit']);
+
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 1], 'building');
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1], 'object:old-tower');
+    expect(readLandmarkEntries($session)[1])->toBe(['id' => 'landmark-2', 'kind' => 'building', 'anchor' => ['object' => 'old-tower']]);
+
+    // A transfer is a route, so moving a building onto one makes it the passage that marker already was.
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 0], 'event:E');
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1], 'event:T');
+    expect(readLandmarkEntries($session))->toBe([
+        ['id' => 'landmark-1', 'kind' => 'landmark', 'anchor' => ['event' => 'E']],
+        ['id' => 'landmark-2', 'kind' => 'passage', 'anchor' => ['event' => 'T']],
+    ]);
+    expect(fn() => applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 1], 'building'))
+        ->toThrow(SessionRefusal::class);
+    expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 0])['options'])->not->toContain('event:T');
+
+    // An object anchor's own id becomes the landmark id when it is a valid one.
+    $remove = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 1]);
+    $session->removeInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $remove['key']);
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], findLandmarkRow($session, ['target' => 'map-landmarks'])['key']);
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], findLandmarkRow($session, ['target' => 'map-landmarks'])['key']);
+    expect(array_column(readLandmarkEntries($session), 'id'))->toBe(['landmark-1', 'landmark-2', 'old-tower']);
+    expect(fn() => $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'],
+        findLandmarkRow($session, ['target' => 'map-landmarks'])['key']))->toThrow(SessionRefusal::class, 'already anchors a landmark');
+    expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'index' => 0])['kind'])->toBe('info');
+});
+
+it('keeps discovery separate from names: secrets say nothing of approach, empty conditions are removed', function () {
+    [, , $session] = createLandmarkProject();
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'building', 'E');
+    $key = static fn(string $field): array => ['target' => 'map-landmarks', 'field' => $field, 'index' => 0];
+
+    applyLandmarkRow($session, $key('approachDistance'), '5');
+    expect(readLandmarkEntries($session)[0]['approachDistance'])->toBe(5);
+    expect(fn() => applyLandmarkRow($session, $key('approachDistance'), '65'))->toThrow(SessionRefusal::class, '0 to 64 cells');
+    applyLandmarkRow($session, $key('approach'), 'false');
+    expect(readLandmarkEntries($session)[0])->toHaveKey('approachDistance', null);
+    applyLandmarkRow($session, $key('approach'), 'true');
+    expect(readLandmarkEntries($session)[0])->not->toHaveKey('approachDistance');
+    expect(findLandmarkRow($session, $key('approachDistance'))['value'])->toBe('(engine default: 3)');
+
+    applyLandmarkRow($session, $key('approachDistance'), '2');
+    applyLandmarkRow($session, $key('secret'), 'true');
+    expect(readLandmarkEntries($session)[0])->toMatchArray(['secret' => true])->not->toHaveKey('approachDistance');
+    $rows = $session->readInspector('test-map')['rows'];
+    expect(array_filter($rows, static fn(array $row): bool => in_array($row['key']['field'] ?? null, ['approach', 'approachDistance'], true)))->toBe([]);
+
+    applyLandmarkRow($session, $key('revealConditions'), 'event:heard_of_tower');
+    expect(readLandmarkEntries($session)[0]['revealConditions'])->toBe([['type' => 'event', 'name' => 'heard_of_tower']]);
+    applyLandmarkRow($session, $key('revealConditions'), '');
+    expect(readLandmarkEntries($session)[0])->not->toHaveKey('revealConditions');
+
+    applyLandmarkRow($session, $key('name'), 'Old Tower');
+    applyLandmarkRow($session, $key('nameMessage'), 'maps.harbour.tower');
+    applyLandmarkRow($session, $key('name'), 'The Old Tower');
+    expect(readLandmarkEntries($session)[0]['name'])->toBe(['message' => 'maps.harbour.tower', 'default' => 'The Old Tower']);
+    expect(fn() => applyLandmarkRow($session, $key('nameMessage'), ' '))->toThrow(SessionRefusal::class, 'needs a translation key');
+    applyLandmarkRow($session, $key('name'), '');
+    expect(readLandmarkEntries($session)[0])->not->toHaveKey('name');
+});
+
+it('refuses to delete or retype an anchor a landmark describes, until the landmark goes', function () {
+    [, , $session] = createLandmarkProject();
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'T'], 'exit', 'T');
+    $revision = $session->readMap('test-map')['revision'];
+    expect(fn() => $session->deleteEvent('test-map', $revision, 'T'))->toThrow(SessionRefusal::class, 'landmark landmark-1');
+    $type = findLandmarkRow($session, ['target' => 'event-type', 'marker' => 'T'], 'T');
+    expect(fn() => $session->applyInspector('test-map', $revision, $type['key'], 'Dialogue'))->toThrow(SessionRefusal::class, 'exit landmark landmark-1');
+
+    $heading = findLandmarkRow($session, ['target' => 'map-landmarks']);
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']);
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'anchor', 'index' => 1], 'object:old-tower');
+    expect(fn() => $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'old-tower'))
+        ->toThrow(SessionRefusal::class, 'test-map landmark landmark-2');
+
+    foreach ([1, 0] as $index) {
+        $session->removeInspectorListEntry('test-map', $session->readMap('test-map')['revision'],
+            findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => $index])['key']);
+    }
+    $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'old-tower');
+    $session->deleteEvent('test-map', $session->readMap('test-map')['revision'], 'T');
+    expect(getSessionMap($session)->getEventDefinition('T'))->toBeNull();
+});
+
+it('follows an anchor that moves without copying its cells, on ragged maps too', function () {
+    [, , $session] = createLandmarkProject(ragged: true);
+    applyLandmarkRow($session, ['target' => 'event-landmark', 'marker' => 'E'], 'landmark', 'E');
+    $session->moveEvent('test-map', $session->readMap('test-map')['revision'], 'E', -1, 0);
+    $entries = readLandmarkEntries($session);
+    expect($entries)->toBe([['id' => 'landmark-1', 'kind' => 'landmark', 'anchor' => ['event' => 'E']]]);
+    $catalog = Ichiloto\Editor\Field\MapLandmarks::validateEntries(getSessionMap($session), $entries);
+    expect($catalog->landmarks[0]->area->cells)->toBe([[2, 0]]);
+});
+
+it('reports declarations the Engine refuses, and still lets an author repair them', function () {
+    [, , $session] = createLandmarkProject("'landmarks' => [['id' => 'gate', 'kind' => 'building', 'anchor' => ['event' => 'T']]],");
+    $map = getSessionMap($session);
+    expect(mapValidatorMessages($map, ['test-map' => $map]))->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.');
+    expect(findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0])['options'])->toBe(['passage', 'entrance', 'exit']);
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'kind', 'index' => 0], 'entrance');
+    expect(mapValidatorMessages($map, ['test-map' => $map]))->not->toContain('Minimap landmarks: A transfer landmark must use passage, entrance or exit so ordinary routes remain visible.');
+});
+
+it('describes a selected world object from its own inspector, through the same rows', function () {
+    [, , $session] = createLandmarkProject();
+    $view = $session->readWorldObject('test-map', 'old-tower');
+    $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['target'] ?? null) === 'object-landmark');
+    expect($row['options'])->toBe(['undescribed', 'building', 'landmark'])
+        ->and($row['optionLabels'][0])->toBe('Not on the minimap');
+
+    $session->applyWorldObject('test-map', $view['revision'], 'old-tower', $row['key'], 'building');
+    expect(readLandmarkEntries($session))->toBe([['id' => 'old-tower', 'kind' => 'building', 'anchor' => ['object' => 'old-tower']]]);
+    $view = $session->readWorldObject('test-map', 'old-tower');
+    expect(array_find($view['rows'], static fn(array $row): bool => $row['label'] === 'Landmark')['value'])->toBe('old-tower · unnamed');
+    expect(fn() => $session->applyWorldObject('test-map', $view['revision'], 'old-tower', $row['key'], 'exit'))
+        ->toThrow(SessionRefusal::class);
+    $session->undo();
+    expect(readLandmarkEntries($session))->toBe([]);
+});
diff --git a/tests/Unit/Session/MapLandmarkIdentityTest.php b/tests/Unit/Session/MapLandmarkIdentityTest.php
new file mode 100644
index 00000000..68d27cba
--- /dev/null
+++ b/tests/Unit/Session/MapLandmarkIdentityTest.php
@@ -0,0 +1,115 @@
+ ['parent' => ['map' => 'world', 'landmark' => {$entry}]],\n",
+        (string) file_get_contents($file)));
+
+    return [$root, $file, EditorSession::open($root)];
+}
+
+it('renames a landmark and the atlas entry naming it as one undo step, saving both as authored', function () {
+    [$root, $file, $session] = createLandmarkIdentityProject();
+    $row = findLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], mapId: 'world');
+    expect($row)->toMatchArray(['kind' => 'text', 'value' => 'town-gate', 'raw' => 'town-gate', 'label' => 'Id']);
+
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $revision = $request('map.read', ['map' => 'world'])['result']['revision'];
+    $childRevision = $request('map.read', ['map' => 'test-map'])['result']['revision'];
+
+    $applied = $request('inspector.apply', ['map' => 'world', 'revision' => $revision, 'key' => $row['key'], 'value' => 'harbour-gate'])['result'];
+    expect($applied)->toMatchArray(['status' => 'applied', 'changed' => true])
+        ->and(array_keys($applied['revisions']))->toBe(['test-map'])
+        ->and($applied['revisions']['test-map'])->not->toBe($childRevision)
+        ->and($request('map.read', ['map' => 'test-map'])['result']['revision'])->toBe($applied['revisions']['test-map']);
+
+    $undone = $request('history.undo')['result'];
+    expect($undone['label'])->toBe('Landmark rename town-gate to harbour-gate')
+        ->and($undone['maps'])->toEqualCanonicalizing(['world', 'test-map']);
+    $request('history.redo');
+    $saved = $request('project.saveAll')['result'];
+    expect($saved['failures'] ?? [])->toBe([]);
+
+    $world = (string) file_get_contents($root . '/assets/Maps/world/world.data.php');
+    $child = (string) file_get_contents($file);
+    expect($world)->toContain("'harbour-gate'")->not->toContain("'town-gate'")
+        ->and($child)->toContain('// Map-owned presentation stays authored.', "'harbour-gate'")->not->toContain("'town-gate'");
+    $loaded = ProjectWorkspace::fromProject($root);
+    $testMap = array_find($loaded->maps, static fn($map): bool => $map->mapId === 'test-map');
+    expect($testMap->getMapDataField(['atlas', 'parent']))->toBe(['map' => 'world', 'landmark' => 'harbour-gate'])
+        ->and(Ichiloto\Editor\Field\MapAtlas::resolveGlobal($testMap, Ichiloto\Editor\Field\MapAtlas::readBlock($testMap),
+            static fn(string $id) => array_find($loaded->maps, static fn($map): bool => $map->mapId === $id)))
+        ->toBe(['map' => 'world', 'landmark' => 'harbour-gate']);
+});
+
+it('names a landmark that had no id, and keeps its translation key through a rename', function () {
+    [, , $session] = createLandmarkProject();
+    $heading = findLandmarkRow($session, ['target' => 'map-landmarks']);
+    $session->addInspectorListEntry('test-map', $session->readMap('test-map')['revision'], $heading['key']);
+    applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'name', 'index' => 0], 'Harbour Gate');
+    $key = readLandmarkEntries($session)[0]['name']['message'];
+
+    $applied = applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], ' harbour-gate ');
+    expect($applied)->toMatchArray(['changed' => true])->not->toHaveKey('revisions')
+        ->and(readLandmarkEntries($session)[0])->toMatchArray(['id' => 'harbour-gate', 'name' => ['message' => $key, 'default' => 'Harbour Gate']])
+        ->and(findLandmarkRow($session, ['target' => 'map-landmarks', 'index' => 0])['label'])->toBe('Landmark harbour-gate')
+        ->and(applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], 'harbour-gate')['changed'])->toBeFalse();
+});
+
+it('refuses an id the Engine would not load or another landmark has, changing nothing', function (string $id, string $reason) {
+    [, , $session] = createLandmarkIdentityProject();
+    $heading = findLandmarkRow($session, ['target' => 'map-landmarks'], mapId: 'world');
+    $session->addInspectorListEntry('world', $session->readMap('world')['revision'], $heading['key']);
+    $revision = $session->readMap('world')['revision'];
+    $entries = readLandmarkEntries($session, 'world');
+
+    expect(fn() => applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], $id, mapId: 'world'))
+        ->toThrow(SessionRefusal::class, $reason)
+        ->and(readLandmarkEntries($session, 'world'))->toBe($entries)
+        ->and($session->readMap('world')['revision'])->toBe($revision)
+        ->and(getSessionMap($session)->getMapDataField(['atlas', 'parent', 'landmark']))->toBe('town-gate');
+})->with([
+    'spaces' => ['Harbour Gate', 'is not a landmark id'],
+    'leading digit' => ['1-gate', 'is not a landmark id'],
+    'blank' => ['', 'is not a landmark id'],
+    'too long' => [str_repeat('a', 65), 'is not a landmark id'],
+    'taken' => ['landmark-1', 'Another landmark on world is already landmark-1.'],
+]);
+
+it('renames nothing when a map naming the landmark cannot take the new entry in its source', function () {
+    [$root, $file, $session] = createLandmarkIdentityProject("implode('-', ['town', 'gate'])");
+    $before = sourceHashTree($root);
+    $revision = $session->readMap('world')['revision'];
+
+    expect(fn() => applyLandmarkRow($session, ['target' => 'map-landmarks', 'field' => 'id', 'index' => 0], 'harbour-gate', mapId: 'world'))
+        ->toThrow(SessionRefusal::class, 'Landmark town-gate was not renamed')
+        ->and(readLandmarkEntries($session, 'world')[0]['id'])->toBe('town-gate')
+        ->and($session->readMap('world')['revision'])->toBe($revision)
+        ->and($session->readMap('world')['dirty'])->toBeFalse()
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse()
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($before);
+});
diff --git a/tests/Unit/Session/MapLifecycleSessionTest.php b/tests/Unit/Session/MapLifecycleSessionTest.php
new file mode 100644
index 00000000..3909497e
--- /dev/null
+++ b/tests/Unit/Session/MapLifecycleSessionTest.php
@@ -0,0 +1,199 @@
+readInspector('test-map');
+    $session->applyInspector('test-map', $inspector['revision'], ['target' => 'map', 'field' => 'name'], $name);
+}
+
+function readLifecycleGlyph(array $map, string $layer, int $x, int $y): string
+{
+    return array_find($map['layers'], static fn(array $entry): bool => $entry['id'] === $layer)['rows'][$y][$x];
+}
+
+it('duplicates all current layers and unsaved tiles without changing the original or its undo history', function () {
+    $root = mapGraphicsProject();
+    $original = $root . '/assets/Maps/test-map';
+    $before = sourceHashTree($original);
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#');
+    $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5);
+    $session->createMap('Cellar', null, 3, 3);
+    $other = $session->readMap('cellar');
+    $session->paint('cellar', $other['revision'], $other['baseLayer'], [[0, 0]], '%');
+
+    $created = $session->duplicateMap('test-map', $session->readMap('test-map')['revision']);
+    $copy = $session->readMap($created['map']);
+    expect($created['map'])->toBe('test-map-copy')
+        ->and(array_column($created['maps'], 'id'))->toContain('test-map', 'test-map-copy', 'cellar')
+        ->and($copy['dirty'])->toBeFalse()
+        ->and(readLifecycleGlyph($copy, $copy['baseLayer'], 0, 0))->toBe('#')
+        ->and(readTileRows($root . '/assets/Maps/test-map-copy/graphics/01.floor.tiles.php')[1][1])->toBe(5)
+        ->and(array_column($copy['layers'], 'id'))->toBe(array_column($map['layers'], 'id'))
+        ->and(file_get_contents($root . '/assets/Maps/test-map-copy/test-map-copy.data.php'))->toContain('// Authored metadata remains authored.')
+        ->and(file_get_contents($root . '/assets/Maps/test-map-copy/graphics/01.floor.tiles.php'))->toContain('// Painted in the GUI editor.')
+        ->and(sourceHashTree($original))->toBe($before)
+        ->and($session->readMap('test-map')['dirty'])->toBeTrue()
+        ->and($session->readMap('cellar')['dirty'])->toBeTrue()
+        ->and($session->undo()['maps'])->toBe(['cellar'])
+        ->and($session->undo()['label'])->toBe('Place tiles')
+        ->and(readTileRows($root . '/assets/Maps/test-map-copy/graphics/01.floor.tiles.php')[1][1])->toBe(5)
+        ->and($session->duplicateMap('test-map', $session->readMap('test-map')['revision'])['map'])->toBe('test-map-copy-2');
+});
+
+it('refuses stale copy requests without writing any files', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#');
+    $before = sourceHashTree($root);
+    expect(fn() => $session->duplicateMap('test-map', $map['revision']))->toThrow(SessionRefusal::class, 'changed since revision')
+        ->and(fn() => $session->duplicateMap('missing', 0))->toThrow(SessionRefusal::class, 'There is no map')
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('asks for an explicit derived-path move and does not move on an ordinary save', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    renameLifecycleMap($session, 'Harbour Workshop');
+    $map = $session->readMap('test-map');
+    $before = sourceHashTree($root);
+    $question = $session->moveMap('test-map', $map['revision']);
+    expect($question)->toMatchArray(['status' => 'question', 'map' => 'test-map', 'revision' => $map['revision'], 'destination' => 'harbour-workshop'])
+        ->and($question['warning'])->toContain('References are not migrated', 'Moving clears the undo history')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and(fn() => $session->moveMap('test-map', $map['revision'], confirmed: true))->toThrow(SessionRefusal::class, 'Review the proposed destination')
+        ->and(fn() => $session->moveMap('test-map', $map['revision'], '../outside', true))->toThrow(SessionRefusal::class, 'derived destination changed')
+        ->and($session->saveMap('test-map')['saved'])->toBe('test-map')
+        ->and(is_dir($root . '/assets/Maps/harbour-workshop'))->toBeFalse();
+});
+
+it('rejects a previously reviewed move when the map or destination changed', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    renameLifecycleMap($session, 'Workshop');
+    $question = $session->moveMap('test-map', $session->readMap('test-map')['revision']);
+    renameLifecycleMap($session, 'New Workshop');
+    $before = sourceHashTree($root);
+    expect(fn() => $session->moveMap('test-map', $question['revision'], $question['destination'], true))
+        ->toThrow(SessionRefusal::class, 'changed since revision')
+        ->and(fn() => $session->moveMap('test-map', $session->readMap('test-map')['revision'], $question['destination'], true))
+            ->toThrow(SessionRefusal::class, 'derived destination changed')
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('moves the authored map transactionally, keeps unrelated work and notes, and explicitly leaves references unchanged', function () {
+    $root = mapGraphicsProject();
+    $original = $root . '/assets/Maps/test-map';
+    file_put_contents($original . '/notes.txt', 'Author-owned notes');
+    $session = EditorSession::open($root);
+    $session->createMap('Cellar', null, 3, 3);
+    $data = $root . '/assets/Maps/cellar/cellar.data.php';
+    file_put_contents($data, str_replace("'events' => []", "'events' => ['D' => ['class' => 'Door', 'data' => ['destinationMap' => 'test-map']]]", (string) file_get_contents($data)));
+    $session = EditorSession::open($root);
+    $other = $session->readMap('cellar');
+    $session->paint('cellar', $other['revision'], $other['baseLayer'], [[0, 0]], '%');
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#');
+    $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[1, 1]], 5);
+    renameLifecycleMap($session, 'Harbour Workshop');
+    $question = $session->moveMap('test-map', $session->readMap('test-map')['revision']);
+    expect($question['references'])->toBe(['event D on cellar']);
+
+    $moved = $session->moveMap('test-map', $question['revision'], $question['destination'], true);
+    $destination = $root . '/assets/Maps/harbour-workshop';
+    $current = $session->readMap('harbour-workshop');
+    expect($moved)->toMatchArray(['status' => 'moved', 'previousMap' => 'test-map', 'map' => 'harbour-workshop'])
+        ->and(array_column($moved['maps'], 'id'))->not->toContain('test-map')
+        ->and($current['dirty'])->toBeFalse()
+        ->and(readLifecycleGlyph($current, $current['baseLayer'], 0, 0))->toBe('#')
+        ->and(readTileRows($destination . '/graphics/01.floor.tiles.php')[1][1])->toBe(5)
+        ->and(file_get_contents($destination . '/harbour-workshop.data.php'))->toContain('// Authored metadata remains authored.')
+        ->and(file_get_contents($original . '/notes.txt'))->toBe('Author-owned notes')
+        ->and(is_file($original . '/test-map.data.php'))->toBeFalse()
+        ->and($session->readMap('cellar')['dirty'])->toBeTrue()
+        ->and($session->undo())->toMatchArray(['label' => null, 'maps' => []])
+        ->and(fn() => $session->readMap('test-map'))->toThrow(SessionRefusal::class, 'There is no map');
+    $loaded = ProjectWorkspace::fromProject($root);
+    $cellar = array_find($loaded->maps, static fn($map): bool => $map->mapId === 'cellar');
+    expect($cellar->getEventDefinitions()['D']['data']['destinationMap'])->toBe('test-map');
+});
+
+it('refuses a colliding destination and keeps undo and all sources intact', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $session->createMap('Workshop', null, 3, 3);
+    renameLifecycleMap($session, 'Workshop');
+    $map = $session->readMap('test-map');
+    $before = sourceHashTree($root);
+    expect(fn() => $session->moveMap('test-map', $map['revision'], 'workshop', true))
+        ->toThrow(SessionRefusal::class, 'already exists')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->undo()['maps'])->toBe(['test-map']);
+});
+
+it('keeps an unchanged derived path as a no-op without clearing undo', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], $map['baseLayer'], [[0, 0]], '#');
+    $before = sourceHashTree($root);
+    expect($session->moveMap('test-map', $session->readMap('test-map')['revision'])['status'])->toBe('unchanged')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->undo()['label'])->toBe('Paint');
+});
+
+it('refuses external source edits across save, copy and move without overwriting them', function (string $member, string $operation) {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    renameLifecycleMap($session, 'Workshop');
+    $path = $root . '/assets/Maps/test-map/' . $member;
+    file_put_contents($path, (string) file_get_contents($path) . "\n// Changed outside the editor.\n");
+    $before = sourceHashTree($root);
+    $revision = $session->readMap('test-map')['revision'];
+    $execute = match ($operation) {
+        'save' => fn() => $session->saveMap('test-map'),
+        'copy' => fn() => $session->duplicateMap('test-map', $revision),
+        'move' => fn() => $session->moveMap('test-map', $revision, 'workshop', true),
+    };
+    expect($execute)->toThrow(SessionRefusal::class, 'changed')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->readMap('test-map')['dirty'])->toBeTrue();
+})->with(['test-map.data.php', 'layers/01.terrain.map.php', 'graphics/01.floor.tiles.php'])->with(['save', 'copy', 'move']);
+
+it('checks source freshness even when an otherwise clean map would not need writing', function () {
+    $root = mapGraphicsProject();
+    $session = EditorSession::open($root);
+    $session->readMap('test-map');
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($path, (string) file_get_contents($path) . "\n// Changed outside the editor.\n");
+    $before = sourceHashTree($root);
+    expect(fn() => $session->saveMap('test-map'))->toThrow(SessionRefusal::class, 'changed after opening')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse();
+});
+
+it('exposes duplication and reviewed relocation through the session protocol', function () {
+    $root = mapGraphicsProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result');
+    $map = $request('map.read', ['map' => 'test-map'])['result'];
+    expect($request('map.duplicate', ['map' => 'test-map', 'revision' => 'bad'])['error']['kind'])->toBe('request')
+        ->and($request('map.duplicate', ['map' => 'test-map', 'revision' => $map['revision']])['result']['map'])->toBe('test-map-copy');
+    $changed = $request('inspector.apply', ['map' => 'test-map', 'revision' => $map['revision'],
+        'key' => ['target' => 'map', 'field' => 'name'], 'value' => 'Workshop'])['result'];
+    $question = $request('map.move', ['map' => 'test-map', 'revision' => $changed['revision']])['result'];
+    expect($question['status'])->toBe('question')
+        ->and($request('map.move', ['map' => 'test-map', 'revision' => $question['revision'], 'destination' => 4])['error']['kind'])->toBe('request')
+        ->and($request('map.move', ['map' => 'test-map', 'revision' => $question['revision'], 'destination' => $question['destination'], 'confirm' => true])['result']['status'])->toBe('moved');
+});
diff --git a/tests/Unit/Session/MapLineInsertionSessionTest.php b/tests/Unit/Session/MapLineInsertionSessionTest.php
new file mode 100644
index 00000000..63af1895
--- /dev/null
+++ b/tests/Unit/Session/MapLineInsertionSessionTest.php
@@ -0,0 +1,297 @@
+ [],", <<<'PHP'
+'station' => ['x' => 2, 'y' => 1],
+    'tileLayers' => ['floor' => ['offset' => [0, 1]]],
+    'npcs' => [['id' => 'visitor', 'name' => 'Visitor', 'sprite' => 'v', 'x' => 2, 'y' => 1,
+        'movement' => 'wander', 'wanderArea' => ['x' => 1, 'y' => 0, 'width' => 2, 'height' => 2]]],
+    'events' => ['D' => ['class' => 'Ichiloto\Engine\Events\Triggers\SleepEventTrigger', 'data' => ['spawnPoint' => ['x' => 2, 'y' => 1], 'spawnSprite' => ['South'], 'cost' => 0, 'confirmDialogue' => ['text' => 'Rest?']]]],
+PHP, (string) file_get_contents($path)));
+    file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource("    \n D  ", 'EVENTS', "// Authored event notes.\n"));
+    $other = $root . '/assets/Maps/approach';
+    mkdir($other, 0o777, true);
+    file_put_contents($other . '/approach.map.php', MapGridSource::buildSource("....\n....", 'MAP'));
+    file_put_contents($other . '/approach.event.php', MapGridSource::buildSource(" D  \n    ", 'EVENTS'));
+    file_put_contents($other . '/approach.data.php', <<<'PHP'
+ 'Approach', 'region' => '', 'events' => ['D' => [
+    'class' => 'Ichiloto\Engine\Events\Triggers\TransferPlayerTrigger',
+    'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1], 'spawnSprite' => ['South']],
+]]];
+PHP);
+    file_put_contents($root . '/assets/Events/arrival.php', <<<'PHP'
+ 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1],
+    ['type' => 'move_player', 'x' => 3, 'y' => 1],
+];
+PHP);
+    $cinematic = $root . '/assets/Cutscenes/Cinematics/arrival';
+    mkdir($cinematic, 0o777, true);
+    file_put_contents($cinematic . '/arrival.data.php', <<<'PHP'
+ 'arrival', 'name' => 'Arrival', 'startMap' => 'test-map',
+    'cast' => [['id' => 'visitor', 'sprite' => 'v', 'x' => 2, 'y' => 1]]];
+PHP);
+    file_put_contents($cinematic . '/arrival.script.php', <<<'PHP'
+ 'move_player', 'x' => 3, 'y' => 1]];
+PHP);
+    $system = $root . '/assets/Data/system.php';
+    $data = ['startingPositions' => ['player' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 2, 'y' => 1], 'spawnSprite' => ['South']]]];
+    file_put_contents($system, " 0, 'migrations' => [], 'aliases' => [], 'tombstones' => []];\n");
+    // Known collision, so the player reaches test-map from the start and saves can stand there to move.
+    file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::NONE, ' ' => CollisionType::NONE, '/' => CollisionType::NONE, 'x' => CollisionType::NONE];\n");
+
+    return $root;
+}
+
+function writeReviewedMapInsertion(EditorSession $session, array $question): array
+{
+    return $session->insertMapLines($question['map'], $question['revision'], $question['axis'], $question['at'], $question['count'], 'write', $question['confirm']);
+}
+
+it('reviews the shared complete insertion without writing and cancels without losing undo or copied content', function () {
+    $root = mapInsertionSessionProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $clipboard = $session->copySelection('test-map', $map['baseLayer'], 0, 0, 1, 1);
+    $before = sourceHashTree($root);
+    $question = $session->insertMapLines('test-map', $map['revision'], 'y', 1, 2);
+    expect($question)->toMatchArray(['status' => 'question', 'map' => 'test-map', 'revision' => $map['revision'],
+        'axis' => 'y', 'at' => 1, 'count' => 2, 'width' => 4, 'height' => 4])
+        ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'write'])
+        ->and($question['answers'][1]['description'])->toContain('now, not on Save', 'Undo restores')
+        ->and($question['paths'])->toContain('assets/Maps/test-map/test-map.event.php', 'assets/Maps/approach/approach.data.php',
+            'assets/Events/arrival.php', 'assets/Data/system.php', 'assets/Data/save-compatibility.php',
+            'assets/Cutscenes/Cinematics/arrival/arrival.data.php', 'assets/Cutscenes/Cinematics/arrival/arrival.script.php')
+        ->and($question['notes'][0])->toContain('Saves move with the map')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->insertMapLines('test-map', $map['revision'], 'y', 1, 2, 'cancel')['status'])->toBe('cancelled')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->describeClipboard())->toBe($clipboard)
+        ->and($session->undo()['label'])->toBeNull();
+});
+
+it('writes all coordinate owners together, reloads every document and reverses the source set through undo and redo', function (string $axis) {
+    $root = mapInsertionSessionProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $other = $session->readMap('approach');
+    $before = sourceHashTree($root);
+    $question = $session->insertMapLines('test-map', $map['revision'], $axis, 1, 2);
+    $result = writeReviewedMapInsertion($session, $question);
+    $after = sourceHashTree($root);
+    $data = require $root . '/assets/Maps/test-map/test-map.data.php';
+    $point = $axis === 'x' ? ['x' => 4, 'y' => 1] : ['x' => 2, 'y' => 3];
+    $loaded = $session->readMap('test-map');
+    $eventLayer = array_find($loaded['layers'], static fn(array $layer): bool => $layer['event']);
+    expect($result)->toMatchArray(['status' => 'inserted', 'map' => 'test-map', 'changed' => true])
+        ->and($result['revision'])->toBeGreaterThan($map['revision'])
+        ->and($session->readMap('approach')['revision'])->toBeGreaterThan($other['revision'])
+        ->and([$loaded['width'], $loaded['height']])->toBe([$question['width'], $question['height']])
+        ->and($loaded['dirty'])->toBeFalse()
+        ->and($data['npcs'][0])->toMatchArray($point)
+        ->and($data['npcs'][0]['wanderArea'])->toBe($axis === 'x'
+            ? ['x' => 3, 'y' => 0, 'width' => 2, 'height' => 2]
+            : ['x' => 1, 'y' => 0, 'width' => 2, 'height' => 4])
+        ->and($data['events']['D']['data']['spawnPoint'])->toBe($point)
+        ->and($data['station'])->toBe(['x' => 2, 'y' => 1])
+        ->and($data['tileLayers'])->toBe(['floor' => ['offset' => [0, 1]]])
+        ->and((require $root . '/assets/Maps/approach/approach.data.php')['events']['D']['data']['spawnPoint'])->toBe($point)
+        ->and((require $root . '/assets/Data/system.php')['startingPositions']['player']['spawnPoint'])->toBe($point)
+        ->and((require $root . '/assets/Cutscenes/Cinematics/arrival/arrival.data.php')['cast'][0])->toMatchArray($point)
+        ->and((require $root . '/assets/Events/arrival.php')[0])->toMatchArray($point)
+        ->and((require $root . '/assets/Data/save-compatibility.php')['migrations'][0]['mapShifts'][0])
+            ->toBe(['map' => 'test-map', 'axis' => $axis, 'at' => 1, 'by' => 2])
+        ->and($eventLayer)->not->toBeNull()
+        ->and($eventLayer['rows'][$axis === 'x' ? 1 : 3][$axis === 'x' ? 3 : 1])->toBe('D')
+        ->and(readTileRows($root . '/assets/Maps/test-map/graphics/01.floor.tiles.php')[$axis === 'x' ? 0 : 1][$axis === 'x' ? 1 : 0])->toBe(0)
+        ->and(file_get_contents($root . '/assets/Maps/approach/approach.data.php'))->toContain("// Keep this author's comment.")
+        ->and(fn() => writeReviewedMapInsertion($session, $question))->toThrow(SessionRefusal::class, 'changed since revision');
+    $undo = $session->undo();
+    expect($undo['maps'])->toContain('test-map', 'approach')
+        ->and($undo['databases'])->not->toBeEmpty()
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->readMap('test-map')['revision'])->toBeGreaterThan($result['revision']);
+    expect($session->redo()['label'])->toBe('Insert ' . ($axis === 'x' ? 'columns' : 'rows'))
+        ->and(sourceHashTree($root))->toBe($after)
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse();
+})->with(['x', 'y']);
+
+it('keeps copied glyphs, styles and relative tiles usable after insertion, undo and redo', function (string $axis, string $state) {
+    $root = mapGraphicsProject();
+    writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "5 6 0 0\n0 0 0 0");
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($path, str_replace("'events' => [],", "'events' => [], 'tileLayers' => ['floor' => ['movesWith' => 'terrain']],", (string) file_get_contents($path)));
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $layerId = $map['baseLayer'];
+    $sourceLayer = array_find($map['layers'], static fn(array $layer): bool => $layer['id'] === $layerId);
+    $sourceColors = array_column(array_filter($sourceLayer['colors'], static fn(array $cell): bool => $cell[1] === 0 && $cell[0] < 2), 2);
+    $clipboard = $session->copySelection('test-map', $layerId, 0, 0, 2, 1);
+    $before = sourceHashTree($root);
+    $question = $session->insertMapLines('test-map', $map['revision'], $axis, 1, 2);
+    writeReviewedMapInsertion($session, $question);
+    $after = sourceHashTree($root);
+
+    if ($state !== 'inserted') {
+        $session->undo();
+        expect(sourceHashTree($root))->toBe($before);
+    }
+    if ($state === 'redone') {
+        $session->redo();
+        expect(sourceHashTree($root))->toBe($after);
+    }
+    $x = $state !== 'undone' && $axis === 'x' ? 1 : 0;
+    $y = $state !== 'undone' && $axis === 'x' ? 0 : 1;
+    expect($session->describeClipboard())->toBe($clipboard);
+    $pasted = $session->pasteSelection('test-map', $session->readMap('test-map')['revision'], $layerId, $x, $y);
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === $layerId);
+    $colors = array_column(array_filter($layer['colors'], static fn(array $cell): bool => $cell[1] === $y && $cell[0] >= $x && $cell[0] < $x + 2), 2);
+    $tiles = array_column($session->readTiles('test-map')['layers'], null, 'name');
+    expect($pasted)->toMatchArray(['status' => 'applied', 'changed' => 2])
+        ->and(array_slice($layer['rows'][$y], $x, 2))->toBe(array_slice($sourceLayer['rows'][0], 0, 2))
+        ->and($colors)->toBe($sourceColors)
+        ->and(array_slice($tiles['floor']['rows'][$y], $x, 2))->toBe([5, 6])
+        ->and($session->undo()['label'])->toBe('Paste selection')
+        ->and($session->describeClipboard())->toBe($clipboard)
+        ->and(sourceHashTree($root))->toBe($state === 'undone' ? $before : $after);
+})->with(['x', 'y'])->with(['inserted', 'undone', 'redone']);
+
+it('refuses changed choices and unreviewed writes without changing a file', function () {
+    $root = mapInsertionSessionProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $question = $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1);
+    $before = sourceHashTree($root);
+    expect(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1, 'write'))->toThrow(SessionRefusal::class, 'files or choices changed')
+        ->and(fn() => $session->insertMapLines('test-map', $map['revision'], 'x', 1, 1, 'write', $question['confirm']))->toThrow(SessionRefusal::class, 'files or choices changed')
+        ->and(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 2, 'write', $question['confirm']))->toThrow(SessionRefusal::class, 'files or choices changed')
+        ->and(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1, 'yes', $question['confirm']))->toThrow(SessionRefusal::class, 'Answer write or cancel')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull();
+});
+
+it('refuses unsaved work in any map before both review and confirmed writes', function () {
+    $root = mapInsertionSessionProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('test-map');
+    $question = $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1);
+    $other = $session->readMap('approach');
+    $session->paint('approach', $other['revision'], $other['baseLayer'], [[0, 0]], '#');
+    $before = sourceHashTree($root);
+    expect(fn() => $session->insertMapLines('test-map', $map['revision'], 'y', 1, 1))->toThrow(SessionRefusal::class, 'Save or undo pending')
+        ->and(fn() => writeReviewedMapInsertion($session, $question))->toThrow(SessionRefusal::class, 'Save or undo pending')
+        ->and($session->readMap('approach')['dirty'])->toBeTrue()
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->undo()['label'])->toBe('Paint');
+});
+
+it('refuses external changes after review, including newly discovered references', function (bool $newFile) {
+    $root = mapInsertionSessionProject();
+    $session = EditorSession::open($root);
+    $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 1);
+    $path = $root . '/assets/Events/' . ($newFile ? 'new-reference' : 'arrival') . '.php';
+    if ($newFile) {
+        file_put_contents($path, " 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1]];\n");
+    } else {
+        file_put_contents($path, (string) file_get_contents($path) . "\n// Another writer's change.\n");
+    }
+    $before = sourceHashTree($root);
+    expect(fn() => writeReviewedMapInsertion($session, $question))->toThrow(SessionRefusal::class, 'files or choices changed')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull();
+})->with([false, true]);
+
+it('preserves external edits when undoing an applied insertion is no longer safe', function () {
+    $root = mapInsertionSessionProject();
+    $session = EditorSession::open($root);
+    $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 1);
+    writeReviewedMapInsertion($session, $question);
+    $path = $root . '/assets/Events/arrival.php';
+    file_put_contents($path, (string) file_get_contents($path) . "\n// Another writer's change.\n");
+    $before = sourceHashTree($root);
+    expect(fn() => $session->undo())->toThrow(RuntimeException::class, 'changed outside')
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('reports expressions needing hand edits and never flattens their source', function () {
+    $root = mapInsertionSessionProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($path, str_replace("'y' => 1,", "'y' => 0 + 1,", (string) file_get_contents($path)));
+    $session = EditorSession::open($root);
+    $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 1);
+    expect(array_column($question['handEdits'], 'where'))->toContain('npcs.0.y');
+    writeReviewedMapInsertion($session, $question);
+    expect(file_get_contents($path))->toContain("'y' => 0 + 1,");
+});
+
+it('exposes strict request validation, review, cancel, apply and undo through the actual host', function () {
+    $root = mapInsertionSessionProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array => $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $map = $request('map.read', ['map' => 'test-map'])['result'];
+    $params = ['map' => 'test-map', 'revision' => $map['revision'], 'axis' => 'y', 'at' => 1, 'count' => 1];
+    $before = sourceHashTree($root);
+    foreach (['revision', 'at', 'count', 'axis', 'confirm', 'answer'] as $field) {
+        expect($request('map.insertLines', [...$params, $field => []])['error']['kind'])->toBe('request');
+    }
+    expect($request('map.insertLines', [...$params, 'axis' => 'z'])['error']['kind'])->toBe('refusal')
+        ->and($request('map.insertLines', [...$params, 'at' => 99])['error']['kind'])->toBe('refusal')
+        ->and($request('map.insertLines', [...$params, 'count' => 0])['error']['kind'])->toBe('refusal');
+    $question = $request('map.insertLines', $params)['result'];
+    expect($question['status'])->toBe('question')
+        ->and($request('map.insertLines', [...$params, 'answer' => 'cancel'])['result']['status'])->toBe('cancelled')
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($request('map.insertLines', [...$params, 'answer' => 'write', 'confirm' => $question['confirm']])['result']['status'])->toBe('inserted')
+        ->and($request('history.undo')['result']['label'])->toBe('Insert row')
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('moves coordinates in event scripts reached only through another event script the map runs', function () {
+    $root = mapInsertionSessionProject();
+    $directory = $root . '/assets/Maps/test-map';
+    $path = $directory . '/test-map.data.php';
+    file_put_contents($path, str_replace("'events' => ['D' =>", <<<'PHP'
+'events' => ['E' => ['class' => 'Ichiloto\Engine\Events\Triggers\ScriptEventTrigger', 'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'outer']],
+        'D' =>
+PHP, (string) file_get_contents($path)));
+    file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource("  E \n D  ", 'EVENTS', "// Authored event notes.\n"));
+    file_put_contents($root . '/assets/Events/outer.php', " 'common_event', 'id' => 'inner']];\n");
+    // The inner script runs the outer one again; following them must still end.
+    file_put_contents($root . '/assets/Events/inner.php', <<<'PHP'
+ 'move_player', 'x' => 3, 'y' => 1],
+    ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'visitor'], 'offset' => ['x' => 1]]]],
+    ['type' => 'common_event', 'id' => 'outer'],
+];
+PHP);
+    $session = EditorSession::open($root);
+    $question = $session->insertMapLines('test-map', $session->readMap('test-map')['revision'], 'y', 1, 2);
+
+    expect($question['paths'])->toContain('assets/Events/inner.php')
+        ->and(writeReviewedMapInsertion($session, $question)['status'])->toBe('inserted')
+        ->and(require $root . '/assets/Events/inner.php')->toBe([
+            ['type' => 'move_player', 'x' => 3, 'y' => 3],
+            // A position relative to a subject moves with its subject and is never rewritten.
+            ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['at' => ['kind' => 'npc', 'id' => 'visitor'], 'offset' => ['x' => 1]]]],
+            ['type' => 'common_event', 'id' => 'outer'],
+        ]);
+});
diff --git a/tests/Unit/Session/MapLineRemovalSessionTest.php b/tests/Unit/Session/MapLineRemovalSessionTest.php
new file mode 100644
index 00000000..633a557a
--- /dev/null
+++ b/tests/Unit/Session/MapLineRemovalSessionTest.php
@@ -0,0 +1,73 @@
+readMap('home')['revision'];
+    $before = sourceHashTree($root);
+
+    $question = $session->removeMapLines('home', $revision, 'y', 2, 1);
+    expect($question)->toMatchArray(['status' => 'question', 'map' => 'home', 'axis' => 'y', 'at' => 2, 'count' => 1,
+        'landing' => [2, 1], 'width' => 6, 'height' => 4])
+        ->and($question['question'])->toBe('Remove 1 row from row 3 of home?')
+        ->and($question['notes'])->toContain('Saves standing on the removed lines land at x 2, y 1.')
+        ->and($question['paths'])->toContain('assets/Maps/home/layers/01.terrain.map.php', 'assets/Data/save-compatibility.php')
+        ->and(sourceHashTree($root))->toBe($before);
+
+    $written = $session->removeMapLines('home', $revision, 'y', 2, 1, $question['landing'], 'write', $question['confirm']);
+    expect($written)->toMatchArray(['status' => 'removed', 'map' => 'home', 'changed' => true])
+        ->and($session->readMap('home')['height'])->toBe(4);
+
+    $session->undo();
+    expect(sourceHashTree($root))->toBe($before)
+        ->and($session->readMap('home')['height'])->toBe(5);
+});
+
+it('refuses a removal that something authored stands on, and one the review did not cover', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    $revision = $session->readMap('home')['revision'];
+    $before = sourceHashTree($root);
+
+    expect(fn() => $session->removeMapLines('home', $revision, 'y', 4, 1))->toThrow(SessionRefusal::class, 'is refused; nothing was changed.');
+    $question = $session->removeMapLines('home', $revision, 'y', 2, 1);
+    expect(fn() => $session->removeMapLines('home', $revision, 'y', 2, 1, [3, 2], 'write', $question['confirm']))->toThrow(SessionRefusal::class)
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('routes map.removeLines through the host with an optional landing', function () {
+    $root = mapLineRemovalProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w+'), fopen('php://memory', 'w+'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result');
+    $revision = $request('map.read', ['map' => 'home'])['result']['revision'];
+
+    expect($request('map.removeLines', ['map' => 'home', 'revision' => $revision, 'axis' => 'y', 'at' => 2, 'count' => 1, 'landing' => [3, 2]])['result'])
+        ->toMatchArray(['status' => 'question', 'landing' => [3, 2]]);
+});
+
+it('moves no save when the player reaches the map nowhere, such as one not yet joined to the world', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    $session->createMap('Far field', null, 6, 5, 'far-field');
+    $manifest = (string) file_get_contents($root . '/assets/Data/save-compatibility.php');
+    $revision = $session->readMap('far-field')['revision'];
+
+    $question = $session->removeMapLines('far-field', $revision, 'x', 0, 2);
+    expect($question)->toMatchArray(['status' => 'question', 'landing' => null])
+        ->and($question['notes'])->toContain('No save moves: the player reaches this map nowhere today.')
+        ->and($question['paths'])->not->toContain('assets/Data/save-compatibility.php');
+
+    $session->removeMapLines('far-field', $revision, 'x', 0, 2, null, 'write', $question['confirm']);
+    expect($session->readMap('far-field')['width'])->toBe(4)
+        ->and((string) file_get_contents($root . '/assets/Data/save-compatibility.php'))->toBe($manifest);
+});
diff --git a/tests/Unit/Session/MapPlacementSessionTest.php b/tests/Unit/Session/MapPlacementSessionTest.php
new file mode 100644
index 00000000..9107f6b4
--- /dev/null
+++ b/tests/Unit/Session/MapPlacementSessionTest.php
@@ -0,0 +1,492 @@
+ 'guide', 'name' => 'Guide', 'x' => 1, 'y' => 1, 'script' => [
+        ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', 'wait' => true, 'steps' => [
+            ['direction' => 'right', 'count' => 2, 'faceOnly' => false, 'seconds' => 0.25, 'custom' => 'preserved'],
+            ['direction' => 'up', 'count' => 3, 'faceOnly' => true],
+        ]],
+        ['type' => 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1],
+    ]]];
+    file_put_contents($path, " ($row['mapPlacement']['kind'] ?? null) === $kind)
+        ?? throw new RuntimeException('Missing placement ' . $kind);
+}
+
+it('edits NPC route endpoints as one source preserving undo step without changing timing facing or identity', function () {
+    $root = mapPlacementProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $original = file_get_contents($path);
+    $session = EditorSession::open($root);
+    $read = $session->readNpc('test-map', 0, ['script']);
+    $row = findMapPlacementRow($read, 'route');
+    expect($row['mapPlacement']['points'][0]['point'])->toBe([2, 0])
+        ->and($row['mapPlacement']['points'][1]['point'])->toBe([2, 0]);
+    $context = ['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0];
+    $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 3, [1, 1], $read['revision']);
+    $changed = findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'route');
+    expect($changed['mapPlacement']['points'][0]['point'])->toBe([0, 2])
+        ->and($session->undo()['label'])->toBe('Place on map');
+    $session->saveMap('test-map');
+    expect(file_get_contents($path))->toBe($original);
+    $session->redo();
+    $session->saveMap('test-map');
+    $saved = require $path;
+    expect(file_get_contents($path))->toContain('// Authored map note.')
+        ->and($saved['npcs'][0]['id'])->toBe('guide')
+        ->and($saved['npcs'][0]['script'][0]['steps'][0])->toMatchArray(['direction' => 'down', 'count' => 2, 'faceOnly' => false, 'seconds' => 0.25, 'custom' => 'preserved'])
+        ->and(findMapPlacementRow(EditorSession::open($root)->readNpc('test-map', 0, ['script']), 'route')['mapPlacement']['points'][0]['point'])->toBe([0, 2]);
+});
+
+it('refuses diagonal outside and stale route gestures without changing history', function () {
+    $session = EditorSession::open(mapPlacementProject());
+    $read = $session->readNpc('test-map', 0, ['script']);
+    $row = findMapPlacementRow($read, 'route');
+    $context = ['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0];
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 3, 3, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'cardinal')
+        ->and(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 99, 1, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'inside')
+        ->and(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, null, $read['revision']))->toThrow(SessionRefusal::class, 'origin')
+        ->and($session->undo()['label'])->toBeNull();
+    $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 1, 5, 1, [1, 1], $read['revision']);
+    $next = findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'route');
+    expect($next['mapPlacement']['points'][1]['point'])->toBe([2, 0]);
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, [1, 1], $read['revision']))->toThrow(SessionRefusal::class);
+});
+
+it('places transfer command coordinates together through shared schema rows', function () {
+    $session = EditorSession::open(mapPlacementProject());
+    $read = $session->readNpc('test-map', 0, ['script']);
+    $row = findMapPlacementRow($read, 'transfer');
+    $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 3, null, $read['revision']);
+    expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'transfer')['mapPlacement']['points'][0]['point'])->toBe([4, 3]);
+    $session->undo();
+    expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'transfer')['mapPlacement']['points'][0]['point'])->toBe([2, 1]);
+});
+
+it('places transfer events through the atomic destination service and preserves unknown fields', function () {
+    $root = mapPlacementProject();
+    $session = EditorSession::open($root);
+    $revision = $session->readMap('test-map')['revision'];
+    $created = $session->createEvent('test-map', $revision, [[4, 1]], 'Transfer Player');
+    $session->saveMap('test-map');
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $data = require $path;
+    $data['events'][$created['marker']]['data']['spawnPoint']['annotation'] = 'keep arrival metadata';
+    file_put_contents($path, "readInspector('test-map', $created['marker']);
+    $row = findMapPlacementRow($read, 'event-transfer');
+    $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 5, 2, null, $read['revision']);
+    expect(findMapPlacementRow($session->readInspector('test-map', $created['marker']), 'event-transfer')['mapPlacement']['points'][0]['point'])->toBe([5, 2]);
+    $session->undo();
+    expect(findMapPlacementRow($session->readInspector('test-map', $created['marker']), 'event-transfer')['mapPlacement'])->toBe($row['mapPlacement']);
+    $session->saveMap('test-map');
+    expect(file_get_contents($path))->toBe($original);
+    $session->redo();
+    $session->saveMap('test-map');
+    expect(findMapPlacementRow(EditorSession::open($root)->readInspector('test-map', $created['marker']), 'event-transfer')['mapPlacement']['points'][0]['point'])->toBe([5, 2])
+        ->and(file_get_contents($path))->toContain('// Keep transfer source notes.', 'keep arrival metadata');
+});
+
+it('exposes cinematic map targets but never screen or actor relative coordinates and preserves nested source', function () {
+    $root = cutsceneProject();
+    $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($path, <<<'PHP'
+ 'sequence', 'commands' => [
+    ['type' => 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 1, 'y' => 1], 'seconds' => 0.5],
+    ['type' => 'field_animation', 'effect' => 'spark', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1]],
+    ['type' => 'field_animation', 'effect' => 'spark', 'target' => ['kind' => 'screen_position', 'x' => 2, 'y' => 1]],
+    ['type' => 'camera', 'operation' => 'focus', 'target' => ['kind' => 'npc', 'id' => 'guide']],
+    ['type' => 'camera', 'operation' => 'route', 'points' => [['kind' => 'position', 'x' => 1, 'y' => 1, 'seconds' => 0.25]]],
+]]];
+PHP);
+    $original = file_get_contents($path);
+    $session = EditorSession::open($root);
+    $read = $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands', 0, 'commands']);
+    $placements = array_values(array_filter($read['rows'], static fn(array $r): bool => isset($r['mapPlacement'])));
+    expect(array_column(array_column($placements, 'mapPlacement'), 'kind'))->toBe(['camera', 'effect', 'camera']);
+    $row = $placements[0];
+    // Transport object member order is not a source revision.
+    $key = array_reverse($row['key'], true);
+    $context = ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0];
+    $previewRevision = $session->readMap('harbour')['revision'];
+    $session->applyMapPlacement($context, $key, array_reverse($row['mapPlacement'], true), 'harbour', 0, 3, 2, null, $previewRevision);
+    expect(findMapPlacementRow($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands', 0, 'commands']), 'camera')['mapPlacement']['points'][0]['point'])->toBe([3, 2]);
+    expect(fn() => $session->applyMapPlacement($context, $key, $row['mapPlacement'], 'harbour', 0, 4, 2, null, $previewRevision))->toThrow(SessionRefusal::class, 'changed');
+    $session->undo();
+    $session->saveDatabase('cutscenes/cinematic');
+    expect(file_get_contents($path))->toBe($original);
+    $session->redo();
+    $session->saveDatabase('cutscenes/cinematic');
+    expect(file_get_contents($path))->toContain('// Keep this script comment.', "'seconds' => 0.5")
+        ->and(findMapPlacementRow(EditorSession::open($root)->readDatabaseRecord('cutscenes/cinematic', 0, ['commands', 0, 'commands']), 'camera')['mapPlacement']['points'][0]['point'])->toBe([3, 2]);
+});
+
+it('edits inline event script paths using the same map placement and grouped inspector service', function () {
+    $root = mapPlacementProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $data = require $path;
+    $data['events'] = ['S' => ['class' => \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => [
+        'script' => [['type' => 'move_route', 'subject' => 'player', 'steps' => [['direction' => 'right', 'count' => 1, 'faceOnly' => false, 'seconds' => 0.4]]]],
+    ]]];
+    file_put_contents($path, "readInspector('test-map', 'S');
+    $row = findMapPlacementRow($read, 'route');
+    $session->applyMapPlacement(['kind' => 'event', 'map' => 'test-map', 'revision' => $read['revision']],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 3, [1, 1], $read['revision']);
+    expect(findMapPlacementRow($session->readInspector('test-map', 'S'), 'route')['mapPlacement']['points'][0]['point'])->toBe([0, 2]);
+    $session->undo();
+    expect(findMapPlacementRow($session->readInspector('test-map', 'S'), 'route')['mapPlacement'])->toBe($row['mapPlacement']);
+});
+
+it('keeps the existing resize inspector safe for NPCs with undo and redo', function () {
+    $session = EditorSession::open(mapPlacementProject());
+    $read = $session->readInspector('test-map');
+    $row = array_find($read['rows'], static fn(array $r): bool => ($r['key']['target'] ?? '') === 'map-size' && ($r['key']['field'] ?? '') === 'width');
+    expect($row['kind'])->toBe('integer');
+    expect(fn() => $session->applyInspector('test-map', $read['revision'], $row['key'], '1'))->toThrow(SessionRefusal::class, 'stranded');
+    $session->applyInspector('test-map', $read['revision'], $row['key'], '15');
+    expect($session->readMap('test-map')['width'])->toBe(15);
+    $session->undo();
+    expect($session->readMap('test-map')['width'])->toBe(12);
+    $session->redo();
+    expect($session->readMap('test-map')['width'])->toBe(15);
+});
+
+it('does not flatten expression owned coordinates or retain half of a grouped edit', function () {
+    $root = makeTemporaryProject('ichiloto-map-placement-expression-');
+    $path = $root . '/assets/Events/placement-expression.php';
+    file_put_contents($path, <<<'PHP'
+ 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => abs($arrival)]];
+PHP);
+    $original = file_get_contents($path);
+    $session = EditorSession::open($root);
+    $labels = $session->listDatabaseRecords('common_events')['records'];
+    $index = array_search('placement-expression', $labels, true);
+    expect($index)->not->toBeFalse();
+    $row = findMapPlacementRow($session->readDatabaseRecord('common_events', $index), 'transfer');
+    expect(fn() => $session->applyMapPlacement(['kind' => 'database', 'category' => 'common_events', 'index' => $index],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 3, null, $session->readMap('test-map')['revision']))->toThrow(SessionRefusal::class);
+    expect(findMapPlacementRow($session->readDatabaseRecord('common_events', $index), 'transfer')['mapPlacement'])->toBe($row['mapPlacement'])
+        ->and($session->undo()['label'])->toBeNull();
+    $session->saveDatabase('common_events');
+    expect(file_get_contents($path))->toBe($original);
+});
+
+it('refuses a route edit that would move later authored points outside the preview map', function () {
+    $root = mapPlacementProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $data = require $path;
+    $data['npcs'][0]['script'][0]['steps'][1] = ['direction' => 'down', 'count' => 2, 'faceOnly' => false];
+    file_put_contents($path, "readNpc('test-map', 0, ['script']);
+    $row = findMapPlacementRow($read, 'route');
+    expect(fn() => $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 1, 4, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'Step 2 would leave')
+        ->and($session->undo()['label'])->toBeNull();
+});
+
+it('round trips common event route placement over the host protocol', function () {
+    $root = makeTemporaryProject('ichiloto-map-placement-protocol-');
+    $path = $root . '/assets/Events/placement-route.php';
+    file_put_contents($path, <<<'PHP'
+ 'move_route', 'subject' => 'player', 'steps' => [
+    ['direction' => 'right', 'count' => 1, 'faceOnly' => false, 'seconds' => 0.3, 'annotation' => 'keep'],
+]]];
+PHP);
+    $original = file_get_contents($path);
+    $host = new \Ichiloto\Editor\Session\SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array => $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params]));
+    $request('hello', ['protocol' => \Ichiloto\Editor\Session\SessionHost::PROTOCOL, 'project' => $root]);
+    $index = array_search('placement-route', $request('database.records', ['category' => 'common_events'])['result']['records'], true);
+    expect($index)->not->toBeFalse();
+    $record = ['category' => 'common_events', 'index' => $index];
+    $row = findMapPlacementRow($request('database.record', $record)['result'], 'route');
+    $gesture = ['context' => ['kind' => 'database', ...$record], 'key' => $row['key'], 'expected' => $row['mapPlacement'],
+        'previewMap' => 'test-map', 'previewRevision' => $request('map.read', ['map' => 'test-map'])['result']['revision'],
+        'point' => 0, 'x' => 1, 'y' => 3, 'origin' => [1, 1]];
+    expect($request('mapPlacement.apply', [...$gesture, 'origin' => ['1', 1]])['error']['kind'])->toBe('request')
+        ->and($request('mapPlacement.apply', [...$gesture, 'previewRevision' => '0'])['error']['kind'])->toBe('request')
+        ->and($request('mapPlacement.apply', array_diff_key($gesture, ['previewRevision' => true]))['error']['kind'])->toBe('request')
+        ->and($request('mapPlacement.apply', $gesture)['result']['changed'])->toBeTrue()
+        ->and($request('history.undo')['result']['label'])->toBe('Place on map');
+    $request('database.save', ['category' => 'common_events']);
+    expect(file_get_contents($path))->toBe($original);
+    $request('history.redo');
+    $request('database.save', ['category' => 'common_events']);
+    $saved = require $path;
+    expect($saved[0]['steps'][0])->toMatchArray(['direction' => 'down', 'count' => 2, 'seconds' => 0.3, 'annotation' => 'keep'])
+        ->and(file_get_contents($path))->toContain('// Keep route timing and annotations.')
+        ->and(findMapPlacementRow(EditorSession::open($root)->readDatabaseRecord('common_events', $index), 'route')['mapPlacement']['points'][0]['point'])->toBe([0, 2]);
+});
+
+it('edits one camera route point without shifting other absolute targets or inventing actor relative geometry', function () {
+    $root = cutsceneProject();
+    $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($path, <<<'PHP'
+'camera', 'operation'=>'route', 'points'=>[
+    ['kind'=>'position', 'x'=>1, 'y'=>1, 'seconds'=>0.2],
+    ['kind'=>'position', 'x'=>2, 'y'=>1, 'seconds'=>0.3],
+    ['kind'=>'npc', 'id'=>'guide', 'seconds'=>0.4],
+    ['kind'=>'position', 'x'=>4, 'y'=>2, 'seconds'=>0.5],
+]]];
+PHP);
+    $session = EditorSession::open($root);
+    $rows = array_values(array_filter($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands'])['rows'],
+        static fn(array $row): bool => isset($row['mapPlacement'])));
+    expect($rows)->toHaveCount(3)
+        ->and($rows[1]['mapPlacement']['selectedPoint'])->toBe(1)
+        ->and(array_column($rows[1]['mapPlacement']['points'], 'point'))->toBe([[1, 1], [2, 1], [4, 2]])
+        ->and($rows[1]['mapPlacement']['points'][1]['from'])->toBe([1, 1])
+        ->and($rows[1]['mapPlacement']['points'][2]['from'])->toBeNull();
+    $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0],
+        $rows[1]['key'], $rows[1]['mapPlacement'], 'harbour', 1, 3, 2, null, $session->readMap('harbour')['revision']);
+    $session->saveDatabase('cutscenes/cinematic');
+    $saved = require $path;
+    expect($saved[0]['points'])->toBe([
+        ['kind'=>'position', 'x'=>1, 'y'=>1, 'seconds'=>0.2],
+        ['kind'=>'position', 'x'=>3, 'y'=>2, 'seconds'=>0.3],
+        ['kind'=>'npc', 'id'=>'guide', 'seconds'=>0.4],
+        ['kind'=>'position', 'x'=>4, 'y'=>2, 'seconds'=>0.5],
+    ]);
+});
+
+it('refuses incomplete or wrongly typed map axes without defaulting them to another cell', function (mixed $x, mixed $y) {
+    $fields = [
+        ['entry'=>[0], 'sourceKey'=>'target.x', 'field'=>'x', 'dataPath'=>['target', 'x']],
+        ['entry'=>[0], 'sourceKey'=>'target.y', 'field'=>'y', 'dataPath'=>['target', 'y']],
+    ];
+    $fields = \Ichiloto\Editor\Maps\MapPlacement::describeFields(['type'=>'field_animation', 'target'=>['kind'=>'position', 'x'=>$x, 'y'=>$y]], $fields);
+    $placement = $fields[0]['mapPlacement'];
+    expect($placement['issue'])->toContain('explicit non-negative integer');
+    expect(fn() => \Ichiloto\Editor\Maps\MapPlacement::getChanges($placement, 0, 2, 1, null, 'synthetic'))
+        ->toThrow(InvalidArgumentException::class, 'explicit non-negative integer');
+})->with([[null, 1], [1, null], [1.5, 1], ['1', 1], [-1, 1]]);
+
+it('refuses current cinematic target expressions even when the evaluated coordinate or kind would not change', function (string $command, string $kind) {
+    $root = cutsceneProject();
+    $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($path, "readDatabaseRecord('cutscenes/cinematic', 0, ['commands']), $kind);
+    expect($row['mapPlacement']['issue'])->toContain('literal source');
+    expect(fn() => $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0],
+        $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 1, null, $session->readMap('harbour')['revision']))->toThrow(SessionRefusal::class, 'literal source');
+    expect($session->undo()['label'])->toBeNull();
+    $session->saveDatabase('cutscenes/cinematic');
+    expect(file_get_contents($path))->toBe($original);
+})->with([
+    'camera unchanged axis' => ["['type'=>'camera','operation'=>'pan','target'=>['kind'=>'position','x'=>2,'y'=>abs(1)],'seconds'=>0.5]", 'camera'],
+    'camera route axis' => ["['type'=>'camera','operation'=>'route','points'=>[['kind'=>'position','x'=>2,'y'=>abs(1),'seconds'=>0.5]]]", 'camera'],
+    'field effect unchanged axis' => ["['type'=>'field_animation','effect'=>'spark','target'=>['kind'=>'position','x'=>2,'y'=>abs(1)]]", 'effect'],
+    'field effect expression kind' => ["['type'=>'field_animation','effect'=>'spark','target'=>['kind'=>strtolower('POSITION'),'x'=>2,'y'=>1]]", 'effect'],
+]);
+
+it('does not record unchanged inline placement values as a new undo step', function () {
+    $root = mapPlacementProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $data = require $path;
+    $data['events']['S'] = ['class'=>\Ichiloto\Engine\Events\Triggers\StoryTrigger::class,
+        'data'=>['script'=>[['type'=>'transfer', 'map'=>'test-map', 'x'=>2, 'y'=>1]]]];
+    file_put_contents($path, "readInspector('test-map', 'S');
+    $row = findMapPlacementRow($read, 'transfer');
+    $result = $session->applyMapPlacement(['kind'=>'event', 'map'=>'test-map', 'revision'=>$read['revision']],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, null, $read['revision']);
+    expect($result['changed'])->toBeFalse()->and($result['revision'])->toBe($read['revision'])
+        ->and($session->undo()['label'])->toBeNull();
+});
+
+it('places authored waypoints and retains runtime owned retrace geometry', function () {
+    foreach ([['waypoints' => [['x' => 2, 'y' => 1]]], ['retrace' => 'remembered-route']] as $mode) {
+        $root = mapPlacementProject();
+        $path = $root . '/assets/Maps/test-map/test-map.data.php';
+        $data = require $path;
+        $data['npcs'][0]['script'][0] = ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'guide', ...$mode];
+        file_put_contents($path, "readNpc('test-map', 0, ['script']);
+        $row = findMapPlacementRow($read, isset($mode['waypoints']) ? 'waypoints' : 'route');
+        if (isset($mode['waypoints'])) {
+            expect($row['mapPlacement']['issue'])->toBeNull();
+            $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0],
+                $row['key'], $row['mapPlacement'], 'test-map', 0, 3, 2, null, $read['revision']);
+            expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'waypoints')['mapPlacement']['points'][0]['point'])->toBe([3, 2]);
+            $session->undo();
+        } else {
+            expect($row['mapPlacement']['issue'])->toContain('completed runtime route record')
+                ->and($row['mapPlacement']['points'])->toBe([])
+                ->and(array_find($read['rows'], static fn(array $r): bool => ($r['reference'] ?? '') === 'cinematic_movement_routes'))->not->toBeNull()
+                ->and(fn() => $session->applyMapPlacement(['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0],
+                    $row['key'], $row['mapPlacement'], 'test-map', 0, 2, 1, [1, 1], $read['revision']))->toThrow(SessionRefusal::class, 'completed runtime route record')
+                ->and($session->undo()['label'])->toBeNull();
+        }
+        $session->saveMap('test-map');
+        expect(file_get_contents($path))->toBe($original);
+    }
+});
+
+it('refuses type changed descriptors and keys but accepts reordered object members', function () {
+    $session = EditorSession::open(mapPlacementProject());
+    $read = $session->readNpc('test-map', 0, ['script']);
+    $row = findMapPlacementRow($read, 'transfer');
+    $context = ['kind' => 'npc', 'map' => 'test-map', 'revision' => $read['revision'], 'index' => 0];
+    $expected = $row['mapPlacement'];
+    $expected['points'][0]['point'][0] = '2';
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $expected, 'test-map', 0, 4, 3, null, $read['revision']))
+        ->toThrow(SessionRefusal::class, 'changed');
+    $key = $row['key'];
+    $key['frame'] = ['script', '0'];
+    expect(fn() => $session->applyMapPlacement($context, $key, $row['mapPlacement'], 'test-map', 0, 4, 3, null, $read['revision']))
+        ->toThrow(SessionRefusal::class);
+    expect($session->undo()['label'])->toBeNull();
+    $session->applyMapPlacement($context, array_reverse($row['key'], true), array_reverse($row['mapPlacement'], true),
+        'test-map', 0, 4, 3, null, $read['revision']);
+    expect(findMapPlacementRow($session->readNpc('test-map', 0, ['script']), 'transfer')['mapPlacement']['points'][0]['point'])->toBe([4, 3]);
+});
+
+it('refuses stale preview map revisions independently of the database owner', function () {
+    $root = cutsceneProject();
+    $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($path, "'move_route','subject'=>'player','steps'=>[['direction'=>'right','count'=>1]]]];\n");
+    $session = EditorSession::open($root);
+    $read = $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']);
+    $row = findMapPlacementRow($read, 'route');
+    $context = ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0];
+    $map = $session->readMap('harbour');
+    $inspector = $session->readInspector('harbour');
+    $width = array_find($inspector['rows'], static fn(array $row): bool => ($row['key']['target'] ?? '') === 'map-size' && ($row['key']['field'] ?? '') === 'width');
+    $session->applyInspector('harbour', $map['revision'], $width['key'], (string) ($map['width'] + 1));
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 2, [1, 1], $map['revision']))
+        ->toThrow(SessionRefusal::class, 'changed since revision');
+    expect(findMapPlacementRow($session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']), 'route')['mapPlacement'])->toBe($row['mapPlacement']);
+    expect($session->undo()['label'])->not->toBe('Place on map');
+});
+
+it('refuses an old database review after a timing edit and undo even when geometry matches again', function () {
+    $root = cutsceneProject();
+    $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($path, "'move_route','subject'=>'player','steps'=>[['direction'=>'right','count'=>1,'seconds'=>0.4]]]];\n");
+    $session = EditorSession::open($root);
+    $read = $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']);
+    $row = findMapPlacementRow($read, 'route');
+    $timing = array_find($read['rows'], static fn(array $row): bool => ($row['name'] ?? '') === 'Seconds');
+    expect($timing)->not->toBeNull();
+    $session->applyDatabaseRecord('cutscenes/cinematic', 0, $timing['key'], '0.8');
+    $session->undo();
+    expect(fn() => $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0],
+        $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 1, [1, 1], $session->readMap('harbour')['revision']))
+        ->toThrow(SessionRefusal::class, 'changed');
+    $session->saveDatabase('cutscenes/cinematic');
+    expect(file_get_contents($path))->toContain("'seconds'=>0.4");
+});
+
+it('refuses unchanged expression coordinates before any route or target mutation', function (string $owner, string $command, string $kind) {
+    $root = mapPlacementProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    if ($owner === 'database') {
+        $path = $root . '/assets/Events/placement-source.php';
+        file_put_contents($path, " \Ichiloto\Engine\Events\Triggers\StoryTrigger::class, 'data' => ['script' => ['COMMAND_SOURCE']]];
+        }
+        file_put_contents($path, " $owner, 'map' => 'test-map', 'revision' => $session->readMap('test-map')['revision'], 'index' => 0];
+    if ($owner === 'database') {
+        $index = array_search('placement-source', $session->listDatabaseRecords('common_events')['records'], true);
+        $context = ['kind' => 'database', 'category' => 'common_events', 'index' => $index];
+        $read = $session->readDatabaseRecord('common_events', $index);
+    } else {
+        $read = $owner === 'npc' ? $session->readNpc('test-map', 0, ['script']) : $session->readInspector('test-map', 'S');
+    }
+    $row = findMapPlacementRow($read, $kind);
+    expect($row['mapPlacement']['issue'])->toContain('literal source');
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 1,
+        $kind === 'route' ? [1, 1] : null, $session->readMap('test-map')['revision']))->toThrow(SessionRefusal::class, 'literal source');
+    expect($session->undo()['label'])->toBeNull();
+    $owner === 'database' ? $session->saveDatabase('common_events') : $session->saveMap('test-map');
+    expect(file_get_contents($path))->toBe($original);
+})->with([
+    'NPC transfer unchanged y' => ['npc', "['type'=>'transfer','map'=>'test-map','x'=>2,'y'=>abs(1)]", 'transfer'],
+    'inline transfer unchanged y' => ['event', "['type'=>'transfer','map'=>'test-map','x'=>2,'y'=>abs(1)]", 'transfer'],
+    'common transfer unchanged y' => ['database', "['type'=>'transfer','map'=>'test-map','x'=>2,'y'=>abs(1)]", 'transfer'],
+    'NPC route count' => ['npc', "['type'=>'move_route','subject'=>'player','steps'=>[['direction'=>'right','count'=>abs(1)]]]", 'route'],
+]);
+
+it('refuses an external literal or expression rewrite rather than authoring over a stale source review', function (bool $expression) {
+    $root = mapPlacementProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $session = EditorSession::open($root);
+    $read = $session->readNpc('test-map', 0, ['script']);
+    $row = findMapPlacementRow($read, 'transfer');
+    file_put_contents($path, str_replace("'x' => 2", $expression ? "'x' => abs(2)" : "'x' => 3", file_get_contents($path)));
+    $external = file_get_contents($path);
+    expect(fn() => $session->applyMapPlacement(['kind'=>'npc', 'map'=>'test-map', 'revision'=>$read['revision'], 'index'=>0],
+        $row['key'], $row['mapPlacement'], 'test-map', 0, 4, 3, null, $read['revision']))->toThrow(SessionRefusal::class, 'changed');
+    expect($session->undo()['label'])->toBeNull()->and(file_get_contents($path))->toBe($external);
+})->with([false, true]);
+
+it('round trips every cinematic map target with grouped undo and unchanged terminal map geometry', function (array $command, string $kind, array $frame) {
+    $root = cutsceneProject();
+    $path = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.script.php';
+    file_put_contents($path, "readDatabaseRecord('cutscenes/cinematic', 0, $frame), $kind);
+    $session->applyMapPlacement(['kind'=>'database', 'category'=>'cutscenes/cinematic', 'index'=>0],
+        $row['key'], $row['mapPlacement'], 'harbour', 0, 3, 2, null, $session->readMap('harbour')['revision']);
+    expect($session->undo()['label'])->toBe('Place on map');
+    $session->saveDatabase('cutscenes/cinematic');
+    expect(file_get_contents($path))->toBe($original);
+    $session->redo();
+    $session->saveDatabase('cutscenes/cinematic');
+    $reopened = EditorSession::open($root);
+    expect(findMapPlacementRow($reopened->readDatabaseRecord('cutscenes/cinematic', 0, $frame), $kind)['mapPlacement']['points'][0]['point'])->toBe([3, 2])
+        ->and(file_get_contents($path))->toContain('// Preserve this author note.', "'annotation' => 'keep'")
+        ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapSources);
+})->with([
+    'camera pan' => [['type'=>'camera', 'operation'=>'pan', 'target'=>['kind'=>'position', 'x'=>1, 'y'=>1], 'seconds'=>0.5, 'annotation'=>'keep'], 'camera', ['commands']],
+    'camera route point' => [['type'=>'camera', 'operation'=>'route', 'points'=>[['kind'=>'position', 'x'=>1, 'y'=>1, 'seconds'=>0.25, 'annotation'=>'keep']]], 'camera', ['commands']],
+    'field effect map target' => [['type'=>'field_animation', 'effect'=>'spark', 'target'=>['kind'=>'position', 'x'=>1, 'y'=>1], 'annotation'=>'keep'], 'effect', ['commands']],
+    'nested effect target' => [['type'=>'branch', 'conditions'=>[], 'then'=>[['type'=>'field_animation', 'effect'=>'spark', 'target'=>['kind'=>'position', 'x'=>1, 'y'=>1], 'annotation'=>'keep']]], 'effect', ['commands', 0, 'then']],
+    'transfer target' => [['type'=>'transfer', 'map'=>'harbour', 'x'=>1, 'y'=>1, 'annotation'=>'keep'], 'transfer', ['commands']],
+    'move player target' => [['type'=>'move_player', 'x'=>1, 'y'=>1, 'annotation'=>'keep'], 'position', ['commands']],
+]);
diff --git a/tests/Unit/Session/MapRoleAuthoringTest.php b/tests/Unit/Session/MapRoleAuthoringTest.php
new file mode 100644
index 00000000..fecb2ab9
--- /dev/null
+++ b/tests/Unit/Session/MapRoleAuthoringTest.php
@@ -0,0 +1,129 @@
+createDatabaseRecord(MapRoleFields::CATEGORY)['index'];
+    $set = static function (string $field, string $value) use ($session, $index): void {
+        $row = array_find($session->readDatabaseRecord(MapRoleFields::CATEGORY, $index)['rows'],
+            static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+        $session->applyDatabaseRecord(MapRoleFields::CATEGORY, $index, $row['key'], $value);
+    };
+    $set('id', strtolower($name));
+    $set('name.default', $name);
+    if ($leadsTo !== null) { $set('leadsTo', $leadsTo); }
+
+    return $index;
+}
+
+function setMapRole(EditorSession $session, string $mapId, string $role): array
+{
+    return applyLandmarkRow($session, ['target' => MapInspector::ROLE_TARGET], $role, null, $mapId);
+}
+
+/** @return list> The project's issues with this code. */
+function findRoleIssues(EditorSession $session, string $code): array
+{
+    return array_values(array_filter($session->validateProject()['issues'], static fn(array $issue): bool => $issue['code'] === $code));
+}
+
+it('refuses a transfer from a map whose role leads only to some roles to a map of another role', function () {
+    // world's transfer T leads into test-map.
+    [$root, , $session] = createAtlasProject();
+    createMapRole($session, 'Overworld');
+    createMapRole($session, 'Outside');
+    createMapRole($session, 'Interior');
+    $overworld = 0;
+    $row = array_find($session->readDatabaseRecord(MapRoleFields::CATEGORY, $overworld)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'leadsTo');
+    $session->applyDatabaseRecord(MapRoleFields::CATEGORY, $overworld, $row['key'], 'overworld, outside');
+
+    setMapRole($session, 'world', 'overworld');
+    setMapRole($session, 'test-map', 'interior');
+    $refused = findRoleIssues($session, 'map.role-destination');
+    expect($refused)->toHaveCount(1)
+        ->and($refused[0]['severity'])->toBe('error')
+        ->and($refused[0]['map'])->toBe('world')
+        ->and($refused[0]['message'])->toContain('Event T', 'test-map', 'Interior', 'Overworld or Outside');
+
+    // An outside map is a place an overworld leads to.
+    setMapRole($session, 'test-map', 'outside');
+    expect(findRoleIssues($session, 'map.role-destination'))->toBe([]);
+
+    // A destination without a role cannot be checked, and says so without refusing.
+    setMapRole($session, 'test-map', '');
+    expect(getSessionMap($session)->hasMapDataField([MapRoleCatalog::MAP_KEY]))->toBeFalse();
+    $unchecked = array_values(array_filter($session->validateProject()['issues'],
+        static fn(array $issue): bool => $issue['map'] === 'world' && str_contains($issue['message'], 'has no role')));
+    expect($unchecked)->toHaveCount(1)->and($unchecked[0]['severity'])->toBe('warning');
+
+    // A role leading anywhere checks nothing.
+    setMapRole($session, 'test-map', 'interior');
+    setMapRole($session, 'world', 'outside');
+    expect(findRoleIssues($session, 'map.role-destination'))->toBe([]);
+
+    $session->saveDatabase(MapRoleFields::CATEGORY);
+    $roles = MapRoleCatalog::load($root . '/assets');
+    expect($roles->getIds())->toBe(['overworld', 'outside', 'interior'])
+        ->and($roles->getDestinationRoles('overworld'))->toBe(['overworld', 'outside'])
+        ->and($roles->getDestinationRoles('interior'))->toBeNull()
+        ->and($roles->getName('interior'))->toBe('Interior');
+});
+
+it('chooses a map role from the project\'s roles, refusing one it does not have, as one undo step', function () {
+    [, , $session] = createAtlasProject();
+    createMapRole($session, 'Interior');
+    $row = findLandmarkRow($session, ['target' => MapInspector::ROLE_TARGET]);
+    expect($row['value'])->toBe(MapInspector::MAP_ROLE_NONE)->and($row['label'])->toBe('Role');
+
+    expect(fn() => setMapRole($session, 'test-map', 'castle'))->toThrow(SessionRefusal::class, 'castle is not a map role');
+    setMapRole($session, 'test-map', 'interior');
+    expect(getSessionMap($session)->getMapDataField([MapRoleCatalog::MAP_KEY]))->toBe('interior')
+        ->and(findLandmarkRow($session, ['target' => MapInspector::ROLE_TARGET])['value'])->toBe('Interior (interior)');
+    $session->undo();
+    expect(getSessionMap($session)->hasMapDataField([MapRoleCatalog::MAP_KEY]))->toBeFalse();
+    $session->redo();
+
+    // A role stays while a map is of it.
+    expect(fn() => $session->deleteDatabaseRecord(MapRoleFields::CATEGORY, 0))->toThrow(SessionRefusal::class, 'map test-map');
+});
+
+it('reports a map naming a role the project does not have', function () {
+    [$root, , $session] = createAtlasProject();
+    $file = $root . '/assets/Maps/world/world.data.php';
+    file_put_contents($file, str_replace("'name' => 'World',", "'name' => 'World', 'role' => 'castle',", (string) file_get_contents($file)));
+    $session = EditorSession::open($root);
+
+    $issues = findRoleIssues($session, 'map.role');
+    expect($issues)->toHaveCount(1)
+        ->and($issues[0]['severity'])->toBe('error')
+        ->and($issues[0]['message'])->toContain('castle is not a map role');
+});
+
+it('holds edge triggers to the same destinations as doors', function () {
+    [$root] = createAtlasProject();
+    file_put_contents($root . '/assets/' . MapRoleCatalog::FILE, ' MapRoleCatalog::SCHEMA, 'roles' => [
+        'region' => ['name' => ['message' => 'map_roles.region.name', 'default' => 'Region'], 'leadsTo' => ['region']],
+        'room' => ['name' => ['message' => 'map_roles.room.name', 'default' => 'Room']],
+    ]], true) . ';');
+    $world = $root . '/assets/Maps/world/world.data.php';
+    $source = (string) file_get_contents($world);
+    // The door now leads back onto the region itself; only the edge leaves it.
+    $source = str_replace("'destinationMap' => 'test-map'", "'destinationMap' => 'world'", $source);
+    file_put_contents($world, str_replace("'name' => 'World',", "'name' => 'World', 'role' => 'region', 'triggers' => [['id' => 'east-edge',"
+        . " 'destinationMap' => 'test-map', 'trigger_area' => ['x' => 0, 'y' => 1, 'width' => 1, 'height' => 1]]],", $source));
+    $map = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($map, str_replace("'name' => 'Test Map',", "'name' => 'Test Map', 'role' => 'room',", (string) file_get_contents($map)));
+
+    $issues = findRoleIssues(EditorSession::open($root), 'map.role-destination');
+    expect($issues)->toHaveCount(1)
+        ->and($issues[0]['message'])->toContain('Edge trigger east-edge', 'test-map', 'Room', 'lead only to Region maps');
+});
diff --git a/tests/Unit/Session/MapWalkMeasurementSessionTest.php b/tests/Unit/Session/MapWalkMeasurementSessionTest.php
new file mode 100644
index 00000000..d096df71
--- /dev/null
+++ b/tests/Unit/Session/MapWalkMeasurementSessionTest.php
@@ -0,0 +1,183 @@
+ ['troops' => ['Wolves' => 2], 'rate' => 4],"): string
+{
+    $root = makeTemporaryProject('ichiloto-measure-');
+    $maps = $root . '/assets/Maps';
+    file_put_contents($maps . '/collisions.php', " CollisionType::NONE, ' ' => CollisionType::NONE, '#' => CollisionType::SOLID, ';' => CollisionType::ENCOUNTER];\n");
+    $directory = $maps . '/test-map';
+    file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource("##########\n#..;;;.#.#\n#.##...#.#\n##########", 'ICHILOTO_MAP'));
+    file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource("          \n          \n    T     \n          ", 'ICHILOTO_EVENT_MAP'));
+    file_put_contents($directory . '/test-map.data.php', << 'Test Map', 'region' => '', 'triggers' => [],
+    'events' => [
+        'T' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\TransferPlayerTrigger',
+            'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 1]]],
+    ],
+    'npcs' => [['name' => 'Guard', 'x' => 6, 'y' => 2, 'dialogue' => [['text' => 'Halt.']]]],
+    {$encounters}
+];
+PHP);
+
+    return $root;
+}
+
+it('measures the shortest walk with its steps, seconds, screens and expected encounters', function () {
+    $walk = EditorSession::open(createMeasuredProject())->measureWalk('test-map', [[1, 1], [6, 1]]);
+
+    expect($walk)->toMatchArray([
+        'map' => 'test-map', 'reachable' => true, 'problem' => null,
+        'path' => [[1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1]],
+        'steps' => 5, 'across' => 5, 'down' => 0,
+        // RPG Maker MZ's walk: a 48-pixel cell in 16 frames at 60 frames a second.
+        'stepSeconds' => 0.2667, 'walkSeconds' => 1.33,
+        // The GPUI field at the default screen and zoom shows 28 x 15 cells.
+        'view' => ['columns' => 28, 'rows' => 15, 'zoom' => 1.0], 'screens' => 0.18,
+        'encounters' => ['rate' => 4, 'everyTile' => false, 'countedSteps' => 3.0, 'expected' => 0.75, 'troops' => ['Wolves']],
+    ])->and($walk['legs'])->toHaveCount(1);
+});
+
+it('says plainly that only walking time is shown while the game authors no dash speed', function () {
+    $walk = EditorSession::open(createMeasuredProject())->measureWalk('test-map', [[1, 1], [6, 1]]);
+
+    expect($walk['dashSeconds'])->toBeNull()
+        ->and($walk['dashSpeed'])->toBeNull()
+        ->and($walk['dashUnavailable'])->toBe('This game authors no dash speed, so only walking time is shown.');
+});
+
+it('shows dashing seconds at the game\'s authored dash speed, unsaved edits included, except where the map turns dashing off', function () {
+    $root = createMeasuredProject();
+    $system = $root . '/assets/Data/system.php';
+    file_put_contents($system, " ['enabled' => true, 'speedMultiplier' => 2.0]], true) . ";\n");
+    $session = EditorSession::open($root);
+    $walk = $session->measureWalk('test-map', [[1, 1], [1, 2], [6, 1]]);
+
+    expect($walk)->toMatchArray(['dashSpeed' => 2.0, 'dashUnavailable' => null, 'walkSeconds' => 1.87, 'dashSeconds' => 0.93])
+        ->and(array_column($walk['legs'], 'dashSeconds'))->toBe([0.13, 0.8]);
+
+    // An unsaved System edit is what the tool reads.
+    $row = static fn(): array => array_find($session->readDatabaseRecord('system', 0)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'dash.speedMultiplier');
+    $session->applyDatabaseRecord('system', 0, $row()['key'], '1.5');
+    expect($session->measureWalk('test-map', [[1, 1], [6, 1]])['dashSeconds'])->toBe(0.89);
+
+    // A map that turns dashing off measures walking only, and says why.
+    $read = $session->readInspector('test-map');
+    $disable = array_find($read['rows'], static fn(array $row): bool => $row['label'] === 'Disable Dashing');
+    $session->applyInspector('test-map', $read['revision'], $disable['key'], 'true');
+    expect($session->measureWalk('test-map', [[1, 1], [6, 1]]))
+        ->toMatchArray(['dashSpeed' => null, 'dashSeconds' => null, 'dashUnavailable' => 'This map turns dashing off, so only walking time is shown.']);
+
+    // A dash the Engine could not read says so instead of guessing.
+    $session->applyInspector('test-map', $session->readInspector('test-map')['revision'], $disable['key'], 'false');
+    $session->applyDatabaseRecord('system', 0, $row()['key'], '9');
+    expect($session->measureWalk('test-map', [[1, 1], [6, 1]])['dashUnavailable'])->toContain('cannot read its dash', 'at most 4');
+});
+
+it('walks through waypoints in order, leg by leg', function () {
+    $walk = EditorSession::open(createMeasuredProject())->measureWalk('test-map', [[1, 1], [1, 2], [6, 1]]);
+
+    expect($walk['steps'])->toBe(7)
+        ->and($walk['across'])->toBe(5)
+        ->and($walk['down'])->toBe(2)
+        ->and(array_column($walk['legs'], 'steps'))->toBe([1, 6])
+        ->and(array_column($walk['legs'], 'expectedEncounters'))->toBe([0.0, 0.75])
+        ->and($walk['path'][0])->toBe([1, 1])
+        ->and($walk['path'][1])->toBe([1, 2])
+        ->and(end($walk['path']))->toBe([6, 1]);
+});
+
+it('ends a walk on a transfer, but never goes on from one', function () {
+    $session = EditorSession::open(createMeasuredProject());
+
+    expect($session->measureWalk('test-map', [[1, 1], [4, 2]])['steps'])->toBe(4)
+        ->and($session->measureWalk('test-map', [[1, 1], [4, 2], [6, 1]]))->toMatchArray(['reachable' => false, 'path' => [],
+            'problem' => 'Stepping onto (4, 2) takes the player off the map, so a walk cannot go on from it.']);
+});
+
+it('says why a point cannot be stood on or reached', function (array $points, string $problem) {
+    $walk = EditorSession::open(createMeasuredProject())->measureWalk('test-map', $points);
+
+    expect($walk)->toMatchArray(['reachable' => false, 'problem' => $problem, 'path' => [], 'steps' => 0, 'legs' => []]);
+})->with([
+    'a wall' => [[[1, 1], [2, 2]], 'The player cannot stand at (2, 2): it is on a cell the player cannot stand on.'],
+    'a fixed NPC' => [[[1, 1], [6, 2]], 'The player cannot stand at (6, 2): it is where an NPC stands.'],
+    'outside the map' => [[[1, 1], [12, 1]], 'The player cannot stand at (12, 1): it is outside the map.'],
+    'a walled-off pocket' => [[[1, 1], [8, 1]], 'No walk leads from (1, 1) to (8, 1).'],
+]);
+
+it('counts no encounters on a map without them', function () {
+    $walk = EditorSession::open(createMeasuredProject(''))->measureWalk('test-map', [[1, 1], [6, 1]]);
+
+    expect($walk['encounters'])->toBeNull()
+        ->and($walk['legs'][0]['expectedEncounters'])->toBeNull();
+});
+
+it('measures the map as the session holds it, unsaved edits included, and changes nothing', function () {
+    $root = createMeasuredProject();
+    $before = sourceHashTree($root);
+    $session = EditorSession::open($root);
+    $revision = $session->readMap('test-map')['revision'];
+    // A wall painted across the top row's corridor, unsaved, sends the walk round below it.
+    $session->paint('test-map', $revision, $session->readMap('test-map')['baseLayer'], [[2, 1]], '#');
+    $walk = $session->measureWalk('test-map', [[1, 1], [6, 1]]);
+
+    expect($walk['reachable'])->toBeFalse()
+        ->and($walk['problem'])->toBe('No walk leads from (1, 1) to (6, 1).');
+    $session->undo();
+    expect($session->measureWalk('test-map', [[1, 1], [6, 1]])['steps'])->toBe(5)
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('answers map.measure over the session protocol, and refuses what it cannot measure', function () {
+    $root = createMeasuredProject();
+    $host = new SessionHost(STDIN, STDOUT, fopen('php://memory', 'w+'));
+    $host->handle(json_encode(['id' => 1, 'method' => 'hello', 'params' => ['protocol' => SessionHost::PROTOCOL, 'project' => $root]]));
+    $answer = $host->handle(json_encode(['id' => 2, 'method' => 'map.measure', 'params' => ['map' => 'test-map', 'points' => [[1, 1], [6, 1]]]]));
+
+    expect($answer['result'])->toMatchArray(['steps' => 5, 'reachable' => true])
+        ->and($host->handle(json_encode(['id' => 3, 'method' => 'map.measure', 'params' => ['map' => 'test-map', 'points' => [[1, 1]]]]))['error'])
+        ->toMatchArray(['kind' => 'refusal'])
+        ->and($host->handle(json_encode(['id' => 4, 'method' => 'map.measure', 'params' => ['map' => 'test-map', 'points' => 'here']]))['error'])
+        ->toMatchArray(['kind' => 'request'])
+        ->and(fn() => EditorSession::open($root)->measureWalk('elsewhere', [[1, 1], [2, 1]]))->toThrow(SessionRefusal::class, 'There is no map elsewhere.');
+    $host->close();
+});
+
+it('meets a terrain-limited troop only on its terrain, as the field reads the map\'s kinds', function () {
+    // The top row is grove, the bottom meadow; every step counts, only the grove holds Sprites, and a grove's
+    // danger tile counts twice on a map that counts every tile.
+    $root = terrainKindProject("♣♣♣♣\n....");
+    file_put_contents($root . '/assets/Maps/test-map/layers/04.buildings.map.php', MapGridSource::buildSource("    \n    ", 'AUTHORED'));
+    file_put_contents($root . '/assets/Maps/test-map/test-map.event.php', MapGridSource::buildSource("    \n    ", 'EVENTS'));
+    $file = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($file, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'terrainKinds' => true, 'encounters' => "
+        . var_export(['troops' => ['Sprites' => ['weight' => 2, 'terrains' => ['grove']]], 'rate' => 2, 'tiles' => 'any'], true) . ',',
+        (string) file_get_contents($file)));
+    $session = EditorSession::open($root);
+
+    expect($session->measureWalk('test-map', [[0, 0], [3, 0]])['encounters'])->toMatchArray(['countedSteps' => 6.0, 'expected' => 3.0, 'troops' => ['Sprites']])
+        ->and($session->measureWalk('test-map', [[0, 1], [3, 1]])['encounters'])->toMatchArray(['countedSteps' => 0.0, 'expected' => 0.0, 'troops' => []]);
+});
diff --git a/tests/Unit/Session/MasterySessionTest.php b/tests/Unit/Session/MasterySessionTest.php
new file mode 100644
index 00000000..1aaf8547
--- /dev/null
+++ b/tests/Unit/Session/MasterySessionTest.php
@@ -0,0 +1,328 @@
+ [\n    ['id' => 'blades', 'name' => 'Blades', 'nodes' => [\n      ['id' => 'cut', 'kind' => 'application', 'skill' => 'Cut'],\n    ]],\n    ['id' => 'storms', 'name' => 'Storms', 'nodes' => [\n      ['id' => 'spark', 'kind' => 'application', 'skill' => 'Spark'],\n    ]],\n  ],\n];\n");
+
+    return $root;
+}
+
+/** The row of a record read, by its field id. */
+function masteryRow(array $read, string $field): array
+{
+    return array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field)
+        ?? throw new RuntimeException("No row {$field}.");
+}
+
+/** Authors one node's fields on a map record through the session. */
+function authorMasteryNode(EditorSession $session, int $index, int $node, array $values): void
+{
+    foreach ($values as $field => $value) {
+        $session->applyDatabaseRecord(MasteryFields::MAPS, $index, masteryRow($session->readDatabaseRecord(MasteryFields::MAPS, $index), "node{$node}{$field}")['key'], (string) $value);
+    }
+}
+
+it('authors a character\'s map node by node, and the Engine reads it back', function () {
+    $root = masteryProject();
+    $session = EditorSession::open($root);
+    $created = $session->createDatabaseRecord(MasteryFields::MAPS, 'Kaelion');
+    $index = $created['index'];
+    foreach ([0, 1] as $node) {
+        $session->addDatabaseItem(MasteryFields::MAPS, $index, ['frame' => []], false);
+    }
+    authorMasteryNode($session, $index, 0, ['Id' => 'cut']);
+    authorMasteryNode($session, $index, 1, ['Id' => 'spark', 'Cost' => 50, 'OpensAtLevel' => 3, 'Requires' => 'cut', 'Hidden' => 'true']);
+    $read = $session->readDatabaseRecord(MasteryFields::MAPS, $index);
+    $session->saveDatabase(MasteryFields::MAPS);
+    $map = MasteryCatalog::load($root . '/assets')->findMap('Kaelion');
+
+    expect(array_keys($map?->terms ?? []))->toBe(['cut', 'spark'])
+        ->and($map->nodes['spark']->skill)->toBe('Spark')
+        ->and($map->nodes['spark']->requires)->toBe(['cut'])
+        ->and($map->nodes['spark']->cost)->toBe(50)
+        ->and($map->nodes['spark']->hidden)->toBeTrue()
+        ->and($map->nodes['cut']->hidden)->toBeFalse()
+        // A node's price is in Mastery points, and nodes have no study order of their own.
+        ->and(masteryRow($read, 'node1Cost')['label'] ?? null)->toContain('SP')
+        ->and(array_filter(array_map(static fn(array $row): string => strval($row['key']['field'] ?? ''), $read['rows']),
+            static fn(string $field): bool => str_ends_with($field, 'Order')))->toBe([]);
+});
+
+it('refuses saving a map the Engine could not read, naming why', function () {
+    $root = masteryProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord(MasteryFields::MAPS, 'Kaelion')['index'];
+    $session->addDatabaseItem(MasteryFields::MAPS, $index, ['frame' => []], false);
+    authorMasteryNode($session, $index, 0, ['Id' => 'spark', 'Requires' => 'missing']);
+
+    expect(fn() => $session->saveDatabase(MasteryFields::MAPS))->toThrow(SessionRefusal::class, 'requires unknown node missing')
+        ->and(glob($root . '/assets/Data/Mastery/*.php') ?: [])->toBe([]);
+});
+
+it('refuses terms for a node the tree lacks, and keeps a skill the tree teaches from being deleted', function () {
+    $root = masteryProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord(MasteryFields::MAPS, 'Kaelion')['index'];
+    $session->addDatabaseItem(MasteryFields::MAPS, $index, ['frame' => []], false);
+    $nodes = masteryRow($session->readDatabaseRecord(MasteryFields::MAPS, $index), 'node0Id');
+    $session->applyDatabaseRecord(MasteryFields::MAPS, $index, $nodes['key'], 'nowhere');
+
+    expect(fn() => $session->saveDatabase(MasteryFields::MAPS))->toThrow(SessionRefusal::class, 'does not have')
+        ->and(fn() => $session->deleteDatabaseRecord('skills', 0))->toThrow(SessionRefusal::class, 'Mastery region Blades');
+});
+
+it('grows a branch whose nodes chain from its foundation, and refuses a ladder the Engine could not read', function () {
+    $root = masteryProject();
+    writeSkillRecords($root, new SpecialSkill('Cut', 'Cuts.', '', 2, 0), new MagicSkill('Spark', 'Shocks.', '', 4, 0),
+        new MagicSkill('Spark II', 'Shocks harder.', '', 8, 0));
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord(MasteryFields::BRANCHES, 'lightning')['index'];
+    $branch = static fn(): array => $session->readDatabaseRecord(MasteryFields::BRANCHES, $index);
+    $session->applyDatabaseRecord(MasteryFields::BRANCHES, $index, masteryRow($branch(), 'region')['key'], 'storms');
+    foreach ([0, 1] as $node) {
+        $session->addDatabaseItem(MasteryFields::BRANCHES, $index, ['frame' => []], false);
+    }
+    foreach ([['node0Id', 'lightning'], ['node0Kind', 'foundation'], ['node0Name', 'Lightning'], ['node1Id', 'spark-ii'],
+        ['node1Skill', 'Spark II'], ['node1Grade', '2'], ['node1Cost', '90']] as [$field, $value]) {
+        $session->applyDatabaseRecord(MasteryFields::BRANCHES, $index, masteryRow($branch(), $field)['key'], $value);
+    }
+
+    // A second grade with no first is a broken ladder.
+    expect(fn() => $session->saveDatabase(MasteryFields::BRANCHES))->toThrow(SessionRefusal::class, 'without a gap');
+
+    $session->applyDatabaseRecord(MasteryFields::BRANCHES, $index, masteryRow($branch(), 'node1Grade')['key'], '1');
+    $session->saveDatabase(MasteryFields::BRANCHES);
+    $tree = MasteryCatalog::load($root . '/assets')->tree;
+
+    expect(array_keys($tree->branches))->toBe(['lightning'])
+        ->and($tree->getRequirements($tree->nodes['spark-ii']))->toBe(['lightning'])
+        ->and($tree->nodes['spark-ii']->cost)->toBe(90)
+        ->and(array_keys((require $root . '/assets/Data/mastery.php')))->toBe(['regions', 'branches']);
+});
+
+it('leaves each node\'s place on the map to the GUI editor and keeps it through Terminal edits', function () {
+    $root = masteryProject();
+    mkdir($root . '/assets/Data/Mastery');
+    file_put_contents($root . '/assets/Data/Mastery/kaelion.php', " 'Kaelion',\n  'nodes' => [\n    ['id' => 'cut', 'cost' => 0, 'opensAtLevel' => 1, 'position' => ['x' => 0.2, 'y' => 0.3]],\n  ],\n];\n");
+    $terminal = loadRecordDatabase($root, MasteryFields::MAPS);
+    $terminalFields = array_column($terminal->getSettingsFields(0), 'field');
+    $terminal->setField(0, 'node0Cost', '40');
+    $terminal->save();
+    $graphical = EditorSession::open($root);
+
+    expect($terminalFields)->toContain('node0Cost')->not->toContain('node0Positionx')
+        ->and((require $root . '/assets/Data/Mastery/kaelion.php')['nodes'][0])->toMatchArray(['cost' => 40, 'position' => ['x' => 0.2, 'y' => 0.3]])
+        ->and(masteryRow($graphical->readDatabaseRecord(MasteryFields::MAPS, 0), 'node0Positionx')['value'])->toBe('0.2');
+});
+
+it('moves a character\'s own skills onto their map, leaves role skills to the role, and undoes as one step', function () {
+    $root = masteryProject();
+    writeSkillRecords($root, new SpecialSkill('Cut', 'Cuts.', '', 2, 0), new MagicSkill('Spark', 'Shocks.', '', 4, 0),
+        new SpecialSkill('Rally', 'Rallies.', '', 3, 0), new SpecialSkill('Lunge', 'Lunges.', '', 3, 0));
+    $actor = $root . '/assets/Data/Actors/Kaelion.php';
+    $settings = (string) file_get_contents($root . '/assets/Data/mastery.php');
+    $source = (string) file_get_contents($actor);
+    file_put_contents($actor, str_replace("    'images' => [", "    'class' => 'Vanguard',\n    'abilities' => [\n      'learned' => ['Cut', 'Rally', 'Lunge'],\n      'learnables' => [\n        ['skill' => 'Spark', 'requirement' => ['experienceRequired' => 300]],\n      ],\n    ],\n    'images' => [", $source));
+    $classes = $root . '/assets/Data/classes.php';
+    file_put_contents($classes, preg_replace("/'name' => 'Vanguard',\n/", "'name' => 'Vanguard',\n    'skillsToLearn' => [['level' => 2, 'skill' => 'Rally']],\n", (string) file_get_contents($classes), 1));
+    $before = (string) file_get_contents($actor);
+    $session = EditorSession::open($root);
+
+    $question = $session->adoptMasterySkills('Kaelion');
+    $session->adoptMasterySkills('Kaelion', 'write', $question['confirm']);
+    $map = MasteryCatalog::load($root . '/assets')->findMap('Kaelion');
+    $abilities = (require $actor)['data']['abilities'];
+
+    // Terms go to the tree node that teaches each skill; a skill the tree does not place yet joins the first region's trunk.
+    expect($question['question'])->toContain('Cut, Lunge, Spark')
+        ->and(array_map(static fn($terms) => [$map->nodes[$terms->id]->skill, $terms->cost], array_values($map?->terms ?? [])))
+            ->toBe([['Cut', 0], ['Lunge', 0], ['Spark', 300]])
+        ->and($map->nodes['lunge']->region)->toBe('blades')
+        ->and($abilities)->toBe(['learned' => [], 'learnables' => []]);
+
+    $session->undo();
+    expect(file_get_contents($actor))->toBe($before)
+        ->and(file_get_contents($root . '/assets/Data/mastery.php'))->toBe($settings)
+        ->and(glob($root . '/assets/Data/Mastery/*.php') ?: [])->toBe([]);
+});
+
+it('gives the map page every node\'s place, moves a node and links two nodes, each one undo step', function () {
+    $root = masteryProject();
+    mkdir($root . '/assets/Data/Mastery');
+    file_put_contents($root . '/assets/Data/Mastery/kaelion.php', " 'Kaelion',\n  'nodes' => [\n    ['id' => 'cut', 'cost' => 0, 'opensAtLevel' => 1],\n    ['id' => 'spark', 'cost' => 50, 'opensAtLevel' => 2],\n  ],\n];\n");
+    $session = EditorSession::open($root);
+    $page = $session->readMasteryPage(0);
+
+    expect(array_column($page['regions'], 'id'))->toBe(['blades', 'storms'])
+        ->and($page['pointsLabel'])->toBe('SP')
+        ->and($page['levelLabel'])->toBe('Lv')
+        ->and(array_map(static fn(array $node): array => [$node['id'], $node['name'], $node['region'], $node['x'], $node['placed']], $page['nodes']))
+            ->toBe([['cut', 'Cut', 'blades', 0.25, false], ['spark', 'Spark', 'storms', 0.75, false]]);
+
+    $session->placeMasteryNode(0, 'spark', 0.6, 0.4);
+    $session->toggleMasteryRequirement(0, 'spark', 'cut');
+    $moved = array_column($session->readMasteryPage(0)['nodes'], null, 'id')['spark'];
+
+    expect([$moved['x'], $moved['y'], $moved['placed'], $moved['requires']])->toBe([0.6, 0.4, true, ['cut']])
+        ->and(fn() => $session->toggleMasteryRequirement(0, 'cut', 'spark'))->toThrow(SessionRefusal::class, 'loop')
+        ->and(fn() => $session->placeMasteryNode(0, 'cut', 1.5, 0.2))->toThrow(SessionRefusal::class, 'between 0 and 1');
+
+    $session->undo();
+    $session->undo();
+    expect(array_column($session->readMasteryPage(0)['nodes'], null, 'id')['spark']['placed'])->toBeFalse();
+});
+
+it('names prices and levels on the map page in the project\'s own words for Mastery points and the level', function () {
+    $root = masteryProject();
+    $config = $root . '/config.php';
+    file_put_contents($config, preg_replace("/'vocab' => \\[/", "'vocab' => [\n    'mastery' => ['points' => 'JP'],\n    'stats' => ['level_short' => 'Lvl'],", (string) file_get_contents($config), 1));
+    mkdir($root . '/assets/Data/Mastery');
+    file_put_contents($root . '/assets/Data/Mastery/kaelion.php', " 'Kaelion', 'nodes' => [['id' => 'cut']]];\n");
+    $page = EditorSession::open($root)->readMasteryPage(0);
+
+    expect([$page['pointsLabel'], $page['levelLabel']])->toBe(['JP', 'Lvl']);
+});
+
+it('gives an enemy and a quest the Mastery points they reward, and leaves records without them unchanged', function () {
+    $root = masteryProject();
+    writeRegularBatEnemy($root);
+    $session = EditorSession::open($root);
+    $enemy = $session->readDatabaseRecord('enemies', 0);
+    $quest = $session->readDatabaseRecord('quests', 0);
+    $enemySource = (string) file_get_contents($root . '/assets/Data/Enemies/regular-bat.php');
+
+    expect(masteryRow($enemy, 'rewards.skillPoints')['label'] ?? null)->toBe('Reward SP')
+        ->and(masteryRow($quest, 'rewards.skillPoints')['label'] ?? null)->toBe('Reward SP')
+        ->and($enemySource)->not->toContain('skillPoints');
+
+    $session->applyDatabaseRecord('enemies', 0, masteryRow($enemy, 'rewards.skillPoints')['key'], '12');
+    $session->applyDatabaseRecord('quests', 0, masteryRow($quest, 'rewards.skillPoints')['key'], '30');
+    $session->saveDatabase('enemies');
+    $session->saveDatabase('quests');
+
+    expect((require $root . '/assets/Data/Enemies/regular-bat.php')['data']['rewards']['skillPoints'] ?? null)->toBe(12)
+        ->and((require $root . '/assets/Data/quests.php')[0]['rewards']['skillPoints'] ?? null)->toBe(30);
+});
+
+it('warns where the tree prices or opens a rung below the one it builds on, and stays quiet for a rising ladder', function (array $terms, array $flagged) {
+    $root = masteryProject();
+    writeSkillRecords($root, new SpecialSkill('Cut', 'Cuts.', '', 2, 0), new MagicSkill('Spark', 'Shocks.', '', 4, 0),
+        new MagicSkill('Bolt', 'Shocks more.', '', 8, 0), new MagicSkill('Storm Call', 'Shocks all.', '', 16, 0));
+    $node = static fn(string $id, array $data): array => ['id' => $id, ...$data, ...$terms[$id]];
+    file_put_contents($root . '/assets/Data/mastery.php', ' [['id' => 'storms', 'name' => 'Storms', 'description' => 'Weather.']], 'branches' => [
+        ['id' => 'storm', 'region' => 'storms', 'name' => 'Storm', 'kind' => 'aspect', 'description' => 'Storms.', 'nodes' => [
+            $node('storm', ['kind' => 'foundation', 'name' => 'Storm']),
+            $node('spark', ['kind' => 'application', 'grade' => 1, 'skill' => 'Spark']),
+            $node('bolt', ['kind' => 'application', 'grade' => 2, 'skill' => 'Bolt']),
+            $node('storm-call', ['kind' => 'advanced', 'skill' => 'Storm Call']),
+        ]],
+    ]], true) . ';');
+    $issues = array_values(array_filter(new \Ichiloto\Editor\Validation\MasteryValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)),
+        static fn($issue): bool => $issue->code === \Ichiloto\Editor\Validation\MasteryValidator::LADDER));
+
+    expect(array_map(static fn($issue): string => strstr($issue->message, ' (', true), $issues))->toBe($flagged);
+})->with([
+    'rising' => [['storm' => ['cost' => 10, 'opensAtLevel' => 2], 'spark' => ['cost' => 20, 'opensAtLevel' => 3],
+        'bolt' => ['cost' => 90, 'opensAtLevel' => 6], 'storm-call' => ['cost' => 1045, 'opensAtLevel' => 20]], []],
+    'a grade cheaper than the grade below' => [['storm' => ['cost' => 10, 'opensAtLevel' => 2], 'spark' => ['cost' => 20, 'opensAtLevel' => 3],
+        'bolt' => ['cost' => 15, 'opensAtLevel' => 6], 'storm-call' => ['cost' => 1045, 'opensAtLevel' => 20]], ['Bolt']],
+    'an advanced application opening before the highest grade' => [['storm' => ['cost' => 10, 'opensAtLevel' => 2], 'spark' => ['cost' => 20, 'opensAtLevel' => 3],
+        'bolt' => ['cost' => 90, 'opensAtLevel' => 6], 'storm-call' => ['cost' => 1045, 'opensAtLevel' => 5]], ['Storm Call']],
+    'an unpriced rung is not compared' => [['storm' => [], 'spark' => ['cost' => 5, 'opensAtLevel' => 1], 'bolt' => [], 'storm-call' => ['cost' => 1, 'opensAtLevel' => 1]], []],
+]);
+
+it('reports what awaits an author: draft skills, unpriced nodes, ungranted foundations and terms that skip a chain step', function () {
+    $root = masteryProject();
+    file_put_contents($root . '/assets/Data/Skills/0099-tempest.php', ' \Ichiloto\Engine\Entities\Skills\Skill::class,
+        'data' => ['kind' => 'magic', 'name' => 'Tempest', 'description' => '', 'icon' => '', 'cooldown' => 0, 'occasion' => 'Battle Screen',
+            'scope' => ['side' => 'Enemy', 'number' => 'All', 'status' => 'Alive'], 'invocation' => ['speed' => 0, 'accuracy' => 0, 'repeat' => 1, 'apGain' => 0],
+            'effects' => [['type' => 'hp_damage']], 'unassigned' => ['cost', 'effects.0.formula']]], true) . ';');
+    file_put_contents($root . '/assets/Data/mastery.php', ' [['id' => 'storms', 'name' => 'Storms', 'description' => 'Weather.']], 'branches' => [
+        ['id' => 'storm', 'region' => 'storms', 'name' => 'Storm', 'kind' => 'aspect', 'description' => 'Storms.', 'nodes' => [
+            ['id' => 'storm', 'kind' => 'foundation', 'name' => 'Storm'],
+            ['id' => 'spark', 'kind' => 'application', 'grade' => 1, 'skill' => 'Spark', 'cost' => 40],
+            ['id' => 'tempest', 'kind' => 'advanced', 'skill' => 'Tempest'],
+        ]],
+    ]], true) . ';');
+    mkdir($root . '/assets/Data/Mastery');
+    file_put_contents($root . '/assets/Data/Mastery/kaelion.php', " 'Kaelion', 'nodes' => [['id' => 'spark', 'cost' => 0]]];\n");
+    $issues = new \Ichiloto\Editor\Validation\MasteryValidator()->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root));
+    $codes = array_map(static fn($issue): ?string => $issue->code, $issues);
+
+    expect(array_filter($issues, static fn($issue): bool => $issue->severity === \Ichiloto\Editor\Validation\Severity::ERROR))->toBe([])
+        ->and($codes)->toBe(['skill.unassigned', 'mastery.unpriced', 'mastery.foundation-ungranted', 'mastery.unchained'])
+        ->and($issues[0]->message)->toContain('Tempest awaits author values (cost, effects.0.formula)')
+        ->and($issues[1]->message)->toContain('Storm, Tempest')
+        ->and($issues[3]->message)->toContain('Kaelion learns Spark without Storm');
+});
+
+it('warns where a discipline, its own trunk or a branch has no summary for Info, and edits the summaries', function () {
+    $root = masteryProject();
+    $validate = static fn(): array => array_values(array_filter(new \Ichiloto\Editor\Validation\MasteryValidator()
+        ->validate(\Ichiloto\Editor\ProjectWorkspace::fromProject($root)), static fn($issue): bool => $issue->code === 'mastery.summary-missing'));
+
+    expect(array_map(static fn($issue): string => $issue->message, $validate()))->toBe([
+        'Blades has no summary: Info offers nothing on it.', 'The Blades trunk has no summary: Info offers nothing on the family its own nodes form.',
+        'Storms has no summary: Info offers nothing on it.', 'The Storms trunk has no summary: Info offers nothing on the family its own nodes form.',
+    ])->and($validate()[0]->severity)->toBe(\Ichiloto\Editor\Validation\Severity::WARNING);
+
+    $session = EditorSession::open($root);
+    foreach ([0, 1] as $index) {
+        foreach (['description' => 'What it covers.', 'trunkDescription' => 'What its own skills cover.'] as $field => $text) {
+            $session->applyDatabaseRecord(MasteryFields::REGIONS, $index, masteryRow($session->readDatabaseRecord(MasteryFields::REGIONS, $index), $field)['key'], $text);
+        }
+    }
+    $session->saveDatabase(MasteryFields::REGIONS);
+    MasteryCatalog::forgetProjectCatalogs();
+
+    expect($validate())->toBe([])
+        ->and(MasteryCatalog::load($root . '/assets')->tree->regions[1]->trunkDescription)->toBe('What its own skills cover.');
+});
+
+it('moves a branch within another branch of its region as a sub-family, keeping its node ids, and refuses one within another region', function () {
+    $root = masteryProject();
+    writeSkillRecords($root, new SpecialSkill('Cut', 'Cuts.', '', 2, 0), new MagicSkill('Spark', 'Shocks.', '', 4, 0),
+        new MagicSkill('Haste', 'Hurries.', '', 6, 0));
+    $settings = $root . '/assets/Data/mastery.php';
+    // An authored source with a comment the move must keep.
+    file_put_contents($settings, str_replace("];\n", "  'branches' => [\n    // Odd forces.\n    ['id' => 'odd', 'region' => 'storms', 'name' => 'Odd', 'kind' => 'non-elemental', 'nodes' => []],\n"
+        . "    ['id' => 'hours', 'region' => 'blades', 'name' => 'Hours', 'kind' => 'aspect', 'nodes' => [\n      ['id' => 'haste', 'kind' => 'application', 'skill' => 'Haste'],\n    ]],\n  ],\n];\n",
+        file_get_contents($settings)));
+    $session = EditorSession::open($root);
+    $branch = static fn(): array => $session->readDatabaseRecord(MasteryFields::BRANCHES, 1);
+    $choices = array_column($session->listReferences(null, MasteryFields::BRANCHES), 'value');
+
+    // Within a branch of another region, the Engine could not read the tree.
+    $session->applyDatabaseRecord(MasteryFields::BRANCHES, 1, masteryRow($branch(), 'within')['key'], 'odd');
+    expect($choices)->toBe(['odd', 'hours'])
+        ->and(fn() => $session->saveDatabase(MasteryFields::BRANCHES))->toThrow(SessionRefusal::class, 'in another region');
+
+    $session->applyDatabaseRecord(MasteryFields::BRANCHES, 1, masteryRow($branch(), 'region')['key'], 'storms');
+    $session->saveDatabase(MasteryFields::BRANCHES);
+    $tree = MasteryCatalog::load($root . '/assets')->tree;
+    $board = new \Ichiloto\Engine\Progression\Mastery\MasteryBoard($tree);
+
+    expect($tree->branches['hours']->within)->toBe('odd')
+        ->and($tree->nodes['haste']->region)->toBe('storms')
+        ->and(array_map(static fn($family): string => $family->id, $board->getFamilies('storms')))->toBe(['odd', 'trunk:storms'])
+        ->and($board->findFamily('odd')->nodes)->toBe(['haste'])
+        ->and(file_get_contents($settings))->toContain('// Odd forces.')->toContain("'within' => 'odd'");
+});
diff --git a/tests/Unit/Session/MenuThemeSessionTest.php b/tests/Unit/Session/MenuThemeSessionTest.php
new file mode 100644
index 00000000..52b602f5
--- /dev/null
+++ b/tests/Unit/Session/MenuThemeSessionTest.php
@@ -0,0 +1,154 @@
+ 'ichiloto.menu/1',
+  'showInputHints' => true, // A setting another page owns.
+  'atlas' => [
+    'margin' => 24, // Breathing room.
+  ],
+];
+PHP);
+    return [$root, $file, EditorSession::open($root)];
+}
+
+/** The index of a theme setting by its path. */
+function findThemeSetting(EditorSession $session, string $path): int
+{
+    $labels = $session->listDatabaseRecords(MenuThemeFields::CATEGORY)['records'];
+    $index = array_search($path, $labels, true);
+    return is_int($index) ? $index : throw new RuntimeException('No theme setting ' . $path);
+}
+
+function setThemeSetting(EditorSession $session, string $path, string $value): array
+{
+    $index = findThemeSetting($session, $path);
+    $row = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value');
+    return $session->applyDatabaseRecord(MenuThemeFields::CATEGORY, $index, $row['key'], $value);
+}
+
+it('offers every atlas setting with the Engine default, authored ones first, only in the graphical editor', function () {
+    [, , $session] = createMenuThemeProject();
+    $labels = $session->listDatabaseRecords(MenuThemeFields::CATEGORY)['records'];
+    $engine = array_map(static fn(string $key): string => 'atlas.' . $key, array_keys(get_object_vars(new MapAtlasTheme())));
+    expect($labels[0])->toBe('atlas.margin')
+        ->and(array_values(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'atlas.'))))->toEqualCanonicalizing($engine);
+    $frame = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.frameRole'))['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value');
+    expect($frame['options'])->toBe(MapAtlasTheme::FRAME_ROLES);
+    $margin = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.margin'))['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value');
+    expect($margin['label'])->toBe(sprintf('Pixels (0..%d)', MapAtlasTheme::MAX_MARGIN));
+    // The Terminal edits no graphical presentation.
+    expect(array_map(static fn($category): string => $category->key, DatabaseCatalog::all()))->not->toContain(MenuThemeFields::CATEGORY)
+        ->and(array_map(static fn($category): string => $category->key, DatabaseCatalog::getGraphicalCategories()))->toContain(MenuThemeFields::CATEGORY);
+});
+
+it('validates each value with its neighbours through the Engine, saves only changed leaves and undoes', function () {
+    [$root, $file, $session] = createMenuThemeProject();
+    $default = (new MapAtlasTheme())->largeWidth;
+    expect(fn() => setThemeSetting($session, 'atlas.compactWidth', (string) ($default + 1)))->toThrow(SessionRefusal::class);
+    expect(fn() => setThemeSetting($session, 'atlas.markerSize', '99'))->toThrow(SessionRefusal::class);
+
+    setThemeSetting($session, 'atlas.largeWidth', '600');
+    setThemeSetting($session, 'atlas.compactWidth', '500');
+    setThemeSetting($session, 'atlas.margin', '20');
+    setThemeSetting($session, 'atlas.frameRole', 'panel');
+    $session->undo();
+    $session->saveDatabase(MenuThemeFields::CATEGORY);
+    $source = (string) file_get_contents($file);
+    expect($source)->toContain('// Theme header stays authored.', '// A setting another page owns.', '// Breathing room.')
+        ->and($source)->not->toContain('frameRole');
+    $saved = (require $file)['atlas'];
+    ksort($saved);
+    expect($saved)->toBe(['compactWidth' => 500, 'largeWidth' => 600, 'margin' => 20]);
+    expect(MapAtlasTheme::fromArray($saved)->compactWidth)->toBe(500);
+
+    // Choosing the default for a setting the theme leaves out writes nothing.
+    setThemeSetting($session, 'atlas.markerSize', (string) (new MapAtlasTheme())->markerSize);
+    expect($session->listDatabaseRecords(MenuThemeFields::CATEGORY)['dirty'])->toBeFalse();
+    expect(EditorSession::open($root)->listDatabaseRecords(MenuThemeFields::CATEGORY)['records'][0])->toBe('atlas.margin');
+});
+
+it('is read-only, saying why, in a project without a menu theme', function () {
+    $root = makeTemporaryProject('editor-menu-theme-none-');
+    $listing = EditorSession::open($root)->listDatabaseRecords(MenuThemeFields::CATEGORY);
+    expect($listing['editable'])->toBeFalse()
+        ->and($listing['readOnly'])->toContain(MenuPresentationSettings::RELATIVE_PATH)
+        ->and($listing['records'])->toBe([]);
+});
+
+it('binds atlas marker images by role, the project\'s own categories included, and unbinds them by removing the key', function () {
+    [$root, $file, $session] = createMenuThemeProject(<<<'PHP'
+ 'ichiloto.menu/1',
+  'icons' => [
+    'notification.save' => 'Graphics/Icons/Save.png', // Bound by another page.
+  ],
+];
+PHP);
+    writeTilesetTestPng($root . '/assets/Graphics/Icons/Save.png', 8, 8);
+    writeTilesetTestPng($root . '/assets/Graphics/Icons/School.png', 8, 8);
+    // A project category, unsaved, already has a marker role to bind.
+    $index = $session->createDatabaseRecord(Ichiloto\Editor\Field\MapCategoryFields::CATEGORY)['index'];
+    $id = array_find($session->readDatabaseRecord(Ichiloto\Editor\Field\MapCategoryFields::CATEGORY, $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'id');
+    $session->applyDatabaseRecord(Ichiloto\Editor\Field\MapCategoryFields::CATEGORY, $index, $id['key'], 'bard-school');
+
+    $labels = $session->listDatabaseRecords(MenuThemeFields::CATEGORY)['records'];
+    $roles = Ichiloto\Engine\UI\Presentation\MapAtlasPresentation::listIconRoles(Ichiloto\Engine\Field\MapCategoryCatalog::fromArray([
+        'schema' => Ichiloto\Engine\Field\MapCategoryCatalog::SCHEMA,
+        'categories' => ['bard-school' => ['name' => ['message' => 'm', 'default' => 'Bard School'], 'base' => 'inn']]]));
+    expect($labels)->toContain('icons.notification.save')
+        ->and(array_values(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'icons.map.'))))
+        ->toEqualCanonicalizing(array_map(static fn(string $role): string => 'icons.' . $role, $roles))
+        ->and($labels)->toContain('icons.map.category.bard-school');
+    $marker = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'icons.map.category.bard-school'))['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value');
+    expect($marker['kind'])->toBe('reference')->and($marker['reference'])->toBe('png_assets')
+        ->and($marker['noneLabel'])->toBe('(Engine pictogram)');
+
+    expect(fn() => setThemeSetting($session, 'icons.map.category.bard-school', 'Graphics/Icons/Missing.png'))->toThrow(SessionRefusal::class);
+    setThemeSetting($session, 'icons.map.category.bard-school', 'Graphics/Icons/School.png');
+    $session->saveDatabase(MenuThemeFields::CATEGORY);
+    expect((require $file)['icons'])->toBe(['notification.save' => 'Graphics/Icons/Save.png', 'map.category.bard-school' => 'Graphics/Icons/School.png'])
+        ->and(file_get_contents($file))->toContain('// Bound by another page.');
+
+    // Choosing none again removes the binding: the Engine's pictogram shows.
+    setThemeSetting($session, 'icons.map.category.bard-school', '');
+    $session->saveDatabase(MenuThemeFields::CATEGORY);
+    expect((require $file)['icons'])->toBe(['notification.save' => 'Graphics/Icons/Save.png'])
+        ->and(file_get_contents($file))->toContain('// Bound by another page.');
+});
+
+it('sets the corner map\'s opacity as a number the Engine checks, and writes it as one', function () {
+    [, $file, $session] = createMenuThemeProject();
+    $row = array_find($session->readDatabaseRecord(MenuThemeFields::CATEGORY, findThemeSetting($session, 'atlas.cornerOpacity'))['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value');
+    expect($row['kind'])->toBe('float')->and($row['label'])->toBe('Opacity (0 clear, 1 solid)');
+    expect(fn() => setThemeSetting($session, 'atlas.cornerOpacity', '1.5'))->toThrow(SessionRefusal::class);
+    setThemeSetting($session, 'atlas.cornerOpacity', '0.5');
+    $session->saveDatabase(MenuThemeFields::CATEGORY);
+    expect((require $file)['atlas']['cornerOpacity'])->toBe(0.5)
+        ->and(MapAtlasTheme::fromArray((require $file)['atlas'])->cornerOpacity)->toBe(0.5)
+        ->and(file_get_contents($file))->toContain('// Breathing room.');
+});
diff --git a/tests/Unit/Session/NpcFieldSheetSessionTest.php b/tests/Unit/Session/NpcFieldSheetSessionTest.php
new file mode 100644
index 00000000..e734bbd2
--- /dev/null
+++ b/tests/Unit/Session/NpcFieldSheetSessionTest.php
@@ -0,0 +1,255 @@
+createNpc('test-map', $session->readMap('test-map')['revision'], 2, 1, 'Gate Guard');
+
+    return [$session, 0];
+}
+
+function findFieldSheetRow(EditorSession $session, int $index): ?array
+{
+    return array_find($session->readNpc('test-map', $index)['rows'], static fn(array $row): bool => $row['label'] === 'Field Sheet');
+}
+
+/** A literal sheet alongside authored expressions, terminal headings and an independent neighbor. */
+function createNpcSheetAuthoringProject(string $asset = 'People.png', int $index = 2): array
+{
+    $root = mapGraphicsProject();
+    writeTilesetTestPng($root . '/assets/Graphics/Characters/People.png', 48, 32);
+    writeTilesetTestPng($root . '/assets/Graphics/Characters/$Guard.png', 12, 16);
+    file_put_contents($root . '/assets/Maps/collisions.php', <<<'PHP'
+ CollisionType::NONE, 'x' => CollisionType::PASS_THROUGH,
+    'buildings' => ['/' => CollisionType::NONE]];
+PHP);
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $source = " 'Test Map', 'events' => [], 'tileset' => 'home',
+        'futureValue' => abs(7),
+        'npcs' => [
+            ['id' => 'guide', 'name' => 'Guide', 'x' => 2, 'y' => 0,
+                'sprite' => 'G', 'sprites' => ['north' => '^', 'south' => 'v', 'east' => '>', 'west' => '<'],
+                'sprites2d' => ['sheet' => 'Graphics/Characters/$asset', /* chosen character */ 'index' => $index, 'layer' => 100]],
+            ['id' => 'neighbor', 'name' => 'Neighbor', 'sprite' => 'N', 'x' => 3, 'y' => 1],
+        ],
+    ];\n";
+    file_put_contents($path, $source);
+
+    return [$root, $path, EditorSession::open($root)];
+}
+
+function getNpcSheetSettingRow(EditorSession $session, string $field): ?array
+{
+    return array_find($session->readNpc('test-map', 0)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'sprites2d.' . $field);
+}
+
+function setNpcSheetSetting(EditorSession $session, string $field, string $value): array
+{
+    $row = getNpcSheetSettingRow($session, $field) ?? throw new RuntimeException('Missing sheet setting ' . $field);
+
+    return $session->applyNpc('test-map', $session->readMap('test-map')['revision'], 0, $row['key'], $value);
+}
+
+it('shows the field sheet as a picture to pick, after the terminal appearance', function () {
+    [$session, $index] = npcFieldSheetSession();
+    $row = findFieldSheetRow($session, $index);
+    $labels = array_column($session->readNpc('test-map', $index)['rows'], 'label');
+
+    expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets', 'value' => '',
+            'media' => ['kind' => 'image', 'root' => 'assets'], 'noneLabel' => 'None: the glyph shows'])
+        ->and(array_search('Field Sheet', $labels, true))->toBeGreaterThan(array_search('Sprite', $labels, true));
+});
+
+it('sets and clears the sheet as one undo step each, keeping the terminal sprite', function () {
+    [$session, $index] = npcFieldSheetSession();
+    $sprite = $session->readNpc('test-map', $index)['npc']['sprite'];
+    $key = findFieldSheetRow($session, $index)['key'];
+
+    $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, 'Graphics/Characters/$Guard.png');
+    expect(findFieldSheetRow($session, $index)['value'])->toBe('Graphics/Characters/$Guard.png')
+        ->and($session->readNpc('test-map', $index)['npc']['sprite'])->toBe($sprite);
+    $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, '');
+    expect(findFieldSheetRow($session, $index)['value'])->toBe('');
+    $session->undo();
+    expect(findFieldSheetRow($session, $index)['value'])->toBe('Graphics/Characters/$Guard.png');
+});
+
+it('refuses a picture the game could not read as a character sheet, changing nothing', function () {
+    [$session, $index] = npcFieldSheetSession();
+    $key = findFieldSheetRow($session, $index)['key'];
+
+    expect(fn() => $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, 'Graphics/Characters/Odd.png'))
+        ->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->applyNpc('test-map', $session->readMap('test-map')['revision'], $index, $key, 'Graphics/Characters/Missing.png'))
+        ->toThrow(SessionRefusal::class)
+        ->and(findFieldSheetRow($session, $index)['value'])->toBe('');
+});
+
+it('authors sheet index and graphical layer through actual session history and source-preserving save reopen', function () {
+    [$root, $path, $session] = createNpcSheetAuthoringProject();
+    $source = file_get_contents($path);
+    $before = require $path;
+    $map = loadLayeredMap($root);
+    $terminal = $map->renderPreview(20, 10);
+    $dictionary = require $root . '/assets/Maps/collisions.php';
+    $collision = \Ichiloto\Engine\Field\MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary);
+    $grids = sourceHashTree($root . '/assets/Maps/test-map/layers');
+    $row = getNpcSheetSettingRow($session, 'index');
+    expect($row)->toMatchArray(['kind' => 'options', 'options' => ['0', '1', '2', '3', '4', '5', '6', '7'], 'value' => '2'])
+        ->and(getNpcSheetSettingRow($session, 'layer'))->toMatchArray(['kind' => 'integer', 'value' => '100']);
+    setNpcSheetSetting($session, 'index', '6');
+    expect(getNpcSheetSettingRow($session, 'sheet')['imagePreview']['frames'][0]['sourceRect'])
+        ->toBe(['x' => 28, 'y' => 16, 'width' => 4, 'height' => 4]);
+    setNpcSheetSetting($session, 'layer', '999');
+    expect(file_get_contents($path))->toBe($source);
+    $session->undo();
+    expect(getNpcSheetSettingRow($session, 'layer')['value'])->toBe('100');
+    $session->undo();
+    expect(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2');
+    $session->redo();
+    $session->redo();
+    expect(setNpcSheetSetting($session, 'index', '6')['changed'])->toBeFalse();
+    $session->saveMap('test-map');
+    expect(file_get_contents($path))->toBe(str_replace(["'index' => 2", "'layer' => 100"], ["'index' => 6", "'layer' => 999"], $source));
+    $saved = require $path;
+    expect($saved['npcs'][0]['sprites2d'])->toBe(['sheet' => 'Graphics/Characters/People.png', 'index' => 6, 'layer' => 999]);
+    unset($saved['npcs'][0]['sprites2d'], $before['npcs'][0]['sprites2d']);
+    expect($saved)->toBe($before)
+        ->and(sourceHashTree($root . '/assets/Maps/test-map/layers'))->toBe($grids);
+    $map = loadLayeredMap($root);
+    expect($map->renderPreview(20, 10))->toBe($terminal)
+        ->and(\Ichiloto\Engine\Field\MapCollisionResolver::resolveLayers($map->getLayerSet(), $dictionary))->toBe($collision);
+    $reopened = EditorSession::open($root);
+    expect(getNpcSheetSettingRow($reopened, 'index')['value'])->toBe('6')
+        ->and(getNpcSheetSettingRow($reopened, 'layer')['value'])->toBe('999')
+        ->and(getNpcSheetSettingRow($reopened, 'sheet')['imagePreview'])->toBe(getNpcSheetSettingRow($session, 'sheet')['imagePreview']);
+    $inspector = new NpcInspector(ProjectWorkspace::fromProject($root)->getMapByIndex(0));
+    expect(json_encode($inspector->getFields(0)))->not->toContain('sprites2d', 'imagePreview');
+});
+
+it('removes preview and dependent controls on clear and restores them with their exact settings on undo', function () {
+    [$root, $path, $session] = createNpcSheetAuthoringProject();
+    $sheet = getNpcSheetSettingRow($session, 'sheet');
+    $indexKey = getNpcSheetSettingRow($session, 'index')['key'];
+    $layerKey = getNpcSheetSettingRow($session, 'layer')['key'];
+    setNpcSheetSetting($session, 'sheet', '');
+    expect(getNpcSheetSettingRow($session, 'sheet'))->not->toHaveKey('imagePreview')
+        ->and(getNpcSheetSettingRow($session, 'index'))->toBeNull()
+        ->and(getNpcSheetSettingRow($session, 'layer'))->toBeNull();
+    $source = sourceHashTree($root);
+    foreach ([$indexKey, $layerKey] as $key) {
+        expect(fn() => $session->applyNpc('test-map', $session->readMap('test-map')['revision'], 0, $key, '0'))
+            ->toThrow(SessionRefusal::class);
+    }
+    expect(sourceHashTree($root))->toBe($source);
+    $session->undo();
+    expect(getNpcSheetSettingRow($session, 'sheet'))->toBe($sheet)
+        ->and(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2')
+        ->and(getNpcSheetSettingRow($session, 'layer')['value'])->toBe('100');
+    $session->redo();
+    $session->saveMap('test-map');
+    expect((require $path)['npcs'][0])->not->toHaveKey('sprites2d');
+    $session->undo();
+    $session->saveMap('test-map');
+    expect(getNpcSheetSettingRow(EditorSession::open($root), 'sheet'))->toBe($sheet);
+});
+
+it('constrains single-character selection and refuses invalid values without touching source state or history', function (string $asset, int $index, string $field, string $value) {
+    [$root, $path, $session] = createNpcSheetAuthoringProject($asset, $index);
+    $before = $session->readNpc('test-map', 0);
+    $files = sourceHashTree($root);
+    expect(getNpcSheetSettingRow($session, 'index')['options'])->toBe($asset === '$Guard.png' ? ['0'] : ['0', '1', '2', '3', '4', '5', '6', '7'])
+        ->and(fn() => setNpcSheetSetting($session, $field, $value))->toThrow(SessionRefusal::class)
+        ->and($session->readNpc('test-map', 0))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($files);
+})->with([
+    ['$Guard.png', 0, 'index', '1'], ['People.png', 2, 'index', '8'], ['People.png', 2, 'index', '-1'],
+    ['People.png', 2, 'index', '1.5'], ['People.png', 2, 'index', ''],
+    ['People.png', 2, 'layer', '-1'], ['People.png', 2, 'layer', '1000'],
+    ['People.png', 2, 'layer', '2147483648'], ['People.png', 2, 'layer', 'no'],
+]);
+
+it('never silently resets the current index or layer when replacing a sheet', function () {
+    [$root, $path, $session] = createNpcSheetAuthoringProject();
+    $before = $session->readNpc('test-map', 0);
+    expect(fn() => setNpcSheetSetting($session, 'sheet', 'Graphics/Characters/$Guard.png'))->toThrow(SessionRefusal::class)
+        ->and($session->readNpc('test-map', 0))->toBe($before);
+    setNpcSheetSetting($session, 'index', '0');
+    setNpcSheetSetting($session, 'sheet', 'Graphics/Characters/$Guard.png');
+    expect(getNpcSheetSettingRow($session, 'index')['options'])->toBe(['0'])
+        ->and(getNpcSheetSettingRow($session, 'layer')['value'])->toBe('100');
+});
+
+it('reads fresh current sheet dimensions and reports unavailable previews without mutating NPC data', function (string $replacement) {
+    [$root, $path, $session] = createNpcSheetAuthoringProject();
+    $original = getNpcSheetSettingRow($session, 'sheet');
+    $npc = $session->readNpc('test-map', 0)['npc'];
+    $png = $root . '/assets/Graphics/Characters/People.png';
+    if ($replacement === 'valid') {
+        writeTilesetTestPng($png, 96, 64);
+    } elseif ($replacement === 'uneven') {
+        writeTilesetTestPng($png, 50, 50);
+    } elseif ($replacement === 'malformed') {
+        file_put_contents($png, 'not a PNG');
+    } else {
+        unlink($png);
+    }
+    $files = sourceHashTree($root);
+    $preview = getNpcSheetSettingRow($session, 'sheet')['imagePreview'];
+    if ($replacement === 'valid') {
+        expect($preview)->not->toHaveKey('issue')
+            ->and($preview['frames'][0]['sourceRect'])->toBe(['x' => 56, 'y' => 0, 'width' => 8, 'height' => 8]);
+    } else {
+        expect($preview['frames'])->toBe([])->and($preview['issue'])->not->toBeEmpty();
+        expect(fn() => setNpcSheetSetting($session, 'layer', '101'))->toThrow(SessionRefusal::class);
+    }
+    expect($session->readNpc('test-map', 0)['npc'])->toBe($npc)
+        ->and(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2')
+        ->and(getNpcSheetSettingRow($session, 'sheet')['value'])->toBe($original['value'])
+        ->and($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($files);
+})->with(['valid', 'uneven', 'malformed', 'missing']);
+
+it('refuses stale revisions stale external source and uneditable sheet source before mutation', function (string $mode) {
+    [$root, $path, $session] = createNpcSheetAuthoringProject();
+    $key = getNpcSheetSettingRow($session, 'index')['key'];
+    $revision = $session->readMap('test-map')['revision'];
+    if ($mode === 'revision') {
+        setNpcSheetSetting($session, 'layer', '101');
+    } elseif ($mode === 'external') {
+        file_put_contents($path, file_get_contents($path) . "\n// Other author.\n");
+    } else {
+        file_put_contents($path, str_replace("'index' => 2", "'index' => abs(2)", file_get_contents($path)));
+        $session = EditorSession::open($root);
+        $revision = $session->readMap('test-map')['revision'];
+    }
+    $files = sourceHashTree($root);
+    expect(fn() => $session->applyNpc('test-map', $revision, 0, $key, '6'))->toThrow(SessionRefusal::class)
+        ->and(sourceHashTree($root))->toBe($files);
+    if ($mode !== 'external') {
+        expect(getNpcSheetSettingRow($session, 'index')['value'])->toBe('2');
+    }
+    expect($session->undo()['label'])->toBe($mode === 'revision' ? 'NPC field sheet layer' : null);
+})->with(['revision', 'external', 'expression']);
diff --git a/tests/Unit/Session/NpcIdentitySessionTest.php b/tests/Unit/Session/NpcIdentitySessionTest.php
new file mode 100644
index 00000000..bffe747d
--- /dev/null
+++ b/tests/Unit/Session/NpcIdentitySessionTest.php
@@ -0,0 +1,146 @@
+ 'Liora', 'x' => 2, 'y' => 1]]);
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $revision = $request('map.read', ['map' => 'test-map'])['result']['revision'];
+
+    $created = $request('npc.create', ['map' => 'test-map', 'revision' => $revision, 'x' => 5, 'y' => 2, 'name' => 'Djin', 'id' => 'djin-contract-trial'])['result'];
+    $assigned = $request('npc.assignId', ['map' => 'test-map', 'revision' => $created['revision'], 'index' => 0, 'id' => 'liora-home'])['result'];
+    $derived = $request('npc.create', ['map' => 'test-map', 'revision' => $assigned['revision'], 'x' => 7, 'y' => 2, 'name' => 'Djin'])['result'];
+    expect($created)->toMatchArray(['changed' => true, 'index' => 1, 'id' => 'djin-contract-trial'])
+        ->and($assigned)->toMatchArray(['changed' => true, 'index' => 0, 'id' => 'liora-home'])
+        // Without an id the name still gives one.
+        ->and($derived['id'])->toBe('djin')
+        ->and($request('npc.create', ['map' => 'test-map', 'revision' => $derived['revision'], 'x' => 8, 'y' => 2, 'name' => 'X', 'id' => 4])['error']['kind'])
+            ->toBe('request');
+
+    expect($request('map.save', ['map' => 'test-map'])['result']['saved'])->toBe('test-map');
+    $source = (string) file_get_contents($root . '/assets/Maps/test-map/test-map.data.php');
+    expect($source)->toContain('// The cast stays authored.', "'djin-contract-trial'", "'liora-home'");
+    $npcs = ProjectWorkspace::fromProject($root)->getMapByIndex(0)?->getNpcs();
+    expect(array_map(static fn($npc): array => [$npc->getId(), $npc->getName()], $npcs?->all() ?? []))
+        ->toBe([['liora-home', 'Liora'], ['djin-contract-trial', 'Djin'], ['djin', 'Djin']]);
+});
+
+it('refuses a chosen id that is no id or that another NPC on the map has, changing nothing', function () {
+    $session = EditorSession::open(npcIdentityProject([
+        ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1],
+        ['name' => 'Old Man', 'x' => 6, 'y' => 3],
+    ]));
+    $revision = $session->readMap('test-map')['revision'];
+    $row = npcSessionRow($session->readNpc('test-map', 0), 'id');
+
+    expect(fn() => $session->createNpc('test-map', $revision, 4, 2, 'Guard', 'Gate Guard'))->toThrow(SessionRefusal::class, '"Gate Guard" is not an NPC id')
+        ->and(fn() => $session->createNpc('test-map', $revision, 4, 2, 'Guard', 'ann'))->toThrow(SessionRefusal::class, 'Ann already has the id "ann" on this map.')
+        ->and(fn() => $session->createNpc('test-map', $revision, 4, 2, 'Guard', '  '))->toThrow(SessionRefusal::class, 'is not an NPC id')
+        ->and(fn() => $session->assignNpcId('test-map', $revision, 1, 'ann'))->toThrow(SessionRefusal::class, 'Ann already has the id "ann"')
+        ->and(fn() => $session->assignNpcId('test-map', $revision, 1, 'old--man'))->toThrow(SessionRefusal::class, 'is not an NPC id')
+        ->and(fn() => $session->assignNpcId('test-map', $revision, 0, 'anna'))->toThrow(SessionRefusal::class, 'change it through its Id row')
+        ->and(fn() => $session->applyNpc('test-map', $revision, 0, $row['key'], 'café'))->toThrow(SessionRefusal::class, 'is not an NPC id')
+        ->and($session->applyNpc('test-map', $revision, 0, $row['key'], 'ann'))->toMatchArray(['changed' => false, 'revision' => $revision])
+        ->and(npcSessionIds($session))->toBe(['ann', null])
+        ->and($session->hasUnsavedChanges())->toBeFalse();
+});
+
+it('changes an id nothing names through the Id row as one undo step', function () {
+    $session = EditorSession::open(npcIdentityProject([['id' => 'nivira', 'name' => 'Nivira', 'x' => 2, 'y' => 1]]));
+    $read = $session->readNpc('test-map', 0);
+
+    $applied = $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'id')['key'], ' nivira-morning-visit ');
+    expect($applied)->toMatchArray(['changed' => true, 'id' => 'nivira-morning-visit', 'followedId' => null])
+        ->and(npcSessionRow($session->readNpc('test-map', 0), 'id')['raw'])->toBe('nivira-morning-visit')
+        ->and($session->undo()['label'])->toBe('NPC id nivira-morning-visit')
+        ->and(npcSessionIds($session))->toBe(['nivira'])
+        ->and($session->redo()['maps'])->toBe(['test-map'])
+        ->and(npcSessionIds($session))->toBe(['nivira-morning-visit']);
+});
+
+it('refuses to change an id a route, a script or a dialogue event names, naming each', function () {
+    $session = EditorSession::open(npcIdentityProject(
+        [
+            ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1],
+            ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [
+                ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'ann', 'steps' => [['direction' => 'north']]],
+            ]],
+        ],
+        ['T' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger', 'data' => ['npcId' => 'ann']]],
+    ));
+    $read = $session->readNpc('test-map', 0);
+
+    expect(fn() => $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'id')['key'], 'ann-at-home'))
+        ->toThrow(SessionRefusal::class, "Ann keeps the id \"ann\": test-map event T, NPC Bob script name it. Change those first.\n- test-map event T\n- NPC Bob script")
+        ->and(npcSessionIds($session))->toBe(['ann', 'bob'])
+        ->and($session->hasUnsavedChanges())->toBeFalse();
+});
+
+it('keeps a chosen id when the NPC is renamed, and still carries a derived one', function () {
+    $session = EditorSession::open(npcIdentityProject([
+        ['id' => 'djin-contract-trial', 'name' => 'Djin', 'x' => 2, 'y' => 1],
+        ['id' => 'mara', 'name' => 'Mara', 'x' => 6, 'y' => 3],
+    ]));
+    $read = $session->readNpc('test-map', 0);
+    $kept = $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'name')['key'], 'Djinn');
+    $other = $session->readNpc('test-map', 1);
+    $followed = $session->applyNpc('test-map', $kept['revision'], 1, npcSessionRow($other, 'name')['key'], 'Old Mara');
+
+    expect($kept)->toMatchArray(['changed' => true, 'id' => 'djin-contract-trial', 'followedId' => null, 'idReferences' => []])
+        ->and($followed)->toMatchArray(['id' => 'old-mara', 'followedId' => 'old-mara'])
+        ->and(npcSessionIds($session))->toBe(['djin-contract-trial', 'old-mara']);
+});
+
+it('keeps and refuses to change an id only an event script reached through common_event chains names', function (array $bob, array $events, array $scripts) {
+    $root = npcIdentityProject([
+        ['id' => 'mara', 'name' => 'Mara', 'x' => 2, 'y' => 1],
+        ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, ...$bob],
+    ], $events);
+    writeNpcEventScripts($root, $scripts);
+    $session = EditorSession::open($root);
+    $read = $session->readNpc('test-map', 0);
+
+    expect(fn() => $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'id')['key'], 'mara-at-home'))
+        ->toThrow(SessionRefusal::class, 'Mara keeps the id "mara": event script b (via event script a) names it.')
+        ->and(npcSessionIds($session))->toBe(['mara', 'bob']);
+
+    $renamed = $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'name')['key'], 'Old Mara');
+    expect($renamed)->toMatchArray(['changed' => true, 'id' => 'mara', 'followedId' => null, 'idReferences' => ['event script b (via event script a)']])
+        ->and(npcSessionIds($session))->toBe(['mara', 'bob']);
+})->with(npcEventScriptChains());
+
+it('lets a derived id follow its name when no script this map reaches names it', function () {
+    $root = npcIdentityProject([['id' => 'mara', 'name' => 'Mara', 'x' => 2, 'y' => 1]]);
+    writeNpcEventScripts($root, npcEventScriptChains()['a cycle between a and b'][2]);
+    $session = EditorSession::open($root);
+    $read = $session->readNpc('test-map', 0);
+
+    expect($session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'name')['key'], 'Old Mara'))
+        ->toMatchArray(['id' => 'old-mara', 'followedId' => 'old-mara', 'idReferences' => []]);
+});
diff --git a/tests/Unit/Session/NpcPageSessionTest.php b/tests/Unit/Session/NpcPageSessionTest.php
new file mode 100644
index 00000000..397c730d
--- /dev/null
+++ b/tests/Unit/Session/NpcPageSessionTest.php
@@ -0,0 +1,203 @@
+ 'Ichiloto\\Engine\\Events\\Triggers\\DialogueEventTrigger',
+    'data' => ['dialogue' => [['name' => '', 'text' => 'A notice.']]]]): string
+{
+    $root = mapGraphicsProject();
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    $data = require $path;
+    $data['events'] = ['E' => $event];
+    $data['npcs'] = [
+        ['id' => 'mentor', 'name' => 'Mentor', 'sprite' => '', 'x' => 2, 'y' => 1, 'dialogue' => [['text' => 'Ready when you are.']]],
+        ['id' => 'aide', 'name' => 'Aide', 'sprite' => '', 'x' => 1, 'y' => 1, 'dialogue' => [['text' => 'Hm.']]],
+    ];
+    file_put_contents($path, " [['type' => 'text', 'name' => 'Mentor', 'text' => 'Your first assignment.']]]);
+
+    return $root;
+}
+
+it('authors a story page that runs an event script once with its own cue, and the Engine reads it back as authored', function () {
+    $root = npcPageProject();
+    $session = EditorSession::open($root);
+    $revision = $session->readMap('test-map')['revision'];
+    $added = $session->addNpcItem('test-map', $revision, 0, npcSessionRow($session->readNpc('test-map', 0), 'variant0Conditions')['key']);
+    $apply = static function (string $field, mixed $value) use ($session): void {
+        $session->applyNpc('test-map', $session->readMap('test-map')['revision'], 0, npcSessionRow($session->readNpc('test-map', 0), $field)['key'], $value);
+    };
+    expect($added['changed'])->toBeTrue();
+
+    // The new page goes after the first; the story page must lead, so the ordinary lines move behind it.
+    $apply('variant1Conditions', 'event:arrived');
+    $apply('variant1ScriptId', 'briefing');
+    $apply('variant1Reusable', 'false');
+    $apply('variant1Id', 'briefing');
+    $apply('variant1Speakers', 'aide');
+    $apply('variant1Cuesymbol', '!');
+    $session->saveMap('test-map');
+    $npc = (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs'][0];
+    $pages = NpcPages::fromEntry($npc);
+
+    expect($npc)->not->toHaveKey('cue')
+        ->and($pages->pages)->toHaveCount(2)
+        ->and($pages->pages[1]->id)->toBe('briefing')->and($pages->pages[1]->scriptId)->toBe('briefing')
+        ->and($pages->pages[1]->reusable)->toBeFalse()->and($pages->pages[1]->speakers)->toBe(['aide'])
+        ->and($pages->pages[1]->cue?->symbol)->toBe('!')
+        // Page 1 is unconditional, so the Engine's rule says page 2 never runs until the author reorders them.
+        ->and(array_column(Ichiloto\Engine\Field\Validation\MapNpcInteractionRule::inspectPages('test-map', ['npcs' => (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs']]), 'rule'))
+            ->toBe(['npc.page']);
+});
+
+it('saves an NPC page list through a change set and refuses pages the Engine would refuse', function () {
+    $root = npcPageProject();
+    $page = static fn(string $field): array => ['target' => 'npc', 'field' => $field, 'frame' => []];
+    $write = static fn(array $changes): array => ChangeBatch::apply($root, $changes);
+    $apply = static fn(string $field, string $value): array => ['method' => 'npc.apply', 'params' => ['map' => 'test-map', 'index' => 0,
+        'key' => $page($field), 'value' => $value]];
+    // The new page lands after the ordinary lines; the story page moves ahead of them, as talking runs the first.
+    $once = [
+        ['method' => 'npc.add', 'params' => ['map' => 'test-map', 'index' => 0, 'key' => $page('variant0Conditions')]],
+        $apply('variant1Conditions', 'event:arrived'),
+        $apply('variant1ScriptId', 'briefing'),
+        $apply('variant1Reusable', 'false'),
+        $apply('variant1Cuesymbol', '!'),
+        ['method' => 'npc.remove', 'params' => ['map' => 'test-map', 'index' => 0, 'key' => $page('variant1Line0Text')]],
+        ['method' => 'npc.reorder', 'params' => ['map' => 'test-map', 'index' => 0, 'key' => $page('variant1Conditions'), 'step' => -1]],
+    ];
+
+    $refused = $write($once);
+    expect($refused['status'])->toBe('refused')
+        ->and(implode("\n", array_column($refused['issues'], 'message')))->toContain('needs a stable id')
+        ->and((string) file_get_contents($root . '/assets/Maps/test-map/test-map.data.php'))->not->toContain("'reusable'");
+
+    $saved = $write([...$once, $apply('variant0Id', 'briefing')]);
+    $briefing = (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs'][0]['dialogue'][0];
+    $source = (string) file_get_contents($root . '/assets/Maps/test-map/test-map.data.php');
+    $npcs = (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs'];
+    $pages = NpcPages::fromEntry($npcs[0]);
+
+    expect($saved['status'])->toBe('saved')
+        ->and($pages->pages[0]->id)->toBe('briefing')->and($pages->pages[0]->cue?->symbol)->toBe('!')
+        ->and($pages->pages[1]->lines)->toBe([['text' => 'Ready when you are.']])
+        // The aide was not touched: still authored as plain lines.
+        ->and($npcs[1]['dialogue'])->toBe([['text' => 'Hm.']])
+        ->and($source)->toContain("'scriptId' => 'briefing'")
+        ->and($briefing)->not->toHaveKey('lines');
+});
+
+it('refuses a player-started event an NPC delivers from a separate cell, pointing at the NPC pages', function () {
+    $root = npcPageProject(['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger',
+        'data' => ['mode' => 'action', 'scriptId' => 'briefing'], 'speakers' => ['mentor']]);
+
+    $result = ChangeBatch::apply($root, [['method' => 'npc.apply', 'params' => ['map' => 'test-map', 'index' => 1,
+        'key' => ['target' => 'npc', 'field' => 'name', 'frame' => []], 'value' => 'Aide Kemi']]]);
+    $issues = array_filter($result['issues'] ?? [], static fn(array $issue): bool => ($issue['code'] ?? '') === 'event.npc-interaction');
+
+    expect($issues)->not->toBe([])
+        ->and(implode("\n", array_column($issues, 'message')))->toContain("Mentor's to deliver");
+});
+
+it('keeps an NPC named as a staged speaker from being renamed away', function () {
+    $root = npcSessionProject([
+        ['id' => 'mentor', 'name' => 'Mentor', 'sprite' => 'M', 'x' => 2, 'y' => 1, 'dialogue' => [
+            ['speakers' => ['aide'], 'lines' => [['text' => 'Both of you, listen.']]],
+        ]],
+        ['id' => 'aide', 'name' => 'Aide', 'sprite' => 'A', 'x' => 3, 'y' => 1, 'dialogue' => [['text' => 'Hm.']]],
+    ]);
+    $session = EditorSession::open($root);
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->applyNpc('test-map', $revision, 1, npcSessionRow($session->readNpc('test-map', 1), 'id')['key'], 'clerk'))
+        ->toThrow(SessionRefusal::class, 'NPC Mentor page 1 (speaker)');
+});
+
+it('marks the rows of a page, not of its lines, as ordered for the graphical editor', function () {
+    $session = EditorSession::open(npcPageProject());
+    $rows = $session->readNpc('test-map', 0)['rows'];
+    $ordered = static fn(string $field): bool => (array_find($rows, static fn(array $row): bool => ($row['key']['field'] ?? null) === $field)['ordered'] ?? false) === true;
+
+    expect($ordered('variant0Conditions'))->toBeTrue()->and($ordered('variant0Cuesymbol'))->toBeTrue()
+        ->and($ordered('variant0Line0Text'))->toBeFalse()->and($ordered('name'))->toBeFalse();
+});
+
+it('moves a one-shot story event onto its NPC as the first page, carrying its saved completion, in one change set', function () {
+    $root = npcPageProject(['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', 'conditions' => [['type' => 'event', 'name' => 'arrived']],
+        'cue' => ['symbol' => '!', 'color' => 'bright-yellow'], 'whenBlocked' => 'Not yet.',
+        'data' => ['mode' => 'action', 'reusable' => false, 'scriptId' => 'briefing'], 'speakers' => ['mentor', 'aide']]);
+    $events = (string) file_get_contents($root . '/assets/Maps/test-map/test-map.event.php');
+
+    $result = ChangeBatch::apply($root, [['method' => 'npc.adoptEvent', 'params' => ['map' => 'test-map', 'marker' => 'E']]]);
+    $data = require $root . '/assets/Maps/test-map/test-map.data.php';
+    $manifest = require $root . '/assets/Data/save-compatibility.php';
+    $pages = NpcPages::fromEntry($data['npcs'][0]);
+
+    expect($result['status'])->toBe('saved')
+        ->and($data['events'] ?? [])->not->toHaveKey('E')
+        ->and((string) file_get_contents($root . '/assets/Maps/test-map/test-map.event.php'))->not->toBe($events)
+        ->and(getSessionMap(EditorSession::open($root))->getEventArea('E'))->toBeNull()
+        ->and($pages->pages[0]->id)->toBe('briefing')->and($pages->pages[0]->reusable)->toBeFalse()
+        ->and($pages->pages[0]->conditions)->toBe([['type' => 'event', 'name' => 'arrived']])
+        ->and($pages->pages[0]->cue?->symbol)->toBe('!')->and($pages->pages[0]->speakers)->toBe(['aide'])
+        ->and($pages->pages[1]->lines)->toBe([['text' => 'Ready when you are.']])
+        ->and($manifest['aliases']['one_shot_events'])->toBe([['from' => 'test-map:E', 'to' => 'test-map:mentor/briefing']])
+        ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->resolveOneShotEvent('test-map:E', 'old.save'))
+            ->toBe('test-map:mentor/briefing');
+});
+
+it('reports what a moved event leaves behind and undoes the whole move as one step', function () {
+    $root = npcPageProject(['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger', 'whenBlocked' => 'Not yet.',
+        'data' => ['mode' => 'action', 'reusable' => false, 'scriptId' => 'briefing'], 'speakers' => ['mentor']]);
+    $manifest = (string) file_get_contents($root . '/assets/Data/save-compatibility.php');
+    $session = EditorSession::open($root);
+    $moved = $session->adoptEventOnNpc('test-map', $session->readMap('test-map')['revision'], 'E');
+
+    expect($moved['npc'])->toBe('mentor')->and($moved['dropped'])->toBe(['its blocked message'])
+        ->and($moved['alias'])->toBe(['from' => 'test-map:E', 'to' => 'test-map:mentor/briefing'])
+        ->and(getSessionMap($session)->getEventDefinition('E'))->toBeNull();
+    $session->undo();
+    expect(getSessionMap($session)->getEventDefinition('E'))->not->toBeNull()
+        ->and(getSessionMap($session)->getNpcs()->get(0)->getDialogue())->toBe([['text' => 'Ready when you are.']])
+        ->and((string) file_get_contents($root . '/assets/Data/save-compatibility.php'))->toBe($manifest)
+        ->and(fn() => $session->adoptEventOnNpc('test-map', $session->readMap('test-map')['revision'], 'E', 'nobody'))
+            ->toThrow(SessionRefusal::class, 'has no NPC nobody');
+});
+
+it('carries an older save alias through a repeating event that moves onto its NPC', function () {
+    $root = npcPageProject(['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger',
+        'data' => ['mode' => 'action', 'scriptId' => 'briefing'], 'speakers' => ['mentor']]);
+    file_put_contents($root . '/assets/Data/save-compatibility.php', " 0,\n  'migrations' => [],\n"
+        . "  'aliases' => [\n    'one_shot_events' => [\n      ['from' => 'old-map:B', 'to' => 'test-map:E'],\n    ],\n  ],\n  'tombstones' => [],\n];\n");
+
+    // The repeating page always answers, so the plain lines it hides go with it, as validation requires.
+    $result = ChangeBatch::apply($root, [['method' => 'npc.adoptEvent', 'params' => ['map' => 'test-map', 'marker' => 'E']],
+        ['method' => 'npc.remove', 'params' => ['map' => 'test-map', 'index' => 0, 'key' => ['target' => 'npc', 'field' => 'variant1Conditions', 'frame' => []]]]]);
+    $manifest = Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root);
+
+    expect($result['status'])->toBe('saved')
+        ->and($manifest->resolveOneShotEvent('old-map:B', 'old.save'))->toBe('test-map:mentor/briefing')
+        ->and(NpcPages::fromEntry((require $root . '/assets/Maps/test-map/test-map.data.php')['npcs'][0])->pages[0]->reusable)->toBeTrue();
+});
+
+it('names the NPC who delivers a story event for an interface to offer the move, and nobody for an object or arrival', function () {
+    $session = EditorSession::open(npcPageProject(['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger',
+        'data' => ['mode' => 'action', 'scriptId' => 'briefing'], 'speakers' => ['mentor']]));
+    $arrival = EditorSession::open(npcPageProject(['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger',
+        'data' => ['mode' => 'auto', 'scriptId' => 'briefing'], 'speakers' => ['mentor']]));
+
+    expect($session->readInspector('test-map', 'E')['deliveredBy'])->toBe(['npc' => 'mentor', 'name' => 'Mentor'])
+        ->and($arrival->readInspector('test-map', 'E')['deliveredBy'])->toBeNull()
+        ->and($session->readInspector('test-map')['deliveredBy'])->toBeNull();
+});
diff --git a/tests/Unit/Session/NpcPhaseSessionTest.php b/tests/Unit/Session/NpcPhaseSessionTest.php
new file mode 100644
index 00000000..c7c1630e
--- /dev/null
+++ b/tests/Unit/Session/NpcPhaseSessionTest.php
@@ -0,0 +1,177 @@
+readNpc('test-map', $index);
+
+    return $session->applyNpc('test-map', $read['revision'], $index, npcSessionRow($read, $fieldId)['key'], $value);
+}
+
+/** The NPC entries as the map's data file holds them. */
+function npcPhaseSource(string $root): array
+{
+    return (require $root . '/assets/Maps/test-map/test-map.data.php')['npcs'];
+}
+
+/** The validator's NPC tile issues for the map. */
+function npcPhaseTileIssues(EditorSession $session): array
+{
+    return array_values(array_filter(
+        array_column($session->validateProject(['test-map'])['issues'], 'message'),
+        static fn(?string $message): bool => str_contains((string) $message, 'same tile') || str_contains((string) $message, 'shares tile')
+            || str_contains((string) $message, 'covers the tile'),
+    ));
+}
+
+it('lets a duplicate share its original\'s tile once their conditions exclude each other, with undo and a faithful save', function () {
+    $root = npcSessionProject([
+        ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]],
+    ]);
+    $session = EditorSession::open($root);
+
+    $copy = $session->duplicateNpc('test-map', $session->readMap('test-map')['revision'], 0);
+    npcPhaseApply($session, $copy['index'], 'conditions', 'event:evidence');
+    npcPhaseApply($session, $copy['index'], 'x', '2');
+
+    expect(array_map(static fn(array $npc): array => [$npc['id'], $npc['x'], $npc['y']], $session->readMap('test-map')['npcs']))
+        ->toBe([['ivara', 2, 1], ['ivara-2', 2, 1]])
+        ->and(npcPhaseTileIssues($session))->toBe([]);
+
+    expect($session->undo()['label'])->not->toBeNull()
+        ->and($session->readMap('test-map')['npcs'][1])->toMatchArray(['x' => 3, 'y' => 1]);
+    $session->redo();
+    $session->saveMap('test-map');
+
+    $reopened = EditorSession::open($root);
+    expect(npcPhaseSource($root))->toMatchArray([
+        ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]],
+        ['id' => 'ivara-2', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence']]],
+    ])
+        ->and(array_column($reopened->readMap('test-map')['npcs'], 'x'))->toBe([2, 2])
+        ->and(npcPhaseTileIssues($reopened))->toBe([]);
+});
+
+it('refuses a second body on a tile while both NPCs can be visible', function (array $original, string $conditions) {
+    $session = EditorSession::open(npcSessionProject([
+        ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => $original],
+        ['id' => 'post', 'name' => 'Post', 'x' => 5, 'y' => 1],
+    ]));
+    npcPhaseApply($session, 1, 'conditions', $conditions);
+    $before = $session->readMap('test-map');
+
+    expect(fn() => npcPhaseApply($session, 1, 'x', '2'))
+        ->toThrow(SessionRefusal::class, 'Ivara already stands at 2,1 while both can be visible.')
+        ->and(fn() => $session->moveNpc('test-map', $session->readMap('test-map')['revision'], 1, 2, 1))
+        ->toThrow(SessionRefusal::class, 'Ivara already stands at 2,1 while both can be visible.')
+        ->and(fn() => $session->createNpc('test-map', $session->readMap('test-map')['revision'], 2, 1, 'Stranger'))
+        ->toThrow(SessionRefusal::class, 'Ivara already stands at 2,1 while both can be visible.')
+        ->and($session->readMap('test-map')['npcs'])->toBe($before['npcs']);
+})->with([
+    'neither guarded' => [[], ''],
+    'one guarded' => [[['type' => 'event', 'name' => 'evidence', 'negate' => true]], ''],
+    'the same flag' => [[['type' => 'event', 'name' => 'evidence']], 'event:evidence'],
+    'different flags' => [[['type' => 'event', 'name' => 'evidence', 'negate' => true]], 'event:dispatched'],
+    'quest statuses' => [[['type' => 'quest', 'name' => 'q', 'status' => 'active']], 'quest:q:completed'],
+    'variable ranges' => [[['type' => 'variable', 'name' => 'v', 'op' => '<', 'value' => 3]], 'variable:v:>=:3'],
+]);
+
+it('lets phases share a tile on any explicit complement the Engine evaluates', function (array $original, string $conditions) {
+    $session = EditorSession::open(npcSessionProject([
+        ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => $original],
+        ['id' => 'post', 'name' => 'Post', 'x' => 5, 'y' => 1],
+    ]));
+    npcPhaseApply($session, 1, 'conditions', $conditions);
+
+    expect(npcPhaseApply($session, 1, 'x', '2')['changed'])->toBeTrue()
+        ->and(npcPhaseTileIssues($session))->toBe([]);
+})->with([
+    'event' => [[['type' => 'event', 'name' => 'evidence']], '!event:evidence'],
+    'switch value' => [[['type' => 'switch', 'name' => 'door']], 'switch:door:false'],
+    'negated switch' => [[['type' => 'switch', 'name' => 'door', 'value' => false]], '!switch:door:false'],
+    'one of several' => [[['type' => 'item', 'name' => 'key'], ['type' => 'event', 'name' => 'evidence']], 'quest:q:active; !event:evidence'],
+    'map event' => [[['type' => 'map_event', 'name' => 'town', 'marker' => 'A']], '!map_event:town:A'],
+    'variable equality' => [[['type' => 'variable', 'name' => 'v', 'op' => '==', 'value' => 3]], 'variable:v:!=:3'],
+]);
+
+it('refuses a visibility edit that would show two phases on one tile together, changing nothing', function (int $edited, string $conditions) {
+    $root = npcSessionProject([
+        ['id' => 'ivara', 'name' => 'Ivara', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence', 'negate' => true]]],
+        ['id' => 'ivara-returned', 'name' => 'Ivara Returned', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'evidence']]],
+    ]);
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $other = $edited === 0 ? 'Ivara Returned' : 'Ivara';
+
+    expect(fn() => npcPhaseApply($session, $edited, 'conditions', $conditions))
+        ->toThrow(SessionRefusal::class, sprintf('%s already stands at 2,1 while both can be visible.', $other))
+        ->and($session->readMap('test-map')['npcs'])->toBe($before['npcs'])
+        ->and($session->hasUnsavedChanges())->toBeFalse()
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(npcPhaseTileIssues($session))->toBe([]);
+})->with([
+    'the original loses its guard' => [0, ''],
+    'the returned phase loses its guard' => [1, ''],
+    'the returned phase takes another flag' => [1, 'event:dispatched'],
+]);
+
+it('reports two NPCs anchored on one tile when nothing keeps them apart, and not phases', function () {
+    $session = EditorSession::open(npcSessionProject([
+        ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1],
+        ['id' => 'bob', 'name' => 'Bob', 'x' => 2, 'y' => 1, 'conditions' => [['type' => 'event', 'name' => 'met']]],
+        ['id' => 'cal', 'name' => 'Cal', 'x' => 6, 'y' => 1, 'conditions' => [['type' => 'switch', 'name' => 'night']]],
+        ['id' => 'dee', 'name' => 'Dee', 'x' => 6, 'y' => 1, 'conditions' => [['type' => 'switch', 'name' => 'night', 'value' => false]]],
+    ]));
+
+    expect(npcPhaseTileIssues($session))->toBe(['It shares tile (2, 1) with NPC Bob.']);
+});
+
+it('reads structured subjects and ids as references, without warnings, when duplicating and renaming', function () {
+    $root = npcSessionProject([
+        ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1],
+        ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [
+            ['type' => 'stage', 'subject' => ['kind' => 'npc', 'id' => 'ann'], 'at' => ['kind' => 'world_object', 'id' => 'desk']],
+            ['type' => 'field_animation', 'id' => ['kind' => 'npc', 'id' => 'bob']],
+            ['type' => 'common_event', 'id' => ['kind' => 'script', 'id' => 'a']],
+        ]],
+    ]);
+    $session = EditorSession::open($root);
+    $warnings = [];
+    set_error_handler(static function (int $level, string $message) use (&$warnings): bool {
+        $warnings[] = $message;
+
+        return true;
+    });
+
+    try {
+        $copy = $session->duplicateNpc('test-map', $session->readMap('test-map')['revision'], 0);
+        $renamed = npcPhaseApply($session, 0, 'name', 'Annabel');
+        $copyRenamed = npcPhaseApply($session, $copy['index'], 'name', 'Anja');
+        $refusal = null;
+
+        try {
+            $session->deleteNpc('test-map', $copyRenamed['revision'], 0);
+        } catch (SessionRefusal $caught) {
+            $refusal = $caught->getMessage();
+        }
+    } finally {
+        restore_error_handler();
+    }
+
+    expect($warnings)->toBe([])
+        ->and($renamed)->toMatchArray(['id' => 'ann', 'followedId' => null, 'idReferences' => ['NPC Bob script']])
+        ->and($copyRenamed)->toMatchArray(['id' => 'anja', 'followedId' => 'anja'])
+        ->and($refusal)->toContain('Annabel is named by NPC Bob script');
+});
diff --git a/tests/Unit/Session/NpcSessionTest.php b/tests/Unit/Session/NpcSessionTest.php
new file mode 100644
index 00000000..7281a8b4
--- /dev/null
+++ b/tests/Unit/Session/NpcSessionTest.php
@@ -0,0 +1,212 @@
+ 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]]));
+    $revision = $session->readMap('test-map')['revision'];
+
+    $created = $session->createNpc('test-map', $revision, 4, 2, 'Gate Guard');
+    expect($created)->toMatchArray(['changed' => true, 'index' => 1, 'id' => 'gate-guard'])
+        ->and(fn() => $session->createNpc('test-map', $revision, 5, 2, 'Late'))->toThrow(SessionRefusal::class, 'changed since revision');
+
+    $moved = $session->moveNpc('test-map', $created['revision'], 1, 8, 3);
+    $copy = $session->duplicateNpc('test-map', $moved['revision'], 0);
+    expect(array_find($session->readMap('test-map')['npcs'], static fn(array $npc): bool => $npc['id'] === 'gate-guard'))
+        ->toMatchArray(['x' => 8, 'y' => 3])
+        ->and($copy)->toMatchArray(['index' => 2, 'id' => 'ann-2']);
+
+    $deleted = $session->deleteNpc('test-map', $copy['revision'], 0);
+    expect($deleted)->toMatchArray(['changed' => true, 'index' => null, 'id' => 'ann'])
+        ->and(npcSessionIds($session))->toBe(['gate-guard', 'ann-2']);
+
+    expect($session->undo())->toMatchArray(['label' => 'NPC delete', 'maps' => ['test-map']])
+        ->and(npcSessionIds($session))->toBe(['ann', 'gate-guard', 'ann-2']);
+    $session->undo();
+    $session->undo();
+    expect($session->undo()['label'])->toBe('NPC create')
+        ->and(npcSessionIds($session))->toBe(['ann']);
+    expect($session->redo()['label'])->toBe('NPC create')
+        ->and(npcSessionIds($session))->toBe(['ann', 'gate-guard']);
+});
+
+it('refuses NPC changes the rules forbid, naming what stands in the way', function () {
+    $session = EditorSession::open(npcSessionProject([
+        ['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1],
+        ['id' => 'bob', 'name' => 'Bob', 'x' => 6, 'y' => 3, 'script' => [
+            ['type' => 'move_route', 'subject' => 'npc', 'npcId' => 'ann', 'steps' => [['direction' => 'north']]],
+        ]],
+    ]));
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->createNpc('test-map', $revision, 20, 1, 'Out'))->toThrow(SessionRefusal::class, '20,1 is outside the map.')
+        ->and(fn() => $session->createNpc('test-map', $revision, 2, 1, 'On Ann'))->toThrow(SessionRefusal::class, 'Ann already stands at 2,1 while both can be visible.')
+        ->and(fn() => $session->moveNpc('test-map', $revision, 0, 6, 3))->toThrow(SessionRefusal::class, 'Bob already stands at 6,3 while both can be visible.')
+        ->and(fn() => $session->duplicateNpc('test-map', $revision, 9))->toThrow(SessionRefusal::class, 'test-map has no NPC 9.')
+        ->and(fn() => $session->deleteNpc('test-map', $revision, 0))
+            ->toThrow(SessionRefusal::class, "Ann is named by NPC Bob script - resolve those before deleting.\n- NPC Bob script")
+        ->and(fn() => $session->assignNpcId('test-map', $revision, 0))->toThrow(SessionRefusal::class, 'change it through its Id row')
+        ->and($session->moveNpc('test-map', $revision, 0, 2, 1))->toMatchArray(['changed' => false, 'revision' => $revision])
+        ->and($session->hasUnsavedChanges())->toBeFalse()
+        ->and($session->undo()['label'])->toBeNull();
+});
+
+it('reads an NPC\'s rows and edits one by its key, the id following a rename', function () {
+    $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]]));
+    $read = $session->readNpc('test-map', 0);
+    $name = npcSessionRow($read, 'name');
+
+    expect($read['npc'])->toMatchArray(['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1])
+        ->and($name)->toMatchArray(['kind' => 'text', 'key' => ['target' => 'npc', 'field' => 'name', 'frame' => []]])
+        ->and(npcSessionRow($read, 'x')['kind'])->toBe('integer')
+        ->and(npcSessionRow($read, 'movement'))->toMatchArray(['kind' => 'options', 'options' => ['fixed', 'wander']])
+        ->and(npcSessionRow($read, 'id'))->toMatchArray(['kind' => 'text', 'raw' => 'ann'])
+        ->and(array_column($read['rows'], 'label'))->toContain('Identity')
+        ->and(npcSessionRow($read, 'commandListScript')['frame'])->toBe(['script']);
+
+    $applied = $session->applyNpc('test-map', $read['revision'], 0, $name['key'], 'Annabel');
+    expect($applied)->toMatchArray(['changed' => true, 'id' => 'annabel', 'followedId' => 'annabel', 'idReferences' => []])
+        ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, npcSessionRow($read, 'id')['key'], 'Not An Id'))
+            ->toThrow(SessionRefusal::class, '"Not An Id" is not an NPC id')
+        ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, ['field' => 'nope'], 'x'))
+            ->toThrow(SessionRefusal::class, 'no longer on the NPC')
+        ->and(fn() => $session->applyNpc('test-map', $applied['revision'], 0, npcSessionRow($read, 'x')['key'], '40'))
+            ->toThrow(SessionRefusal::class, '40,1 is outside the map.');
+
+    expect($session->undo()['label'])->toBe('NPC Name edit')
+        ->and($session->readNpc('test-map', 0)['npc'])->toMatchArray(['id' => 'ann', 'name' => 'Ann']);
+});
+
+it('adds and removes script commands inside a frame, and refuses a frame that is gone', function () {
+    $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1]]));
+    $revision = $session->readMap('test-map')['revision'];
+
+    $added = $session->addNpcItem('test-map', $revision, 0, ['frame' => ['script']]);
+    $script = $session->readNpc('test-map', 0, ['script']);
+    $type = npcSessionRow($script, 'command0Type');
+
+    expect($added['changed'])->toBeTrue()
+        ->and($script['frameLabel'])->toBe('Script')
+        ->and($type['key']['frame'])->toBe(['script']);
+
+    $removed = $session->removeNpcItem('test-map', $added['revision'], 0, $type['key']);
+    expect($removed['changed'])->toBeTrue()
+        ->and($session->readNpc('test-map', 0, ['script'])['rows'][0]['kind'])->toBe('info')
+        ->and(fn() => $session->readNpc('test-map', 0, [4, 'then']))->toThrow(SessionRefusal::class, 'is no longer there')
+        ->and(fn() => $session->readNpc('test-map', 3))->toThrow(SessionRefusal::class, 'test-map has no NPC 3.')
+        ->and(fn() => $session->addNpcItem('test-map', $removed['revision'], 0, ['frame' => 'script']))
+            ->toThrow(SessionRefusal::class, 'A frame must be a list');
+
+    expect($session->undo()['label'])->toBe('NPC remove')
+        ->and($session->undo()['label'])->toBe('NPC add')
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse();
+});
+
+it('serves NPC authoring over the line protocol', function () {
+    $root = npcSessionProject([['name' => 'Old Man', 'x' => 2, 'y' => 1]]);
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $request(1, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $revision = $request(2, 'map.read', ['map' => 'test-map'])['result']['revision'];
+
+    $assigned = $request(3, 'npc.assignId', ['map' => 'test-map', 'revision' => $revision, 'index' => 0])['result'];
+    $created = $request(4, 'npc.create', ['map' => 'test-map', 'revision' => $assigned['revision'], 'x' => 5, 'y' => 2, 'name' => 'Guard'])['result'];
+    $moved = $request(5, 'npc.move', ['map' => 'test-map', 'revision' => $created['revision'], 'index' => 1, 'x' => 6, 'y' => 2])['result'];
+    $read = $request(6, 'npc.read', ['map' => 'test-map', 'index' => 1])['result'];
+    $applied = $request(7, 'npc.apply', ['map' => 'test-map', 'revision' => $moved['revision'], 'index' => 1,
+        'key' => npcSessionRow($read, 'sprite')['key'], 'value' => 'G'])['result'];
+    $added = $request(8, 'npc.add', ['map' => 'test-map', 'revision' => $applied['revision'], 'index' => 1, 'key' => ['frame' => ['script']]])['result'];
+    $removed = $request(9, 'npc.remove', ['map' => 'test-map', 'revision' => $added['revision'], 'index' => 1,
+        'key' => ['field' => 'command0Type', 'frame' => ['script']]])['result'];
+    $copy = $request(10, 'npc.duplicate', ['map' => 'test-map', 'revision' => $removed['revision'], 'index' => 1])['result'];
+    $deleted = $request(11, 'npc.delete', ['map' => 'test-map', 'revision' => $copy['revision'], 'index' => 2])['result'];
+
+    expect($assigned['id'])->toBe('old-man')
+        ->and($created['id'])->toBe('guard')
+        ->and($moved['changed'])->toBeTrue()
+        ->and($applied['changed'])->toBeTrue()
+        ->and([$added['changed'], $removed['changed']])->toBe([true, true])
+        ->and($copy['id'])->toBe('guard-2')
+        ->and($deleted['index'])->toBeNull()
+        ->and($request(12, 'npc.delete', ['map' => 'test-map', 'revision' => $copy['revision'], 'index' => 0])['error']['kind'])->toBe('refusal')
+        ->and($request(13, 'npc.read', ['map' => 'test-map', 'index' => 0, 'frame' => 'script'])['error'])
+            ->toBe(['kind' => 'request', 'message' => '"frame" must be a list of indexes and keys.'])
+        ->and($request(14, 'npc.apply', ['map' => 'test-map', 'revision' => $deleted['revision'], 'index' => 0, 'value' => 'x'])['error']['kind'])
+            ->toBe('request');
+});
+
+it('builds an NPC\'s conditions and writes a part at a time and refuses a typo instead of dropping it', function () {
+    $session = EditorSession::open(npcSessionProject([['id' => 'ann', 'name' => 'Ann', 'x' => 2, 'y' => 1,
+        'conditions' => [['type' => 'quest', 'name' => 'breakfast', 'status' => 'active']]]]));
+    $read = $session->readNpc('test-map', 0);
+    $visible = npcSessionRow($read, 'conditions');
+    $after = npcSessionRow($read, 'sets');
+
+    expect([$visible['kind'], $visible['raw'], $visible['entries']])
+        ->toBe(['conditions', 'quest:breakfast:active', [['type' => 'quest', 'name' => 'breakfast', 'status' => 'active']]])
+        ->and([$after['kind'], $after['raw']])->toBe(['writes', '']);
+
+    $conditions = $session->encodeWorldState('conditions', [
+        ['type' => 'quest', 'name' => 'breakfast', 'status' => 'completed'],
+        ['type' => 'variable', 'name' => 'mood', 'op' => '>=', 'value' => 2, 'negate' => true],
+    ]);
+    $writes = $session->encodeWorldState('writes', [['type' => 'switch', 'name' => 'met-ann', 'value' => true]]);
+    expect($conditions['line'])->toBe('quest:breakfast:completed; !variable:mood:>=:2')
+        ->and($conditions['descriptions'][1])->toStartWith('NOT Variable mood');
+
+    $revision = $session->applyNpc('test-map', $read['revision'], 0, $visible['key'], $conditions['line'])['revision'];
+    $revision = $session->applyNpc('test-map', $revision, 0, $after['key'], $writes['line'])['revision'];
+    $reread = $session->readNpc('test-map', 0);
+    expect(npcSessionRow($reread, 'conditions')['entries'])->toBe([
+        ['type' => 'quest', 'name' => 'breakfast', 'status' => 'completed'],
+        ['type' => 'variable', 'name' => 'mood', 'op' => '>=', 'value' => 2, 'negate' => true],
+    ])
+        ->and(npcSessionRow($reread, 'sets')['raw'])->toBe('switch:met-ann:true')
+        // A typed line that cannot be read is refused whole, never stored without its unreadable part.
+        ->and(fn() => $session->applyNpc('test-map', $revision, 0, $visible['key'], 'quest:breakfast; swich:door'))
+        ->toThrow(SessionRefusal::class, 'Condition "swich:door" cannot be read')
+        ->and(fn() => $session->encodeWorldState('conditions', [['type' => 'switch', 'name' => ' ']]))
+        ->toThrow(SessionRefusal::class, 'Entry 1 needs a name.')
+        ->and(fn() => $session->encodeWorldState('writes', [['type' => 'quest', 'name' => 'q']], ['switch']))
+        ->toThrow(SessionRefusal::class, 'cannot write a quest');
+});
+
+it('describes the condition and write vocabulary from the editors that build them', function () {
+    $grammar = EditorSession::open(npcSessionProject([]))->describeWorldStateGrammar();
+    $session = EditorSession::open(npcSessionProject([]));
+
+    expect(array_column($grammar['conditions'], 'type'))->toBe(Ichiloto\Editor\Database\ConditionCodec::types())
+        ->and(array_column($grammar['writes'], 'type'))->toBe(Ichiloto\Editor\Database\WorldWriteCodec::TYPES);
+    // Every type's defaults make an entry the codec writes and reads back.
+    foreach (['conditions', 'writes'] as $codec) {
+        foreach ($grammar[$codec] as $type) {
+            $entry = ['type' => $type['type'], 'name' => 'something'];
+            foreach ($type['extras'] as $extra) {
+                $entry[$extra['key']] = $extra['default'];
+            }
+            expect($session->encodeWorldState($codec, [$entry])['line'])->toStartWith($type['type'] . ':something');
+        }
+    }
+});
+
+it('offers the events of the map a map event condition names, each read as what it is', function () {
+    $session = EditorSession::open(npcSessionProject([]));
+    $grammar = array_find($session->describeWorldStateGrammar()['conditions'], static fn(array $type): bool => $type['type'] === 'map_event');
+    $marker = $grammar['extras'][0];
+    expect($marker['category'])->toBe('map_event_markers')
+        ->and($marker['mapFrom'])->toBe('name')
+        ->and($session->listReferences('test-map', 'map_event_markers', ['map' => 'test-map']))
+        ->toContain(['value' => 'E', 'label' => 'E · Chest'])
+        ->and($session->listReferences('test-map', 'map_event_markers', ['map' => 'no-such-map']))->toBe([]);
+});
diff --git a/tests/Unit/Session/PaintRegionSessionTest.php b/tests/Unit/Session/PaintRegionSessionTest.php
new file mode 100644
index 00000000..b78bad66
--- /dev/null
+++ b/tests/Unit/Session/PaintRegionSessionTest.php
@@ -0,0 +1,34 @@
+readMap('test-map');
+    $buildings = array_find($map['layers'], static fn(array $layer): bool => $layer['name'] === 'buildings')['id'];
+
+    $painted = $session->paintRegion('test-map', $map['revision'], $buildings, 1, 0, ['#+', ' #']);
+
+    expect($painted['status'])->toBe('applied')
+        ->and($painted['changed'])->toBe(4);
+    $session->saveMap('test-map');
+    expect(Ichiloto\Engine\Field\MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe(" #+ \n  # ");
+
+    $session->undo();
+    $session->saveMap('test-map');
+    expect(Ichiloto\Engine\Field\MapGridSource::readFile($root . '/assets/Maps/test-map/layers/04.buildings.map.php'))->toBe(" /  \n xx ");
+});
+
+it('refuses a region that leaves the map, painting none of it', function () {
+    $session = EditorSession::open(layeredMapProject());
+    $map = $session->readMap('test-map');
+    $buildings = array_find($map['layers'], static fn(array $layer): bool => $layer['name'] === 'buildings')['id'];
+
+    expect(fn() => $session->paintRegion('test-map', $map['revision'], $buildings, 2, 1, ['###']))
+        ->toThrow(SessionRefusal::class, 'Row 1 reaches 4, 1, outside test-map (4 x 2); nothing was painted.')
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
diff --git a/tests/Unit/Session/PassageAgreementSessionTest.php b/tests/Unit/Session/PassageAgreementSessionTest.php
new file mode 100644
index 00000000..cd09d08c
--- /dev/null
+++ b/tests/Unit/Session/PassageAgreementSessionTest.php
@@ -0,0 +1,89 @@
+ CollisionType::NONE, ' ' => CollisionType::NONE, '/' => CollisionType::SOLID, 'x' => CollisionType::SOLID];\n");
+
+    return EditorSession::open($root);
+}
+
+/** @return array Appearance rows' values by label. */
+function readAppearanceRows(EditorSession $session): array
+{
+    $rows = array_filter($session->readDatabaseRecord('tilesets', 0)['rows'],
+        static fn(array $row): bool => str_starts_with((string) $row['label'], 'Appearance'));
+
+    return array_column($rows, 'value', 'label');
+}
+
+it('marks solid cells that show only ground, and leaves a cell whose tile it cannot classify alone', function () {
+    $agreement = passageSession()->readPassageAgreement('test-map');
+
+    expect($agreement['available'])->toBeTrue()
+        ->and($agreement['cells'])->toBe([[1, 1, MapPassageAgreementRule::INVISIBLE_WALL], [2, 1, MapPassageAgreementRule::INVISIBLE_WALL]]);
+});
+
+it('authors tile and sheet appearance through the tileset record, one undo step each, and the findings follow', function () {
+    $session = passageSession();
+    $session->setTileAppearance(0, 'home', [5], 'ground');
+
+    expect(readAppearanceRows($session))->toMatchArray(['Appearance: Ground' => '5'])
+        ->and((array) $session->readTilesetPreview(0)['appearance'])->toMatchArray([5 => ['class' => 'ground', 'sheet' => 'B', 'source' => 'tile']])
+        ->and(array_column($session->readPassageAgreement('test-map')['cells'], 0))->toBe([1, 1, 2]);
+
+    // Setting what already holds changes nothing and records no step.
+    expect($session->setTileAppearance(0, 'home', [5], 'ground'))->toMatchArray(['changed' => false]);
+
+    // Moving the tile to another class takes it out of the first, in one step.
+    $session->setTileAppearance(0, 'home', [5], 'barrier');
+    expect(readAppearanceRows($session))->toMatchArray(['Appearance: Ground' => '', 'Appearance: Barrier' => '5'])
+        ->and($session->readPassageAgreement('test-map')['cells'])->toBe([[1, 1, MapPassageAgreementRule::INVISIBLE_WALL],
+            [2, 1, MapPassageAgreementRule::INVISIBLE_WALL], [3, 1, MapPassageAgreementRule::WALK_THROUGH]]);
+    $session->undo();
+    expect(readAppearanceRows($session))->toMatchArray(['Appearance: Ground' => '5', 'Appearance: Barrier' => '']);
+
+    // A whole sheet, then the tile's own class taken away so it reads by its sheet again.
+    $session->setTileAppearance(0, 'home', ['B'], 'water');
+    $session->setTileAppearance(0, 'home', [5], null);
+    expect(readAppearanceRows($session))->toMatchArray(['Appearance: Ground' => '', 'Appearance: Water' => 'B'])
+        ->and((array) $session->readTilesetPreview(0)['appearance'])->toMatchArray([5 => ['class' => 'water', 'sheet' => 'B', 'source' => 'sheet']])
+        ->and($session->readTilesetPreview(0)['appearanceSheets'])->toEqual((object) ['B' => 'water']);
+});
+
+it('refuses an unknown class, selection or record before changing anything', function (array $selectors, ?string $class, string $tileset, string $message) {
+    $session = passageSession();
+    expect(fn() => $session->setTileAppearance(0, $tileset, $selectors, $class))->toThrow(SessionRefusal::class, $message)
+        ->and(readAppearanceRows($session))->each->toBe('');
+})->with([
+    'class' => [[5], 'lava', 'home', 'not a tile appearance'],
+    'selector' => [['F'], 'barrier', 'home', 'neither a tile nor an RPG Maker sheet'],
+    'empty' => [[], 'barrier', 'home', 'Choose tiles or a sheet'],
+    'record' => [[5], 'barrier', 'other', 'record changed'],
+]);
+
+it('reports findings as warnings in project validation, which never refuse a save', function () {
+    $session = passageSession();
+    $issues = array_values(array_filter($session->validateProject()['issues'],
+        static fn(array $issue): bool => str_starts_with((string) $issue['code'], 'passage.')));
+
+    expect($issues)->toHaveCount(1)
+        ->and($issues[0])->toMatchArray(['severity' => 'warning', 'code' => MapPassageAgreementRule::INVISIBLE_WALL, 'map' => 'test-map'])
+        ->and($issues[0]['message'])->toContain('2 cells: (1, 1), (2, 1)');
+});
diff --git a/tests/Unit/Session/PhysicalFootprintStampingTest.php b/tests/Unit/Session/PhysicalFootprintStampingTest.php
new file mode 100644
index 00000000..ea7c2278
--- /dev/null
+++ b/tests/Unit/Session/PhysicalFootprintStampingTest.php
@@ -0,0 +1,139 @@
+ 'home',", $body));
+    writeTestTileset($root, pieces: ['desk' => ['name' => 'Desk', 'layer' => 'not-required-for-collision',
+        'glyphs' => ['##', '##'], 'occupancy' => $recipe ?? [[null, CollisionType::COUNTER], [CollisionType::SOLID, CollisionType::NONE]]]]);
+
+    return $root;
+}
+
+it('previews and stamps a heterogeneous footprint independently of either presentation with shared undo and saved source', function () {
+    $root = createPhysicalFootprintProject();
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $hashes = sourceHashTree($root);
+    $pieces = $session->listPieces('test-map');
+    $expected = [[null, 10], [1, 0]];
+    expect($pieces['tileset'])->toBe('home')
+        ->and($pieces['pieces'][0]['layerIssue'])->toBeString()
+        ->and($pieces['pieces'][0]['occupancy'])->toBe($expected)
+        ->and($session->previewOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 0, 0)['cells'])
+            ->toBe([[1, 0, 10], [0, 1, 1], [1, 1, 0]])
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and(sourceHashTree($root))->toBe($hashes);
+    $applied = $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 0, 0);
+    $after = $session->readMap('test-map');
+    expect($applied['changed'])->toBe(2)
+        ->and($after['occupancy']['rows'])->toBe([[0, 10, 0, 0], [1, 0, 0]])
+        ->and($after['layers'])->toBe($before['layers']);
+    $session->saveMap('test-map');
+    expect(file_get_contents($root . '/assets/Maps/test-map/test-map.data.php'))->toContain('// Physical source notes.');
+    $map = getPhysicalOccupancyMap($session);
+    expect($map->getMapDataField(['occupancy', 0, 1]))->toBe(CollisionType::COUNTER);
+    $session->undo();
+    expect($session->readMap('test-map')['occupancy']['rows'])->toBe($before['occupancy']['rows']);
+    $session->redo();
+    $session->saveMap('test-map');
+    expect(EditorSession::open($root)->readMap('test-map')['occupancy']['rows'])->toBe($after['occupancy']['rows']);
+    foreach ($hashes as $path => $hash) {
+        if ($path !== 'assets/Maps/test-map/test-map.data.php') {
+            expect(sourceHashTree($root)[$path])->toBe($hash);
+        }
+    }
+});
+
+it('refuses a partial ragged footprint, stale map revision and changed recipe without touching source or history', function () {
+    $root = createPhysicalFootprintProject();
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $hashes = sourceHashTree($root);
+    $expected = [[null, 10], [1, 0]];
+    expect(fn() => $session->previewOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 2, 0))
+        ->toThrow(SessionRefusal::class, 'whole physical footprint')
+        ->and(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 2, 0))
+            ->toThrow(SessionRefusal::class, 'whole physical footprint')
+        ->and(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'wrong', 'desk', $expected, 0, 0))
+            ->toThrow(SessionRefusal::class, 'tileset changed')
+        ->and(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', [[1, 10], [1, 0]], 0, 0))
+            ->toThrow(SessionRefusal::class, 'footprint changed')
+        ->and($session->undo()['label'])->toBeNull()
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and(sourceHashTree($root))->toBe($hashes);
+    $session->paintOccupancy('test-map', $before['revision'], [[3, 0]], 1);
+    expect(fn() => $session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', $expected, 0, 0))
+        ->toThrow(SessionRefusal::class);
+    $new = $session->readMap('test-map');
+    writeTestTileset($root, pieces: ['desk' => ['name' => 'Desk', 'layer' => 'not-required-for-collision', 'glyphs' => ['##', '##'],
+        'occupancy' => [[null, CollisionType::COUNTER], [CollisionType::NONE, CollisionType::NONE]]]]);
+    expect(fn() => $session->stampOccupancyPiece('test-map', $new['revision'], 'home', 'desk', $expected, 0, 0))
+        ->toThrow(SessionRefusal::class, 'footprint changed')
+        ->and($session->readMap('test-map'))->toBe($new);
+});
+
+it('keeps all-null recipes as no-op masks and rejects legacy maps without implicit migration', function () {
+    $root = createPhysicalFootprintProject([[null, null], [null, null]]);
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    expect($session->stampOccupancyPiece('test-map', $before['revision'], 'home', 'desk', [[null, null], [null, null]], 0, 0)['changed'])->toBe(0)
+        ->and($session->undo()['label'])->toBeNull()
+        ->and($session->readMap('test-map'))->toBe($before);
+    $map = getPhysicalOccupancyMap($session);
+    $map->setMapDataField(['occupancy'], null);
+    file_put_contents($root . '/assets/Maps/collisions.php', ' \\Ichiloto\\Engine\\Events\\Enumerations\\CollisionType::NONE];');
+    $read = $session->readMap('test-map');
+    expect(fn() => $session->stampOccupancyPiece('test-map', $read['revision'], 'home', 'desk', [[null, null], [null, null]], 0, 0))
+        ->toThrow(SessionRefusal::class, 'explicitly migrated');
+});
+
+it('never applies a physical recipe as a side effect of graphical or glyph piece placement', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: ['post' => ['name' => 'Post', 'layer' => 'buildings', 'glyphs' => ['#'],
+        'tiles' => ['decor' => ['5']], 'occupancy' => [[CollisionType::COUNTER]]]]);
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $session->migratePhysicalOccupancy('test-map', $before['revision']);
+    $separated = $session->readMap('test-map');
+    $session->placePiece('test-map', $separated['revision'], 'post', [0, 0], [0, 0]);
+    expect($session->readMap('test-map')['occupancy'])->toBe($separated['occupancy']);
+});
+
+it('refuses conflicting or invalid heterogeneous writes atomically through the shared source gate', function () {
+    $root = createPhysicalFootprintProject();
+    $session = EditorSession::open($root);
+    $map = getPhysicalOccupancyMap($session);
+    $before = $session->readMap('test-map');
+    foreach ([[[0, 0, CollisionType::NONE], [0, 0, CollisionType::COUNTER]],
+        [[0, 0, CollisionType::COUNTER], [3, 1, CollisionType::SOLID]],
+        [[0, 0, CollisionType::COUNTER], [1, 0, CollisionType::PASS_THROUGH]], [[0, 0, 1]]] as $writes) {
+        expect(fn() => $map->writePhysicalOccupancy($writes))->toThrow(\Ichiloto\Editor\MapSourceRefusal::class)
+            ->and($session->readMap('test-map'))->toBe($before);
+    }
+    $path = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($path, str_replace('// Physical source notes.', '// External edit.', file_get_contents($path)));
+    expect(fn() => PhysicalOccupancyEditor::applyPiece($map, $map->loadTileset()->pieces['desk'], 0, 0))
+        ->toThrow(\Ichiloto\Editor\MapSourceRefusal::class);
+});
+
+it('validates physical footprint RPC identity and values before mutation', function (array $override) {
+    $root = createPhysicalFootprintProject();
+    $request = createPhysicalOccupancyRpc($root);
+    $before = $request('map.read', ['map' => 'test-map'])['result'];
+    $params = ['map' => 'test-map', 'revision' => $before['revision'], 'tileset' => 'home', 'piece' => 'desk', 'expected' => [[null, 10], [1, 0]], 'x' => 0, 'y' => 0];
+    expect($request('occupancy.stampPiece', array_replace($params, $override))['error']['kind'])->toBe('request')
+        ->and($request('map.read', ['map' => 'test-map'])['result'])->toBe($before);
+})->with([
+    'missing expected' => [['expected' => null]], 'bad row' => [['expected' => [1]]], 'boolean cell' => [['expected' => [[true, 10], [1, 0]]]],
+    'fractional origin' => [['x' => 1.5]], 'tileset integer' => [['tileset' => 1]],
+]);
diff --git a/tests/Unit/Session/PhysicalOccupancyPaintingTest.php b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php
new file mode 100644
index 00000000..ef1afa0c
--- /dev/null
+++ b/tests/Unit/Session/PhysicalOccupancyPaintingTest.php
@@ -0,0 +1,314 @@
+readMap('test-map');
+    $map = getPhysicalOccupancyMap($session);
+    expect($read['physicalOccupancy'])->toBeTrue()
+        ->and($read['occupancy']['rows'])->toBe([[0, 0, 0], [0, 0]])
+        ->and($read['occupancy']['issue'])->toBeNull()
+        ->and(array_column($read['occupancy']['types'], 'value'))->toBe(array_values(array_map(
+            static fn(CollisionType $type): int => $type->value,
+            array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH))))
+        ->and(array_column($read['occupancy']['types'], 'label'))->not->toContain('Pass Through')
+        ->and($map->getMapDataField(['occupancy', 0, 0]))->toBe(CollisionType::NONE);
+});
+
+it('reads legacy collision truth but refuses painting without explicit conversion', function () {
+    $root = createPhysicalOccupancyProject(' []];', "##\n#");
+    file_put_contents($root . '/assets/Maps/collisions.php', ' '
+        . var_export(CollisionType::COUNTER, true) . '];');
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $hashes = sourceHashTree($root);
+    expect($before['occupancy']['rows'])->toBe([[10, 10], [10]])
+        ->and($before['occupancy']['issue'])->toBeNull()
+        ->and($before['physicalOccupancy'])->toBeFalse()
+        ->and($session->getOccupancyFillRegion('test-map', 0, 0, $before['revision'])['cells'])->toHaveCount(3)
+        ->and(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 0))
+            ->toThrow(SessionRefusal::class, 'explicitly migrated')
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($hashes);
+});
+
+it('reports unresolved legacy collisions rather than inventing physical passage', function () {
+    $root = createPhysicalOccupancyProject(' []];');
+    file_put_contents($root . '/assets/Maps/collisions.php', ' 0];');
+    $session = EditorSession::open($root);
+    $read = $session->readMap('test-map');
+    expect($read['occupancy']['rows'])->toBe([])
+        ->and($read['occupancy']['issue'])->toContain('Invalid dictionary entry')
+        ->and(fn() => $session->getOccupancyFillRegion('test-map', 0, 0, $read['revision']))
+            ->toThrow(SessionRefusal::class, 'Invalid dictionary entry');
+});
+
+it('reports malformed declared occupancy and atomically refuses painting and fill', function (mixed $rows) {
+    $root = createPhysicalOccupancyProject(' [], 'occupancy' => $rows], true) . ';');
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $hashes = sourceHashTree($root);
+    expect($before['physicalOccupancy'])->toBeTrue()
+        ->and($before['occupancy']['rows'])->toBe([])
+        ->and($before['occupancy']['issue'])->toBeString()
+        ->and(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 1))
+            ->toThrow(SessionRefusal::class, 'occupancy')
+        ->and(fn() => $session->getOccupancyFillRegion('test-map', 0, 0, $before['revision']))
+            ->toThrow(SessionRefusal::class, 'occupancy')
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($hashes);
+})->with([
+    'null' => [null],
+    'missing rows' => [[]],
+    'integer cells' => [[[0, 0, 0], [0, 0]]],
+    'wrong row width' => [[[CollisionType::NONE], [CollisionType::NONE, CollisionType::NONE]]],
+    'unresolved cell' => [[[CollisionType::PASS_THROUGH, CollisionType::NONE, CollisionType::NONE], [CollisionType::NONE, CollisionType::NONE]]],
+]);
+
+it('strictly validates occupancy RPC arguments before changing any state', function (string $method, array $override) {
+    $root = createPhysicalOccupancyProject();
+    $request = createPhysicalOccupancyRpc($root);
+    $before = $request('map.read', ['map' => 'test-map'])['result'];
+    $params = ['map' => 'test-map', 'revision' => $before['revision'], 'cells' => [[0, 0]], 'collision' => 1, 'x' => 0, 'y' => 0];
+    expect($request($method, array_replace($params, $override))['error']['kind'])->toBe('request')
+        ->and($request('map.read', ['map' => 'test-map'])['result'])->toBe($before)
+        ->and($request('history.undo', [])['result']['label'])->toBeNull();
+})->with([
+    'map integer' => ['map.paintOccupancy', ['map' => 1]],
+    'revision string' => ['map.paintOccupancy', ['revision' => '0']],
+    'collision string' => ['map.paintOccupancy', ['collision' => '1']],
+    'collision float' => ['map.paintOccupancy', ['collision' => 1.5]],
+    'collision boolean' => ['map.paintOccupancy', ['collision' => true]],
+    'label null' => ['map.paintOccupancy', ['label' => null]],
+    'cells null' => ['map.paintOccupancy', ['cells' => null]],
+    'cell fractional' => ['map.paintOccupancy', ['cells' => [[0.5, 0]]]],
+    'cell extra value' => ['map.paintOccupancy', ['cells' => [[0, 0, 1]]]],
+    'cell text' => ['map.paintOccupancy', ['cells' => [[0, '0']]]],
+    'fill x string' => ['canvas.fillOccupancy', ['x' => '0']],
+    'fill revision null' => ['canvas.fillOccupancy', ['revision' => null]],
+]);
+
+it('rejects malformed or outside physical cells atomically even after a valid cell', function (array $cells) {
+    $root = createPhysicalOccupancyProject();
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $hashes = sourceHashTree($root);
+    expect(fn() => $session->paintOccupancy('test-map', $before['revision'], $cells, 1))->toThrow(SessionRefusal::class)
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($hashes);
+})->with([
+    'ragged gap' => [[[0, 0], [2, 1]]],
+    'negative x' => [[[0, 0], [-1, 0]]],
+    'negative y' => [[[0, 0], [0, -1]]],
+    'outside width' => [[[0, 0], [3, 0]]],
+    'outside height' => [[[0, 0], [0, 2]]],
+    'short pair' => [[[0, 0], [1]]],
+    'noninteger' => [[[0, 0], [1, '0']]],
+    'not pair' => [[[0, 0], null]],
+    'nonlist' => [[1 => [0, 0]]],
+    'reordered pair' => [[[1 => 0, 0 => 0]]],
+]);
+
+it('rejects nonfinal collision values without dirty state or history', function (int $collision) {
+    $session = EditorSession::open(createPhysicalOccupancyProject());
+    $before = $session->readMap('test-map');
+    expect(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], $collision))
+        ->toThrow(SessionRefusal::class, 'final CollisionType')
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull();
+})->with([9, -1, 999]);
+
+it('paints every final enum type after explicit migration and saves enums rather than integers', function (CollisionType $collision) {
+    $root = createPhysicalOccupancyProject(' []];');
+    $session = EditorSession::open($root);
+    $session->migratePhysicalOccupancy('test-map', $session->readMap('test-map')['revision']);
+    $before = $session->readMap('test-map');
+    $changed = $before['occupancy']['rows'][0][0] === $collision->value ? 0 : 1;
+    expect($session->paintOccupancy('test-map', $before['revision'], [[0, 0]], $collision->value)['changed'])->toBe($changed)
+        ->and($session->readMap('test-map')['occupancy']['rows'][0][0])->toBe($collision->value);
+    $session->saveMap('test-map');
+    $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0);
+    expect($map->getMapDataField(['occupancy', 0, 0]))->toBe($collision);
+})->with(array_values(array_filter(CollisionType::cases(), static fn(CollisionType $type): bool => $type !== CollisionType::PASS_THROUGH)));
+
+it('deduplicates a physical stroke and rejects stale painting and fill over RPC', function () {
+    $request = createPhysicalOccupancyRpc(createPhysicalOccupancyProject());
+    $revision = $request('map.read', ['map' => 'test-map'])['result']['revision'];
+    $params = ['map' => 'test-map', 'revision' => $revision, 'cells' => [[0, 0], [0, 0], [1, 0]], 'collision' => 10];
+    $applied = $request('map.paintOccupancy', $params)['result'];
+    expect($applied)->toBe(['status' => 'applied', 'changed' => 2, 'revision' => $revision + 1])
+        ->and($request('map.paintOccupancy', $params)['error']['kind'])->toBe('refusal')
+        ->and($request('canvas.fillOccupancy', ['map' => 'test-map', 'revision' => $revision, 'x' => 0, 'y' => 0])['error']['kind'])
+            ->toBe('refusal')
+        ->and($request('canvas.fillOccupancy', ['map' => 'test-map', 'revision' => $applied['revision'], 'x' => 0, 'y' => 0])['result'])
+            ->toBe(['cells' => [[0, 0], [1, 0]], 'map' => 'test-map', 'revision' => $applied['revision']])
+        ->and($request('history.undo', [])['result']['label'])->toBe('Paint collision');
+});
+
+it('leaves no-op physical strokes clean and preserves the redo stack', function () {
+    $session = EditorSession::open(createPhysicalOccupancyProject());
+    $before = $session->readMap('test-map');
+    foreach ([[], [[0, 0], [0, 0]]] as $cells) {
+        expect($session->paintOccupancy('test-map', $before['revision'], $cells, 0))
+            ->toBe(['status' => 'applied', 'changed' => 0, 'revision' => $before['revision']]);
+    }
+    expect($session->readMap('test-map'))->toBe($before)->and($session->undo()['label'])->toBeNull();
+    $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 1, 'Physical test stroke');
+    expect($session->undo()['label'])->toBe('Physical test stroke');
+    $revision = $session->readMap('test-map')['revision'];
+    $session->paintOccupancy('test-map', $revision, [[0, 0]], 0);
+    expect($session->redo()['label'])->toBe('Physical test stroke')
+        ->and($session->readMap('test-map')['occupancy']['rows'][0][0])->toBe(1);
+});
+
+it('does not flood across missing ragged cells or accept a missing fill seed', function () {
+    $rows = [[CollisionType::SOLID, CollisionType::NONE, CollisionType::NONE], [CollisionType::SOLID],
+        [CollisionType::SOLID, CollisionType::NONE, CollisionType::NONE]];
+    $root = createPhysicalOccupancyProject(' [], 'occupancy' => $rows], true) . ';', "###\n#\n###");
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    expect($session->getOccupancyFillRegion('test-map', 1, 0, $before['revision'])['cells'])->toBe([[1, 0], [2, 0]])
+        ->and($session->getOccupancyFillRegion('test-map', 1, 2, $before['revision'])['cells'])->toBe([[1, 2], [2, 2]])
+        ->and(fn() => $session->getOccupancyFillRegion('test-map', 1, 1, $before['revision']))
+            ->toThrow(SessionRefusal::class, 'outside the map')
+        ->and($session->readMap('test-map'))->toBe($before);
+});
+
+it('preserves authored comments and unrelated expressions through shared undo save and redo', function () {
+    $source = <<<'PHP'
+     'Physical fixture',
+      'description' => strtoupper('kept'), // Keep this authored expression.
+      'note' => $kept,
+      'events' => [],
+      'occupancy' => array(
+        array(CollisionType::NONE, /* ground contact */ CollisionType::NONE, CollisionType::NONE),
+        array(CollisionType::NONE, CollisionType::NONE),
+      ),
+    );
+    PHP;
+    $root = createPhysicalOccupancyProject($source);
+    $file = $root . '/assets/Maps/test-map/test-map.data.php';
+    $session = EditorSession::open($root);
+    $map = getPhysicalOccupancyMap($session);
+    $hashes = sourceHashTree($root);
+    $session->paintOccupancy('test-map', $session->readMap('test-map')['revision'], [[1, 0]], 10);
+    expect(sourceHashTree($root))->toBe($hashes)
+        ->and($map->getMapDataField(['occupancy', 0, 1]))->toBe(CollisionType::COUNTER);
+    $session->saveMap('test-map');
+    $painted = file_get_contents($file);
+    expect($painted)->toContain("'description' => strtoupper('kept'), // Keep this authored expression.",
+        "'note' => \$kept", '/* ground contact */', "'occupancy' => array(")
+        ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getResolvedCollisionMap())->toBe([[0, 10, 0], [0, 0]]);
+    expect($session->undo()['label'])->toBe('Paint collision');
+    $session->saveMap('test-map');
+    expect(file_get_contents($file))->toBe($source);
+    $session->redo();
+    $session->saveMap('test-map');
+    expect(file_get_contents($file))->toBe($painted);
+});
+
+it('refuses computed occupancy writes without flattening authored PHP', function (string $declaration) {
+    $root = createPhysicalOccupancyProject($declaration);
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $hashes = sourceHashTree($root);
+    expect($before['occupancy']['issue'])->toBeNull()
+        ->and(fn() => $session->paintOccupancy('test-map', $before['revision'], [[0, 0]], 1))
+            ->toThrow(SessionRefusal::class)
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($hashes);
+})->with([
+    'computed root' => [' [], "occupancy" => [[\Ichiloto\Engine\Events\Enumerations\CollisionType::NONE, \Ichiloto\Engine\Events\Enumerations\CollisionType::NONE, \Ichiloto\Engine\Events\Enumerations\CollisionType::NONE], [\Ichiloto\Engine\Events\Enumerations\CollisionType::NONE, \Ichiloto\Engine\Events\Enumerations\CollisionType::NONE]]]; return $data;'],
+    'computed rows' => [' [], "occupancy" => array_map(static fn($n) => array_fill(0, $n, CollisionType::NONE), [3, 2])];'],
+    'computed cell' => [' [], "occupancy" => [[CollisionType::from(0), CollisionType::NONE, CollisionType::NONE], [CollisionType::NONE, CollisionType::NONE]]];'],
+]);
+
+it('refuses external source replacement on painting fill and saved history without losing history', function (string $member) {
+    $root = createPhysicalOccupancyProject();
+    $session = EditorSession::open($root);
+    $session->paintOccupancy('test-map', $session->readMap('test-map')['revision'], [[0, 0]], 1);
+    $session->saveMap('test-map');
+    $path = $root . '/assets/Maps/test-map/test-map.' . $member . '.php';
+    $source = file_get_contents($path);
+    file_put_contents($path, $source . "\n// External author replacement.\n");
+    $before = $session->readMap('test-map');
+    $hashes = sourceHashTree($root);
+    expect(fn() => $session->paintOccupancy('test-map', $before['revision'], [[1, 0]], 1))->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->getOccupancyFillRegion('test-map', 0, 0, $before['revision']))->toThrow(SessionRefusal::class)
+        // The session reports a map source refusal as its own, as every session refusal reaches an interface.
+        ->and(fn() => $session->undo())->toThrow(SessionRefusal::class)
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and(sourceHashTree($root))->toBe($hashes);
+    file_put_contents($path, $source);
+    expect($session->undo()['label'])->toBe('Paint collision')
+        ->and($session->readMap('test-map')['occupancy']['rows'][0][0])->toBe(0);
+})->with(['data', 'map']);
+
+it('keeps occupancy painting independent from glyph and bound tile edits and their history', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: [
+        'chest' => ['name' => 'Chest', 'layer' => 'buildings', 'glyphs' => ['m'], 'tiles' => ['furniture' => ['60']]],
+    ]);
+    $session = EditorSession::open($root);
+    $session->migratePhysicalOccupancy('test-map', $session->readMap('test-map')['revision']);
+    $map = getPhysicalOccupancyMap($session);
+    $glyphs = $map->getLayerSet()->getComposedGrid();
+    $tiles = $map->getTileLayerSources();
+    $physical = $session->readMap('test-map')['occupancy']['rows'];
+    $target = $physical[0][0] === 10 ? 1 : 10;
+    $session->paintOccupancy('test-map', $session->readMap('test-map')['revision'], [[0, 0]], $target);
+    $painted = $session->readMap('test-map')['occupancy']['rows'];
+    expect($map->getLayerSet()->getComposedGrid())->toBe($glyphs)->and($map->getTileLayerSources())->toBe($tiles);
+    $session->paint('test-map', $session->readMap('test-map')['revision'], 'map:4', [[0, 0]], 'm');
+    expect($session->readMap('test-map')['occupancy']['rows'])->toBe($painted);
+    $session->stampTiles('test-map', $session->readMap('test-map')['revision'], 'furniture', [[1, 1, 60]]);
+    expect($session->readMap('test-map')['occupancy']['rows'])->toBe($painted);
+    $session->undo();
+    $session->undo();
+    expect($map->getLayerSet()->getComposedGrid())->toBe($glyphs)
+        ->and($map->getTileLayerSources())->toBe($tiles)
+        ->and($session->readMap('test-map')['occupancy']['rows'])->toBe($painted);
+    $session->undo();
+    expect($session->readMap('test-map')['occupancy']['rows'])->toBe($physical);
+    $session->redo();
+    $session->redo();
+    $session->redo();
+    expect($session->readMap('test-map')['occupancy']['rows'])->toBe($painted);
+    $session->saveMap('test-map');
+    expect(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getResolvedCollisionMap())->toBe($painted);
+});
+
+it('restores only physical data rather than unrelated metadata or presentations', function () {
+    $root = createPhysicalOccupancyProject();
+    $session = EditorSession::open($root);
+    $map = getPhysicalOccupancyMap($session);
+    $edit = PhysicalOccupancyEditor::applyPaint($map, [[0, 0]], 1);
+    $map->setMapField('name', 'Later authored name');
+    $map->setLayerCell($map->getBaseLayerId(), 1, 0, '.');
+    $edit['command']->undo();
+    expect($map->getDisplayName())->toBe('Later authored name')
+        ->and($map->getLayerSymbol($map->getBaseLayerId(), 1, 0))->toBe('.')
+        ->and($map->getResolvedCollisionMap())->toBe([[0, 0, 0], [0, 0]]);
+    $edit['command']->execute();
+    expect($map->getDisplayName())->toBe('Later authored name')
+        ->and($map->getLayerSymbol($map->getBaseLayerId(), 1, 0))->toBe('.')
+        ->and($map->getResolvedCollisionMap())->toBe([[1, 0, 0], [0, 0]]);
+});
diff --git a/tests/Unit/Session/PhysicalOccupancySessionTest.php b/tests/Unit/Session/PhysicalOccupancySessionTest.php
new file mode 100644
index 00000000..632f9113
--- /dev/null
+++ b/tests/Unit/Session/PhysicalOccupancySessionTest.php
@@ -0,0 +1,143 @@
+ CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::COUNTER];');
+    $session = EditorSession::open($root);
+    $map = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0);
+    $original = sourceHashTree($root);
+    $originalData = $map->getMapDataField([]);
+    $terminal = $map->getLayerSet()->getComposedGrid();
+    $collision = $map->getResolvedCollisionMap();
+    $revision = $session->readMap('test-map')['revision'];
+    expect($session->readMap('test-map')['physicalOccupancy'])->toBeFalse();
+
+    expect($session->migratePhysicalOccupancy('test-map', $revision))
+        ->toMatchArray(['status' => 'applied', 'changed' => true])
+        ->and($map->getLayerSet()->getComposedGrid())->toBe($terminal)
+        ->and($map->getResolvedCollisionMap())->toBe($collision)
+        ->and($session->readMap('test-map')['physicalOccupancy'])->toBeTrue()
+        ->and(sourceHashTree($root))->toBe($original)
+        ->and(fn() => $session->migratePhysicalOccupancy('test-map', $revision))
+            ->toThrow(SessionRefusal::class, 'changed since revision');
+    $session->saveMap('test-map');
+    $migrated = sourceHashTree($root);
+    expect($migrated)->not->toBe($original)
+        ->and(ProjectWorkspace::fromProject($root)->getMapByIndex(0)->getResolvedCollisionMap())->toBe($collision);
+    expect($session->undo())->toMatchArray(['label' => 'Physical occupancy migration', 'maps' => ['test-map']]);
+    $session->saveMap('test-map');
+    expect(sourceHashTree($root))->toBe($original)
+        ->and($map->getMapDataField([]))->toBe($originalData);
+    $session->redo();
+    $session->saveMap('test-map');
+    expect(sourceHashTree($root))->toBe($migrated)
+        ->and($map->getResolvedCollisionMap())->toBe($collision);
+    $current = $session->readMap('test-map')['revision'];
+    expect($session->migratePhysicalOccupancy('test-map', $current))
+        ->toMatchArray(['status' => 'applied', 'changed' => false])
+        ->and($session->readMap('test-map')['revision'])->toBe($current);
+});
+
+it('serves explicit occupancy migration through the existing revisioned session protocol', function () {
+    $root = layeredMapProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+    $request(0, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    $revision = $request(1, 'map.read', ['map' => 'test-map'])['result']['revision'];
+    expect($request(2, 'map.migrateOccupancy', ['map' => 'test-map', 'revision' => $revision])['result'])
+        ->toMatchArray(['status' => 'applied', 'changed' => true])
+        ->and($request(3, 'map.migrateOccupancy', ['map' => 'test-map', 'revision' => $revision])['error']['kind'])
+            ->toBe('refusal')
+        ->and($request(4, 'map.migrateOccupancy', ['map' => 'test-map', 'revision' => 'old'])['error']['kind'])
+            ->toBe('request');
+});
+
+it('keeps physical cells unchanged across later presentation and layer edits', function () {
+    $root = layeredMapProject();
+    file_put_contents($root . '/assets/Maps/collisions.php', ' CollisionType::NONE, "/" => CollisionType::SOLID, "x" => CollisionType::COUNTER];');
+    $map = loadLayeredMap($root);
+    $collision = $map->getResolvedCollisionMap();
+    $map->migratePhysicalOccupancy();
+    $map->setLayerCell('map:1', 0, 0, '/');
+    expect($map->getResolvedCollisionMap())->toBe($collision)
+        ->and($map->countDecorationCollisionChanges('map:4', true))->toBe(0);
+    $map->setLayerDecoration('map:4', true);
+    $map->renameLayer('map:4', 'appearance');
+    $map->save();
+    expect(loadLayeredMap($root)->getResolvedCollisionMap())->toBe($collision);
+});
+
+it('refuses malformed existing occupancy without source edits revision changes or history entries', function () {
+    $root = layeredMapProject();
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, ' [], "occupancy" => null];');
+    $session = EditorSession::open($root);
+    $map = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session)->getMapByIndex(0);
+    $source = sourceHashTree($root);
+    $revision = $session->readMap('test-map')['revision'];
+    expect(fn() => $map->migratePhysicalOccupancy())->toThrow(MapSourceRefusal::class, 'occupancy')
+        ->and(fn() => $session->migratePhysicalOccupancy('test-map', $revision))
+            ->toThrow(SessionRefusal::class, 'occupancy')
+        ->and($session->readMap('test-map')['revision'])->toBe($revision)
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse()
+        ->and($session->undo())->toMatchArray(['label' => null, 'maps' => [], 'revisions' => [], 'databases' => []])
+        ->and(sourceHashTree($root))->toBe($source);
+});
+
+it('refuses explicit migration when source cannot preserve the added declaration', function () {
+    $root = layeredMapProject();
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, ' []]; return $data;');
+    $map = loadLayeredMap($root);
+    $before = sourceHashTree($root);
+    $snapshot = $map->captureLayerSnapshot();
+    expect(fn() => $map->migratePhysicalOccupancy())->toThrow(MapSourceRefusal::class)
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($map->captureLayerSnapshot())->toBe($snapshot)
+        ->and($map->getMapDataField(['occupancy']))->toBeNull();
+});
+
+it('uses declared occupancy in the shared field preview and refuses malformed replacement', function () {
+    $root = makeTemporaryProject();
+    $directory = $root . '/assets/Maps/test-map';
+    file_put_contents($directory . '/test-map.map.php', MapGridSource::buildSource('###', 'MAP'));
+    file_put_contents($directory . '/test-map.event.php', MapGridSource::buildSource('   ', 'EVENT'));
+    $path = $directory . '/test-map.data.php';
+    $data = ['events' => [], 'occupancy' => [[CollisionType::NONE, CollisionType::COUNTER, CollisionType::SOLID]]];
+    file_put_contents($path, ' 0.0);
+    try {
+        expect($field->mapFailure)->toBeNull()
+            ->and($field->scene->previewMap->getCollision(0, 0))->toBe(CollisionType::NONE)
+            ->and($field->scene->previewMap->getCollision(1, 0))->toBe(CollisionType::COUNTER)
+            ->and($field->scene->previewMap->getCollision(2, 0))->toBe(CollisionType::SOLID)
+            ->and(sourceHashTree($root))->toBe($source);
+        file_put_contents($path, ' [], "occupancy" => null];');
+        $field->run(function () use ($field): void {
+            expect(fn() => $field->scene->previewMap->loadForPreview('test-map'))
+                ->toThrow(InvalidArgumentException::class, 'Map occupancy must be a zero-based list');
+        });
+        expect($field->mapFailure)->toContain('Map occupancy must be a zero-based list')
+            ->and($field->scene->previewMap->mapData)->toBe([]);
+    } finally {
+        $field->dispose();
+    }
+});
diff --git a/tests/Unit/Session/PieceDoorwaySessionTest.php b/tests/Unit/Session/PieceDoorwaySessionTest.php
new file mode 100644
index 00000000..5dac1bc6
--- /dev/null
+++ b/tests/Unit/Session/PieceDoorwaySessionTest.php
@@ -0,0 +1,65 @@
+ $extra + ['name' => 'Hut', 'layer' => 'fixtures', 'glyphs' => ['/\\', '∩|'],
+        'tiles' => ['decor' => ['1 2', '9 10']]]]);
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    file_put_contents($path, str_replace("toBe([['x' => 2, 'y' => 4], ['x' => 4, 'y' => 4, 'entrance' => 'front']])
+        ->and(PieceDoorwayCodec::decode(' 0, 1 on ;'))->toBe([['x' => 0, 'y' => 1]])
+        ->and(PieceDoorwayCodec::decode(''))->toBe([])
+        ->and(PieceDoorwayCodec::encode([['x' => 2, 'y' => 4], ['x' => 4, 'y' => 4, 'entrance' => 'front']]))->toBe('2,4; 4,4 front')
+        ->and(PieceDoorwayCodec::encode(null))->toBe('')
+        ->and(PieceDoorwayCodec::encode([['x' => 2]]))->toContain("'x' => 2");
+    expect(fn() => PieceDoorwayCodec::decode('2;4'))->toThrow(InvalidArgumentException::class, 'Painted doors are cells such as')
+        ->and(fn() => PieceDoorwayCodec::decode('2,4 behind'))->toThrow(InvalidArgumentException::class, 'entrance must be one of')
+        ->and(fn() => PieceDoorwayCodec::decode('2,4; 2,4'))->toThrow(InvalidArgumentException::class, 'distinct');
+});
+
+it('edits a piece\'s painted doors through its record row, keeping the source, and lists them with the piece', function () {
+    $root = createPieceDoorwayProject();
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $session = EditorSession::open($root);
+    $row = array_find($session->readDatabaseRecord('tilesets', 0)['rows'], static fn(array $row): bool =>
+        ($row['key']['field'] ?? null) === 'piece0Doorway');
+    expect($row)->not->toBeNull();
+
+    $session->applyDatabaseRecord('tilesets', 0, $row['key'], '0,1');
+    $session->saveDatabase('tilesets');
+
+    expect(Tileset::load($root . '/assets', 'home')->pieces['hut']->doorway)->toBe([['x' => 0, 'y' => 1, 'entrance' => DoorwayEntrance::ON]])
+        ->and(file_get_contents($path))->toContain('// Keep the author', "'doorway'")
+        ->and(array_find(EditorSession::open($root)->listPieces('test-map')['pieces'], static fn(array $piece): bool => $piece['id'] === 'hut')['doorway'])
+        ->toBe([['x' => 0, 'y' => 1]]);
+
+    $session->applyDatabaseRecord('tilesets', 0, $row['key'], '');
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['hut']->doorway)->toBe([])
+        ->and(file_get_contents($path))->not->toContain("'doorway'");
+});
+
+it('keeps a piece\'s painted doors when its glyph footprint and physical recipe are authored', function () {
+    $root = createPieceDoorwayProject(['doorway' => [['x' => 0, 'y' => 1]]]);
+    $session = EditorSession::open($root);
+    $piece = array_find($session->readTilesetPreview(0)['authoring'], static fn(array $piece): bool => $piece['id'] === 'hut');
+    expect($piece['issue'])->toBeNull()->and($piece['width'])->toBe(2);
+
+    $session->setTilesetOccupancy(0, 'home', 'hut', null, [[1, 1], [0, 1]]);
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['hut']->doorway)->toHaveCount(1);
+});
diff --git a/tests/Unit/Session/PieceSessionTest.php b/tests/Unit/Session/PieceSessionTest.php
new file mode 100644
index 00000000..8982c5dd
--- /dev/null
+++ b/tests/Unit/Session/PieceSessionTest.php
@@ -0,0 +1,109 @@
+ ['name' => 'Tree', 'layer' => 'buildings', 'glyphs' => ['^^', '| ']],
+        'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines',
+            'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']],
+        'shelf' => ['name' => 'Shelf', 'layer' => 'storage', 'glyphs' => ['#']],
+    ]);
+
+    return EditorSession::open($root);
+}
+
+/** @return list The buildings layer, row by row. */
+function readPieceSessionRows(EditorSession $session): array
+{
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4');
+
+    return array_map(static fn(array $row): string => implode('', $row), $layer['rows']);
+}
+
+function placeSessionPiece(EditorSession $session, string $piece, array $from, array $to, ?string $color = null): array
+{
+    return $session->placePiece('test-map', $session->readMap('test-map')['revision'], $piece, $from, $to, $color);
+}
+
+it('lists the map tileset\'s pieces with the layer each goes on and a picture of what it draws', function () {
+    $listed = pieceSession()->listPieces('test-map');
+    [$tree, $wall, $shelf] = $listed['pieces'];
+
+    expect($listed['issue'])->toBeNull()
+        ->and($tree)->toMatchArray(['id' => 'tree', 'name' => 'Tree', 'layer' => 'buildings', 'layerIssue' => null,
+            'connected' => false, 'width' => 2, 'height' => 2])
+        ->and($tree['picture'])->toBe([
+            [['symbol' => '^', 'color' => '#5faf00'], ['symbol' => '^', 'color' => '#5faf00']],
+            [['symbol' => '|', 'color' => '#875f00'], ['symbol' => ' ', 'color' => null]],
+        ])
+        ->and($wall['connected'])->toBeTrue()
+        ->and(array_map(static fn(array $row): string => implode('', array_column($row, 'symbol')), $wall['picture']))->toBe(['+-+', '| |', '+-+'])
+        // A piece for a layer the map lacks is listed, saying so.
+        ->and($shelf['layerIssue'])->toContain('storage layer, which this map does not have');
+});
+
+it('says why a map offers no pieces', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root);
+
+    expect(EditorSession::open($root)->listPieces('test-map'))->toMatchArray(['pieces' => []])
+        ->and(EditorSession::open($root)->listPieces('test-map')['issue'])->toContain('has no pieces');
+});
+
+it('stamps whole copies of a piece across the dragged area in their own colours, as previewed, in one undo step', function () {
+    $session = pieceSession();
+    $preview = $session->previewPiece('test-map', 'tree', [0, 0], [3, 1], 'cyan');
+
+    expect($preview['cells'])->toBe([
+        [0, 0, '^', '#5faf00'], [1, 0, '^', '#5faf00'], [2, 0, '^', '#5faf00'], [3, 0, '^', '#5faf00'],
+        [0, 1, '|', '#875f00'], [2, 1, '|', '#875f00'],
+    ]);
+    expect(placeSessionPiece($session, 'tree', [0, 0], [3, 1], 'cyan'))->toMatchArray(['status' => 'applied', 'changed' => true, 'copies' => 2])
+        // The gaps leave the map's own cells as they were.
+        ->and(readPieceSessionRows($session))->toBe(['^^^^', '|x| ']);
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4');
+    expect($layer['colors'])->toContain([0, 0, '#5faf00'], [2, 1, '#875f00']);
+
+    $session->undo();
+    expect(readPieceSessionRows($session))->toBe([' /  ', ' xx ']);
+});
+
+it('draws a connected piece along the outline between the corners, as previewed', function () {
+    $session = pieceSession();
+    $preview = $session->previewPiece('test-map', 'wall', [0, 0], [3, 1]);
+
+    expect(placeSessionPiece($session, 'wall', [0, 0], [3, 1]))->toMatchArray(['changed' => true, 'cells' => 8]);
+    $rows = readPieceSessionRows($session);
+    // Every outline cell is a wall, each shaped as the preview showed it.
+    expect(count($preview['cells']))->toBe(8);
+    foreach ($preview['cells'] as [$x, $y, $glyph]) {
+        expect(mb_substr($rows[$y], $x, 1))->toBe($glyph)->toBeIn(['+', '-', '|']);
+    }
+    expect(placeSessionPiece($session, 'wall', [0, 0], [3, 1]))->toMatchArray(['changed' => false]);
+});
+
+it('refuses a placement that cannot be made whole, a stale revision or an unknown piece, changing nothing', function () {
+    $session = pieceSession();
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => placeSessionPiece($session, 'tree', [3, 1], [3, 1]))->toThrow(SessionRefusal::class, 'does not fit')
+        ->and(fn() => placeSessionPiece($session, 'shelf', [0, 0], [0, 0]))->toThrow(SessionRefusal::class, 'storage layer')
+        ->and(fn() => placeSessionPiece($session, 'gone', [0, 0], [0, 0]))->toThrow(SessionRefusal::class, 'no longer in the map\'s tileset')
+        ->and(fn() => $session->placePiece('test-map', $revision + 1, 'tree', [0, 0], [0, 0]))->toThrow(SessionRefusal::class)
+        ->and(readPieceSessionRows($session))->toBe([' /  ', ' xx '])
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse();
+});
diff --git a/tests/Unit/Session/PlaceTypeAuthoringTest.php b/tests/Unit/Session/PlaceTypeAuthoringTest.php
new file mode 100644
index 00000000..10b2168b
--- /dev/null
+++ b/tests/Unit/Session/PlaceTypeAuthoringTest.php
@@ -0,0 +1,164 @@
+ ['name' => 'Walls', 'layer' => 'buildings', 'glyphs' => ['#^', '#∩'], 'tiles' => ['decor' => ['1 2', '9 10']]],
+        'hut' => ['name' => 'Hut', 'layer' => 'buildings', 'glyphs' => ['^', '∩'], 'tiles' => ['decor' => ['3', '11']]],
+    ]);
+    if ($icons !== []) {
+        $path = $root . '/assets/Data/Tilesets/home.php';
+        $data = require $path;
+        file_put_contents($path, " $icons], true) . ";\n");
+    }
+    $maps = $root . '/assets/Maps';
+    copyDirectoryRecursively($maps . '/test-map', $maps . '/site');
+    rename($maps . '/site/test-map.data.php', $maps . '/site/site.data.php');
+    rename($maps . '/site/test-map.event.php', $maps . '/site/site.event.php');
+    file_put_contents($maps . '/site/site.data.php', str_replace("'name' => 'Test Map'", "'name' => 'Site'",
+        (string) file_get_contents($maps . '/site/site.data.php')));
+    $world = $maps . '/test-map';
+    file_put_contents($world . '/layers/04.buildings.map.php', MapGridSource::buildSource("#^  \n#∩  ", 'AUTHORED'));
+    writeTileLayer($world, '02.decor.tiles.php', "1 2 0 0\n9 10 0 0");
+    file_put_contents($world . '/test-map.event.php', MapGridSource::buildSource("    \n E  ", 'EVENTS'));
+    file_put_contents($world . '/test-map.data.php', str_replace("'events' => [],", <<<'PHP'
+'events' => [
+        'E' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger',
+            'data' => ['destinationMap' => 'site', 'spawnPoint' => ['x' => 1, 'y' => 0]]],
+    ],
+PHP, (string) file_get_contents($world . '/test-map.data.php')));
+
+    return $root;
+}
+
+/** Makes a place type on the Place Types page; returns its index. */
+function createPlaceType(EditorSession $session, string $name): int
+{
+    $index = $session->createDatabaseRecord(PlaceTypeFields::CATEGORY)['index'];
+    foreach (['id' => strtolower($name), 'name.default' => $name] as $field => $value) {
+        $row = array_find($session->readDatabaseRecord(PlaceTypeFields::CATEGORY, $index)['rows'],
+            static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+        $session->applyDatabaseRecord(PlaceTypeFields::CATEGORY, $index, $row['key'], $value);
+    }
+
+    return $index;
+}
+
+function setPlaceType(EditorSession $session, string $mapId, string $type): array
+{
+    return applyLandmarkRow($session, ['target' => MapInspector::PLACE_TYPE_TARGET], $type, null, $mapId);
+}
+
+/** @return list> The project's issues with this code. */
+function findPlaceIssues(EditorSession $session, string $code): array
+{
+    return array_values(array_filter($session->validateProject()['issues'], static fn(array $issue): bool => $issue['code'] === $code));
+}
+
+/** @return array> The tileset record's rows by field id. */
+function readPlaceIconRows(EditorSession $session): array
+{
+    $rows = [];
+    foreach ($session->readDatabaseRecord('tilesets', 0)['rows'] as $row) {
+        $rows[is_string($row['key']['field'] ?? null) ? $row['key']['field'] : json_encode($row['key'] ?? null)] = $row;
+    }
+
+    return $rows;
+}
+
+it('chooses a map\'s place type from the project\'s types, refusing one it does not have, as one undo step', function () {
+    $root = createPlaceIconProject();
+    $session = EditorSession::open($root);
+    createPlaceType($session, 'Outpost');
+    $row = findLandmarkRow($session, ['target' => MapInspector::PLACE_TYPE_TARGET], null, 'site');
+    expect($row['value'])->toBe(MapInspector::PLACE_TYPE_NONE)
+        ->and($row['label'])->toBe('Place Type')
+        ->and($row['reference'])->toBe(PlaceTypeFields::CATEGORY);
+
+    expect(fn() => setPlaceType($session, 'site', 'castle'))->toThrow(SessionRefusal::class, 'castle is not a place type');
+    setPlaceType($session, 'site', 'outpost');
+    expect(getSessionMap($session, 'site')->getMapDataField([PlaceTypeCatalog::MAP_KEY]))->toBe('outpost')
+        ->and(findLandmarkRow($session, ['target' => MapInspector::PLACE_TYPE_TARGET], null, 'site')['value'])->toBe('Outpost (outpost)');
+    $session->undo();
+    expect(getSessionMap($session, 'site')->hasMapDataField([PlaceTypeCatalog::MAP_KEY]))->toBeFalse();
+    $session->redo();
+
+    // A type stays while a map is of it.
+    expect(fn() => $session->deleteDatabaseRecord(PlaceTypeFields::CATEGORY, 0))->toThrow(SessionRefusal::class, 'map site');
+    $session->saveDatabase(PlaceTypeFields::CATEGORY);
+    expect(PlaceTypeCatalog::load($root . '/assets')->getName('outpost'))->toBe('Outpost');
+});
+
+it('gives a place type its icon in Database > Tilesets, choosing among the tileset\'s whole pieces', function () {
+    $root = createPlaceIconProject();
+    $session = EditorSession::open($root);
+    createPlaceType($session, 'Outpost');
+    $session->saveDatabase(PlaceTypeFields::CATEGORY);
+
+    $heading = array_find($session->readDatabaseRecord('tilesets', 0)['rows'],
+        static fn(array $row): bool => ($row['label'] ?? null) === 'Place Icons' || str_starts_with((string) ($row['label'] ?? ''), 'Place Icons'));
+    expect($heading)->not->toBeNull();
+    $session->addDatabaseItem('tilesets', 0, $heading['key']);
+    $rows = readPlaceIconRows($session);
+    expect($rows['placeIcon0Piece']['options'])->toBe(['', 'walls', 'hut'])
+        ->and($rows['placeIcon0Type']['reference'])->toBe(PlaceTypeFields::CATEGORY);
+    $session->applyDatabaseRecord('tilesets', 0, $rows['placeIcon0Type']['key'], 'outpost');
+    $session->applyDatabaseRecord('tilesets', 0, readPlaceIconRows($session)['placeIcon0Piece']['key'], 'hut');
+    $session->saveDatabase('tilesets');
+
+    expect(Tileset::load($root . '/assets', 'home')->placeIcons)->toBe(['outpost' => 'hut']);
+    // A type stays while a tileset gives it an icon.
+    expect(fn() => $session->deleteDatabaseRecord(PlaceTypeFields::CATEGORY, 0))->toThrow(SessionRefusal::class, 'tileset home');
+});
+
+it('warns where an entrance\'s icon is not its place type\'s, and follows the type as it changes', function () {
+    $session = EditorSession::open(createPlaceIconProject(['town' => 'walls', 'outpost' => 'hut']));
+    createPlaceType($session, 'Town');
+    createPlaceType($session, 'Outpost');
+
+    // A walled settlement over the way into a single outpost.
+    setPlaceType($session, 'site', 'outpost');
+    $wrong = findPlaceIssues($session, MapPlaceIconRule::ICON);
+    expect($wrong)->toHaveCount(1)
+        ->and($wrong[0]['severity'])->toBe('warning')
+        ->and($wrong[0]['map'])->toBe('test-map')
+        ->and($wrong[0]['cells'])->toBe([[1, 1]])
+        ->and($wrong[0]['message'])->toBe("Entrance 'E' leads to site, a place of type Outpost, whose icon is Hut; the Walls stands over it.");
+
+    // The same icon over the way into a town shows its type.
+    setPlaceType($session, 'site', 'town');
+    expect(findPlaceIssues($session, MapPlaceIconRule::ICON))->toBe([]);
+
+    // An icon over the way into a map naming no type claims what cannot be checked.
+    setPlaceType($session, 'site', '');
+    $untyped = findPlaceIssues($session, MapPlaceIconRule::UNTYPED);
+    expect($untyped)->toHaveCount(1)->and($untyped[0]['message'])->toContain('shows the Walls icon', 'names no place type');
+});
+
+it('reports a map naming a place type the project does not have', function () {
+    $root = createPlaceIconProject();
+    $file = $root . '/assets/Maps/site/site.data.php';
+    file_put_contents($file, str_replace("'name' => 'Site',", "'name' => 'Site', 'placeType' => 'castle',", (string) file_get_contents($file)));
+
+    $issues = findPlaceIssues(EditorSession::open($root), 'map.place-type');
+    expect($issues)->toHaveCount(1)
+        ->and($issues[0]['severity'])->toBe('error')
+        ->and($issues[0]['message'])->toContain('castle is not a place type');
+});
diff --git a/tests/Unit/Session/ProjectMenuSessionTest.php b/tests/Unit/Session/ProjectMenuSessionTest.php
new file mode 100644
index 00000000..b1a23bfa
--- /dev/null
+++ b/tests/Unit/Session/ProjectMenuSessionTest.php
@@ -0,0 +1,84 @@
+createProject('Lake Legend', $root . '/lake-legend', 'Mira', 'active_time');
+
+    expect($created)->toBe(realpath($root . '/lake-legend'))
+        ->and(json_decode((string) file_get_contents($root . '/arguments.json'), true))
+        ->toBe(['new', 'Lake Legend', '--directory', $root . '/lake-legend', '--no-interaction', '--no-install', '--hero', 'Mira', '--battle-engine', 'active_time'])
+        ->and(fn() => $creator->createProject('Again', $root . '/lake-legend'))->toThrow(RuntimeException::class, 'already exists')
+        ->and(fn() => $creator->createProject('', $root . '/other'))->toThrow(RuntimeException::class, 'title')
+        ->and(fn() => $creator->createProject('Rel', 'relative/path'))->toThrow(RuntimeException::class, 'Choose where')
+        ->and(fn() => $creator->createProject('Gone', $root . '/missing/game'))->toThrow(RuntimeException::class, 'does not exist')
+        ->and(fn() => $creator->createProject('Odd', $root . '/odd', battleEngine: 'turbo'))->toThrow(RuntimeException::class, 'traditional or active_time')
+        ->and(fn() => new ProjectCreator(fakeConsole($root, true))->createProject('Shut', $root . '/shut'))->toThrow(RuntimeException::class, 'The guild is closed.');
+
+    removeDirectoryRecursively($root);
+});
+
+it('finds maps, events and NPCs across the project, by what they are called and hold', function () {
+    $root = makeTemporaryProject();
+    $session = EditorSession::open($root);
+    [$map] = $session->describeMaps();
+    $session->createNpc($map['id'], $map['revision'], 2, 2, 'Lantern Keeper');
+
+    expect(array_column($session->searchProject('test'), 'kind'))->toContain('map')
+        // A chest found by the item it gives, with where it is.
+        ->and($session->searchProject('s-potion'))->toBe([[
+            'kind' => 'event', 'map' => 'test-map', 'label' => 'E Chest', 'detail' => 'Test Map · S-Potion', 'marker' => 'E',
+            ...array_intersect_key($session->searchProject('E')[0], ['x' => true, 'y' => true]),
+        ]])
+        // One character is a marker, not every text holding it.
+        ->and(array_column($session->searchProject('E'), 'marker'))->toBe(['E'])
+        ->and($session->searchProject('lantern'))->toHaveCount(1)
+        ->and($session->searchProject('lantern')[0])->toMatchArray(['kind' => 'npc', 'map' => 'test-map', 'label' => 'Lantern Keeper', 'index' => 0, 'x' => 2, 'y' => 2])
+        ->and($session->searchProject('   '))->toBe([]);
+});
+
+it('serves project creation without an open project, and search with one', function () {
+    $root = makeTemporaryProject();
+    $host = new SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(int $id, string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => $id, 'method' => $method, 'params' => $params]));
+
+    // No project is open, and a refused creation says why.
+    expect($request(1, 'project.create', ['title' => 'Lake', 'directory' => 'relative'])['error'])->toMatchArray(['kind' => 'refusal'])
+        ->and($request(2, 'project.search', ['query' => 'test'])['error']['message'])->toContain('Say hello');
+
+    $request(3, 'hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+
+    expect($request(4, 'project.search', ['query' => 'test'])['result'][0])->toMatchArray(['kind' => 'map', 'map' => 'test-map']);
+});
diff --git a/tests/Unit/Session/RecordConditionCarryTest.php b/tests/Unit/Session/RecordConditionCarryTest.php
new file mode 100644
index 00000000..292eb65d
--- /dev/null
+++ b/tests/Unit/Session/RecordConditionCarryTest.php
@@ -0,0 +1,31 @@
+ 'quest', 'name' => 'harvest', 'status' => 'failed']];
+    $root = npcSessionProject([['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 2, 'y' => 1, 'conditions' => $conditions]]);
+    $session = EditorSession::open($root);
+    $read = $session->readNpc('test-map', 0);
+    $row = npcSessionRow($read, 'conditions');
+
+    expect($row['kind'])->toBe('info')
+        ->and($row['value'])->toStartWith('! condition 1 has a quest status the condition editor would rewrite')
+        ->and(fn() => $session->applyNpc('test-map', $read['revision'], 0, $row['key'], 'switch door-open'))
+            ->toThrow(SessionRefusal::class, 'cannot be edited')
+        ->and(getSessionMap($session)->getNpcs()->all()[0]->toArray()['conditions'])->toBe($conditions)
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('keeps a condition list the line carries whole editable through the same row', function () {
+    $root = npcSessionProject([['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 2, 'y' => 1,
+        'conditions' => [['type' => 'switch', 'name' => 'door-open', 'value' => true]]]]);
+    $session = EditorSession::open($root);
+    $read = $session->readNpc('test-map', 0);
+
+    expect(npcSessionRow($read, 'conditions')['kind'])->toBe('conditions');
+});
diff --git a/tests/Unit/Session/SaveMapRetirementTest.php b/tests/Unit/Session/SaveMapRetirementTest.php
new file mode 100644
index 00000000..d970f184
--- /dev/null
+++ b/tests/Unit/Session/SaveMapRetirementTest.php
@@ -0,0 +1,296 @@
+ CollisionType::SOLID, '~' => CollisionType::SOLID, '.' => CollisionType::NONE, ' ' => CollisionType::NONE, ';' => CollisionType::ENCOUNTER];\n");
+    file_put_contents($root . '/assets/Data/system.php', " ['player' => "
+        . "['destinationMap' => 'field', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['South']]]];\n");
+    $maps = [
+        'field' => [["########", "#......#", "#.~~.;.#", "#......#", "#......#", "########"],
+            ["        ", "      E ", "        ", "        ", "        ", "        "],
+            "'events' => ['E' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\ChestEventTrigger', 'data' => ['lootType' => 'item', 'loot' => 'S-Potion']]],\n"
+            . "  'npcs' => [['id' => 'guard', 'name' => 'Guard', 'sprite' => 'g', 'x' => 4, 'y' => 3, 'dialogue' => [['text' => 'Halt.']]]],\n"],
+        'old-road' => [["######", "#....#", "######"], ["      ", "      ", "      "], "'events' => [],\n"],
+    ];
+    foreach ($maps as $mapId => [$tiles, $events, $content]) {
+        writeRetirementMap($root, $mapId, $tiles, $events, $content);
+    }
+
+    return $root;
+}
+
+/** Writes one map of the synthetic project from its glyph rows, event rows and data entries. */
+function writeRetirementMap(string $root, string $mapId, array $tiles, array $events, string $content): void
+{
+    $folder = $root . '/assets/Maps/' . $mapId;
+    is_dir($folder) || mkdir($folder, 0o777, true);
+    file_put_contents("{$folder}/{$mapId}.map.php", " '{$mapId}',\n  'region' => '',\n  'triggers' => [],\n  {$content}];\n");
+}
+
+/**
+ * The synthetic project with a second successor: `reach`, a small field the player reaches from `field` through a
+ * door, holding the part of `old-road` split off from `field`.
+ */
+function splitRetirementProject(): string
+{
+    $root = retirementProject();
+    writeRetirementMap($root, 'field', ["########", "#......#", "#.~~.;.#", "#......#", "#......#", "########"],
+        ["        ", "      E ", "        ", "        ", "      D ", "        "],
+        "'events' => ['E' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\ChestEventTrigger', 'data' => ['lootType' => 'item', 'loot' => 'S-Potion']],\n"
+        . "    'D' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\TransferPlayerTrigger', 'data' => ['destinationMap' => 'reach', 'spawnPoint' => ['x' => 2, 'y' => 1], 'spawnSprite' => ['South']]]],\n"
+        . "  'npcs' => [['id' => 'guard', 'name' => 'Guard', 'sprite' => 'g', 'x' => 4, 'y' => 3, 'dialogue' => [['text' => 'Halt.']]]],\n");
+    writeRetirementMap($root, 'reach', ["#####", "#...#", "#.~.#", "#####"], ["     ", " A   ", "     ", "     "],
+        "'events' => ['A' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\TransferPlayerTrigger', 'data' => ['destinationMap' => 'field', 'spawnPoint' => ['x' => 5, 'y' => 4], 'spawnSprite' => ['West']]]],\n");
+
+    return $root;
+}
+
+function readRetirementManifest(string $root): array
+{
+    return require $root . '/assets/Data/save-compatibility.php';
+}
+
+/** The retirement most cases write: old-road's west end lands in field's south-east, everything else in its south-west. */
+function retireOldRoad(EditorSession $session): array
+{
+    return $session->retireMap('old-road', 'field', [1, 4], [['x' => 0, 'y' => 0, 'width' => 3, 'height' => 3, 'to' => [5, 4]]]);
+}
+
+it('appends one retirement step that the game reads, keeping the manifest as authored, with undo', function () {
+    $root = retirementProject();
+    $path = $root . '/assets/Data/save-compatibility.php';
+    file_put_contents($path, str_replace('return [', "// Authored by the release team.\nreturn [", (string) file_get_contents($path)));
+    $before = (string) file_get_contents($path);
+    $session = EditorSession::open($root);
+
+    expect(retireOldRoad($session))->toBe(['from' => 0, 'to' => 1, 'contentVersion' => 1, 'map' => 'old-road', 'into' => 'field'])
+        ->and(readRetirementManifest($root)['migrations'])->toBe([['from' => 0, 'to' => 1, 'mapRetirements' => [[
+            'map' => 'old-road', 'into' => 'field', 'landings' => [['x' => 0, 'y' => 0, 'width' => 3, 'height' => 3, 'to' => [5, 4]]], 'to' => [1, 4],
+        ]]]])
+        ->and((string) file_get_contents($path))->toContain('// Authored by the release team.')
+        ->and(describeSaveCompatibilityIssues($root))->toBe([]);
+
+    $migration = SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0);
+    expect($migration->movePlace('old-road', new Vector2(2, 1)))->toEqual(['mapId' => 'field', 'position' => new Vector2(5, 4)])
+        ->and($migration->movePlace('old-road', new Vector2(4, 1)))->toEqual(['mapId' => 'field', 'position' => new Vector2(1, 4)]);
+
+    expect($session->undo()['label'])->toBe('Retire map old-road')
+        ->and((string) file_get_contents($path))->toBe($before);
+    $session->redo();
+    expect(readRetirementManifest($root)['contentVersion'])->toBe(1);
+});
+
+it('refuses a retirement whose landings cannot be stood on, writing nothing', function (string $into, array $to, ?array $landings, string $reason) {
+    $root = retirementProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+
+    expect(fn() => $session->retireMap('old-road', $into, $to, $landings))->toThrow(SessionRefusal::class, $reason)
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull();
+})->with([
+    'solid' => ['field', [2, 2], null, 'field x 2, y 2 (it is solid)'],
+    'an event' => ['field', [1, 4], [['x' => 0, 'y' => 0, 'width' => 1, 'height' => 1, 'to' => [6, 1]]], 'field x 6, y 1 (an event is there)'],
+    'an NPC' => ['field', [4, 3], null, 'field x 4, y 3 (an NPC stands there)'],
+    'grass' => ['field', [5, 2], null, 'field x 5, y 2 (it is not plain floor (encounter))'],
+    'off the map' => ['field', [9, 9], null, 'field x 9, y 9 (it is off the map)'],
+    'an unknown destination' => ['nowhere', [1, 1], null, 'There is no map nowhere for the saves on old-road to move into.'],
+    'a malformed landing' => ['field', [1, 4], [['x' => 0, 'y' => 0, 'width' => 0, 'height' => 1, 'to' => [5, 4]]], 'landings[0] width must be a positive integer'],
+]);
+
+it('lands part of a split retired map on the successor its landing names', function () {
+    $root = splitRetirementProject();
+    $session = EditorSession::open($root);
+
+    expect($session->retireMap('old-road', 'field', [1, 4], [['x' => 3, 'y' => 0, 'width' => 3, 'height' => 3, 'map' => 'reach', 'to' => [3, 1]]]))
+        ->toBe(['from' => 0, 'to' => 1, 'contentVersion' => 1, 'map' => 'old-road', 'into' => 'field'])
+        ->and(readRetirementManifest($root)['migrations'][0]['mapRetirements'][0]['landings'])
+        ->toBe([['x' => 3, 'y' => 0, 'width' => 3, 'height' => 3, 'map' => 'reach', 'to' => [3, 1]]])
+        ->and(describeSaveCompatibilityIssues($root))->toBe([]);
+
+    $migration = SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0);
+    expect($migration->movePlace('old-road', new Vector2(4, 1)))->toEqual(['mapId' => 'reach', 'position' => new Vector2(3, 1)])
+        ->and($migration->movePlace('old-road', new Vector2(1, 1)))->toEqual(['mapId' => 'field', 'position' => new Vector2(1, 4)]);
+});
+
+it('refuses a split landing on a missing map, on the retired map or where a save cannot stand on its own map', function (array $landing, string $reason) {
+    $root = splitRetirementProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+
+    expect(fn() => $session->retireMap('old-road', 'field', [1, 4], [['x' => 3, 'y' => 0, 'width' => 3, 'height' => 3, ...$landing]]))
+        ->toThrow(SessionRefusal::class, $reason)
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+})->with([
+    'a missing map' => [['map' => 'nowhere', 'to' => [1, 1]], 'nowhere x 1, y 1 (there is no such map)'],
+    'the retired map' => [['map' => 'old-road', 'to' => [1, 1]], 'map old-road is the retired map'],
+    'water on the other successor' => [['map' => 'reach', 'to' => [2, 2]], 'reach x 2, y 2 (it is solid)'],
+    'an event on the other successor' => [['map' => 'reach', 'to' => [1, 1]], 'reach x 1, y 1 (an event is there)'],
+]);
+
+it('re-checks a split landing on its own map when a change set commits', function () {
+    $root = splitRetirementProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+
+    $session->beginChanges();
+    $session->retireMap('old-road', 'field', [1, 4], [['x' => 3, 'y' => 0, 'width' => 3, 'height' => 3, 'map' => 'reach', 'to' => [3, 1]]]);
+    $map = $session->readMap('reach');
+    $session->paint('reach', $map['revision'], $map['baseLayer'], [[3, 1]], '~');
+    $refused = $session->commitChanges(relocateSaves: true);
+
+    expect($refused['status'])->toBe('refused')
+        ->and($refused['reason'])->toContain('saves on old-road at reach x 3, y 1 (it is solid)')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+});
+
+it('reports a split landing on a map the project no longer has', function () {
+    $root = splitRetirementProject();
+    $session = EditorSession::open($root);
+    $session->retireMap('old-road', 'field', [1, 4], [['x' => 3, 'y' => 0, 'width' => 3, 'height' => 3, 'map' => 'reach', 'to' => [3, 1]]]);
+    $path = $root . '/assets/Data/save-compatibility.php';
+    file_put_contents($path, str_replace("'map' => 'reach'", "'map' => 'gone'", (string) file_get_contents($path)));
+
+    expect(describeSaveCompatibilityIssues($root))->toBe(['Saves on old-road move into gone, which is not a map in the project.']);
+});
+
+it('retires a redrawn map into itself, its landings judged on the layout the session holds', function () {
+    $root = retirementProject();
+    $session = EditorSession::open($root);
+
+    expect(fn() => $session->retireMap('field', 'field', [2, 2]))->toThrow(SessionRefusal::class, 'field x 2, y 2 (it is solid)');
+
+    $retired = $session->retireMap('field', 'field', [1, 4], [['x' => 0, 'y' => 0, 'width' => 4, 'height' => 2, 'to' => [5, 4]]]);
+
+    expect($retired)->toMatchArray(['map' => 'field', 'into' => 'field'])
+        ->and(readRetirementManifest($root)['migrations'][0]['mapRetirements'][0])->toMatchArray(['map' => 'field', 'into' => 'field', 'to' => [1, 4]]);
+});
+
+it('judges landings on the destination as the session holds it, unsaved edits included', function () {
+    $root = retirementProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('field');
+    $session->paint('field', $map['revision'], $map['baseLayer'], [[1, 4]], '~');
+
+    expect(fn() => $session->retireMap('old-road', 'field', [1, 4]))->toThrow(SessionRefusal::class, 'field x 1, y 4 (it is solid)');
+});
+
+it('writes a retirement inside a change set, and refuses one whose later edits take a landing, leaving nothing', function () {
+    $root = retirementProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+
+    $session->beginChanges();
+    retireOldRoad($session);
+    $map = $session->readMap('field');
+    $session->paint('field', $map['revision'], $map['baseLayer'], [[5, 4]], '~');
+    $refused = $session->commitChanges(relocateSaves: true);
+
+    expect($refused['status'])->toBe('refused')
+        ->and($refused['reason'])->toContain('map retirement landings where saves cannot stand: saves on old-road at field x 5, y 4 (it is solid)')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([]);
+
+    $session->beginChanges();
+    retireOldRoad($session);
+    expect($session->commitChanges()['status'])->toBe('saved')
+        ->and(readRetirementManifest($root)['contentVersion'])->toBe(1);
+});
+
+it('follows a landing through the steps written after it in the same change set', function () {
+    $root = retirementProject();
+    $session = EditorSession::open($root);
+
+    $session->beginChanges();
+    $session->retireMap('old-road', 'field', [5, 3]);
+    $question = $session->insertMapLines('field', $session->readMap('field')['revision'], 'x', 0, 1);
+    $session->insertMapLines('field', $question['revision'], 'x', 0, 1, 'write', $question['confirm']);
+
+    // The column moves the landing to x 6, y 3 with the floor under it; x 5, y 3 is now the guard's cell.
+    expect($session->commitChanges()['status'])->toBe('saved')
+        ->and(SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(1)->movePlace('field', new Vector2(5, 3)))
+        ->toEqual(['mapId' => 'field', 'position' => new Vector2(6, 3)]);
+});
+
+it('adds a save alias with undo, inside a change set too, and refuses one the game or the catalogue would not take', function () {
+    $root = retirementProject();
+    $path = $root . '/assets/Data/save-compatibility.php';
+    $before = (string) file_get_contents($path);
+    $session = EditorSession::open($root);
+
+    expect($session->addSaveAlias('one_shot_events', 'old-road:A', 'field:E'))
+        ->toBe(['category' => 'one_shot_events', 'from' => 'old-road:A', 'to' => 'field:E', 'added' => true])
+        ->and(readRetirementManifest($root)['aliases'])->toBe(['one_shot_events' => [['from' => 'old-road:A', 'to' => 'field:E']]])
+        ->and(describeSaveCompatibilityIssues($root))->toBe([])
+        ->and($session->addSaveAlias('one_shot_events', 'old-road:A', 'field:E')['added'])->toBeFalse();
+    $session->addSaveAlias('maps', 'old-field', 'field');
+
+    expect(fn() => $session->addSaveAlias('maps', 'old-field', 'old-road'))->toThrow(SessionRefusal::class, 'already sends old-field to field')
+        ->and(fn() => $session->addSaveAlias('one_shot_events', 'old-road:B', 'field:Z'))->toThrow(SessionRefusal::class, 'field:Z is not a current one_shot_events identity')
+        ->and(fn() => $session->addSaveAlias('one_shot_events', 'old-road:B', 'field'))->toThrow(SessionRefusal::class, 'is not a current one_shot_events identity')
+        ->and(fn() => $session->addSaveAlias('places', 'a', 'b'))->toThrow(SessionRefusal::class, 'places is not a saved-content category')
+        ->and(fn() => $session->addSaveAlias('maps', 'field', 'field'))->toThrow(SessionRefusal::class, 'self-alias');
+
+    expect($session->undo()['label'])->toBe('Save alias')
+        ->and($session->undo()['label'])->toBe('Save alias')
+        ->and((string) file_get_contents($path))->toBe($before);
+
+    $session->beginChanges();
+    $session->addSaveAlias('maps', 'old-field', 'field');
+    expect(readRetirementManifest($root)['aliases'])->toBe(['maps' => [['from' => 'old-field', 'to' => 'field']]]);
+    $session->cancelChanges();
+    expect((string) file_get_contents($path))->toBe($before);
+});
+
+it('reports a retirement into a map the project no longer has', function () {
+    $root = retirementProject();
+    $session = EditorSession::open($root);
+    retireOldRoad($session);
+    $path = $root . '/assets/Data/save-compatibility.php';
+    file_put_contents($path, str_replace("'into' => 'field'", "'into' => 'gone'", (string) file_get_contents($path)));
+
+    expect(describeSaveCompatibilityIssues($root))->toBe(['Saves on old-road move into gone, which is not a map in the project.']);
+
+    file_put_contents($path, str_replace("'aliases' => [", "'aliases' => ['maps' => [['from' => 'gone', 'to' => 'field']],", (string) file_get_contents($path)));
+    expect(describeSaveCompatibilityIssues($root))->toBe([]);
+});
+
+it('serves map retirement and save aliases over the session protocol', function () {
+    $root = retirementProject();
+    $host = new Ichiloto\Editor\Session\SessionHost(fopen('php://memory', 'r'), fopen('php://memory', 'w'), fopen('php://memory', 'w'));
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    expect($request('hello', ['protocol' => Ichiloto\Editor\Session\SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result');
+
+    expect($request('saveCompatibility.retireMap', ['map' => 'old-road', 'into' => 'field', 'to' => 'x'])['error']['kind'])->toBe('request')
+        ->and($request('saveCompatibility.retireMap', ['map' => 'old-road', 'into' => 'field', 'to' => [1, 4], 'landings' => 'all'])['error']['kind'])->toBe('request')
+        ->and($request('saveCompatibility.retireMap', ['map' => 'old-road', 'into' => 'field', 'to' => [2, 2]])['error']['kind'])->toBe('refusal')
+        ->and($request('saveCompatibility.retireMap', ['map' => 'old-road', 'into' => 'field', 'to' => [1, 4],
+            'landings' => [['x' => 0, 'y' => 0, 'width' => 3, 'height' => 3, 'to' => [5, 4]]]])['result'])
+        ->toBe(['from' => 0, 'to' => 1, 'contentVersion' => 1, 'map' => 'old-road', 'into' => 'field'])
+        ->and($request('saveCompatibility.alias', ['category' => 'one_shot_events', 'from' => 'old-road:A', 'to' => 'field:E'])['result']['added'])->toBeTrue()
+        ->and($request('saveCompatibility.alias', ['category' => 'one_shot_events', 'from' => 'old-road:A'])['error']['kind'])->toBe('request');
+});
diff --git a/tests/Unit/Session/SaveMigrationRegistrationTest.php b/tests/Unit/Session/SaveMigrationRegistrationTest.php
new file mode 100644
index 00000000..9f9e5667
--- /dev/null
+++ b/tests/Unit/Session/SaveMigrationRegistrationTest.php
@@ -0,0 +1,117 @@
+setPsr4('Ichiloto\\\\Engine\\\\', [getenv('ICHILOTO_ENGINE_SRC') . '/src']);
+    }
+    \$loader->setPsr4('Synthetic\\\\', [__DIR__ . '/../assets/Migrations']);
+    return \$loader;
+    PHP);
+    mkdir($root . '/assets/Migrations', 0o777, true);
+    file_put_contents($root . '/assets/Migrations/RepairArrival.php', "registerSaveMigration('\\Synthetic\\RepairArrival'))
+        ->toBe(['from' => 0, 'to' => 1, 'class' => 'Synthetic\\RepairArrival', 'contentVersion' => 1])
+        ->and(readMigrationManifest($root))->toMatchArray(['contentVersion' => 1, 'migrations' => [['from' => 0, 'to' => 1, 'class' => 'Synthetic\\RepairArrival']]])
+        ->and((string) file_get_contents($path))->toContain('// Authored by the release team.')
+        ->and(describeSaveCompatibilityIssues($root))->toBe([]);
+
+    expect($session->undo()['label'])->toBe('Register save migration')
+        ->and((string) file_get_contents($path))->toBe($before);
+    $session->redo();
+    expect(readMigrationManifest($root)['contentVersion'])->toBe(1);
+});
+
+it('refuses a class the game would not run as a migration, writing nothing', function (string $class, string $reason) {
+    $root = migrationProject();
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+
+    expect(fn() => $session->registerSaveMigration($class))->toThrow(SessionRefusal::class, $reason)
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->undo()['label'])->toBeNull();
+})->with([
+    'not a migration' => ['Synthetic\\NotAMigration', 'class "Synthetic\\NotAMigration" must implement Ichiloto\\Engine\\IO\\SaveCompatibility\\ContentMigrationInterface'],
+    'a class that does not load' => ['Synthetic\\Missing', 'class "Synthetic\\Missing" must implement'],
+    'no class' => ['  ', 'Name the migration class to register.'],
+]);
+
+it('refuses when the project has no autoloader or a manifest it cannot rewrite', function () {
+    $root = migrationProject();
+    unlink($root . '/vendor/autoload.php');
+    $session = EditorSession::open($root);
+    expect(fn() => $session->registerSaveMigration('Synthetic\\RepairArrival'))->toThrow(SessionRefusal::class, 'the project has no vendor/autoload.php');
+
+    $root = migrationProject();
+    $path = $root . '/assets/Data/save-compatibility.php';
+    file_put_contents($path, str_replace("'migrations' => []", "'migrations' => array_values([])", (string) file_get_contents($path)));
+    $before = (string) file_get_contents($path);
+    $session = EditorSession::open($root);
+    expect(fn() => $session->registerSaveMigration('Synthetic\\RepairArrival'))->toThrow(SessionRefusal::class, 'cannot take the migration step')
+        ->and((string) file_get_contents($path))->toBe($before);
+});
+
+it('writes a change set that registers a migration, and refuses one that also moves saves off map cells', function () {
+    $root = migrationProject();
+    // The quay is where the game starts, so saves may stand on its floor.
+    file_put_contents($root . '/assets/Maps/collisions.php', " CollisionType::SOLID, '=' => CollisionType::SOLID, ' ' => CollisionType::NONE];\n");
+    file_put_contents($root . '/assets/Data/system.php', " ['player' => "
+        . "['destinationMap' => 'harbour', 'spawnPoint' => ['x' => 5, 'y' => 3], 'spawnSprite' => ['South']]]];\n");
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+
+    $session->beginChanges();
+    $session->registerSaveMigration('Synthetic\\RepairArrival');
+    $map = $session->readMap('harbour');
+    $session->paint('harbour', $map['revision'], $map['baseLayer'], [[6, 4]], '#');
+    $refused = $session->commitChanges(relocateSaves: true);
+
+    expect($refused['status'])->toBe('refused')
+        ->and($refused['reason'])->toContain('The Engine has no save migration step that does both')
+        ->and(sourceHashTree($root . '/assets'))->toBe($before)
+        ->and($session->listUnsavedChanges())->toBe([]);
+
+    $session->beginChanges();
+    $session->registerSaveMigration('Synthetic\\RepairArrival');
+    expect($session->commitChanges()['status'])->toBe('saved')
+        ->and(readMigrationManifest($root)['migrations'])->toBe([['from' => 0, 'to' => 1, 'class' => 'Synthetic\\RepairArrival']]);
+});
diff --git a/tests/Unit/Session/SavePointScriptSettingTest.php b/tests/Unit/Session/SavePointScriptSettingTest.php
new file mode 100644
index 00000000..9115b52a
--- /dev/null
+++ b/tests/Unit/Session/SavePointScriptSettingTest.php
@@ -0,0 +1,42 @@
+listDatabaseRecords('configuration')['records'], true);
+    $value = array_find($session->readDatabaseRecord('configuration', $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === 'value');
+
+    $outcome = ChangeBatch::apply($root, [['method' => 'database.apply', 'params' => ['category' => 'configuration',
+        'index' => $index, 'key' => $value['key'], 'value' => 'dresser-note']]]);
+
+    expect($value['kind'])->toBe('reference')
+        ->and(array_column($session->listReferences(null, 'common_events'), 'value'))->toContain('dresser-note')
+        ->and($outcome['status'])->toBe('saved')
+        ->and((require $root . '/config.php')['save']['point_script'] ?? null)->toBe('dresser-note');
+});
+
+it('reports a save point script the project does not have', function () {
+    $root = makeTemporaryProject();
+    $source = (string) file_get_contents($root . '/config.php');
+    file_put_contents($root . '/config.php', preg_replace('/return \[/', "return [\n  'save' => ['point_script' => 'missing-ledger'],", $source, 1));
+
+    $messages = array_map(static fn($issue): string => $issue->where . ' ' . $issue->message,
+        new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)));
+
+    expect(array_filter($messages, static fn(string $message): bool => str_contains($message, 'config.php: save.point_script')
+        && str_contains($message, '"missing-ledger"')))->not->toBe([]);
+});
diff --git a/tests/Unit/Session/SaveRelocationTest.php b/tests/Unit/Session/SaveRelocationTest.php
new file mode 100644
index 00000000..f32e9175
--- /dev/null
+++ b/tests/Unit/Session/SaveRelocationTest.php
@@ -0,0 +1,274 @@
+readMap('home');
+    $session->paint('home', $map['revision'], $map['baseLayer'], [[1, 2]], '#');
+
+    return [$root, $session];
+}
+
+function readSaveManifest(string $root): array
+{
+    return require $root . '/assets/Data/save-compatibility.php';
+}
+
+it('asks before saving a map that makes reachable floor solid, naming where those saves land', function () {
+    [$root, $session] = openWalledHomeSession();
+    $before = sourceHashTree($root);
+
+    $question = $session->saveMap('home');
+
+    expect($question)->toMatchArray(['status' => 'question', 'map' => 'home', 'contentVersion' => 1])
+        ->and($question['relocations'])->toBe([['map' => 'home', 'cells' => [[1, 2]], 'to' => [1, 1]]])
+        ->and($question['moves'])->toBe(['x 1, y 2 to x 1, y 1'])
+        ->and(array_column($question['answers'], 'key'))->toBe(['cancel', 'write'])
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->saveMap('home', 'cancel'))->toMatchArray(['status' => 'cancelled'])
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('saves the map and the relocation step together once confirmed, and undo removes the step', function () {
+    [$root, $session] = openWalledHomeSession();
+    $manifest = (string) file_get_contents($root . '/assets/Data/save-compatibility.php');
+    $question = $session->saveMap('home');
+
+    $saved = $session->saveMap('home', 'write', $question['confirm']);
+
+    expect($saved['relocated'])->toBe(['x 1, y 2 to x 1, y 1'])
+        ->and(readSaveManifest($root)['contentVersion'])->toBe(1)
+        ->and(readSaveManifest($root)['migrations'])->toBe([['from' => 0, 'to' => 1,
+            'relocations' => [['map' => 'home', 'cells' => [[1, 2]], 'to' => [1, 1]]]]])
+        ->and(Ichiloto\Engine\IO\SaveCompatibility\SaveCompatibilityManifest::fromProjectRoot($root)->createMigrationFrom(0))
+        ->toBeInstanceOf(Ichiloto\Engine\IO\SaveCompatibility\DeclaredPositionContentMigration::class)
+        ->and(describeSaveCompatibilityIssues($root))->toBe([])
+        // Saved: nothing is left to move.
+        ->and(SaveRelocationPlanner::plan(getPhysicalOccupancyMap($session), $root))->toBeNull();
+
+    $session->undo();
+    expect(file_get_contents($root . '/assets/Data/save-compatibility.php'))->toBe($manifest);
+});
+
+it('refuses a write the author was not shown', function () {
+    [$root, $session] = openWalledHomeSession();
+    $before = sourceHashTree($root);
+
+    expect(fn() => $session->saveMap('home', 'write', 'not-the-shown-review'))->toThrow(SessionRefusal::class, 'Review them again')
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('saves at once when nothing reachable becomes solid, or when the project keeps no save manifest', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('home');
+    // A floor cell becomes another kind of floor: nothing a save could stand on turns solid.
+    $session->paint('home', $map['revision'], $map['baseLayer'], [[2, 3]], ' ');
+    expect($session->saveMap('home'))->toHaveKey('saved');
+
+    [$root, $session] = openWalledHomeSession();
+    unlink($root . '/assets/Data/save-compatibility.php');
+    expect($session->saveMap('home'))->toHaveKey('saved');
+});
+
+it('moves no save off walkable grass or a save point, and moves saves off grass that becomes solid', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    $paint = static function (array $cells, string $glyph) use ($session): void {
+        $map = $session->readMap('home');
+        $session->paint('home', $map['revision'], $map['baseLayer'], $cells, $glyph);
+    };
+
+    // New grass and a new save point are walkable: nothing to move.
+    $paint([[1, 2]], 'g');
+    $paint([[1, 1]], 'P');
+    expect($session->saveMap('home'))->toHaveKey('saved');
+
+    // Saved grass and save points the map keeps are not new blockers either.
+    $paint([[2, 1]], ' ');
+    expect($session->saveMap('home'))->toHaveKey('saved');
+
+    // Grass that becomes solid does move the saves on it, past the save point to plain floor.
+    $paint([[1, 2]], '#');
+    expect($session->saveMap('home')['relocations'])->toBe([['map' => 'home', 'cells' => [[1, 2]], 'to' => [2, 1]]]);
+});
+
+it('moves saves off walkable floor no route reaches today, since an older layout may have reached it', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    $paint = static function (array $cells, string $glyph) use ($session): void {
+        $map = $session->readMap('home');
+        $session->paint('home', $map['revision'], $map['baseLayer'], $cells, $glyph);
+    };
+    // Walling in (4, 1) leaves it walkable on disk but cut off.
+    $paint([[3, 1], [4, 2]], '#');
+    $question = $session->saveMap('home');
+    $session->saveMap('home', 'write', $question['confirm']);
+
+    $paint([[4, 1]], '#');
+    $relocations = $session->saveMap('home')['relocations'];
+
+    expect(array_merge(...array_column($relocations, 'cells')))->toBe([[4, 1]])
+        ->and($relocations[0]['to'])->not->toBe([4, 1]);
+});
+
+it('moves saves off floor an NPC now stands on, and lands them on no other NPC', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    // The lamp moves from (1, 3) onto open floor at (3, 3); the cat still stands at (2, 3).
+    $session->moveNpc('home', $session->readMap('home')['revision'], 2, 3, 3);
+
+    expect($session->saveMap('home')['relocations'])->toBe([['map' => 'home', 'cells' => [[3, 3]], 'to' => [4, 2]]]);
+});
+
+it('leaves a map that would move saves out of Save All, for its own reviewed save', function () {
+    [$root, $session] = openWalledHomeSession();
+    $workspace = new ReflectionProperty(EditorSession::class, 'workspace')->getValue($session);
+    $result = WorkspaceSave::saveAll($workspace, new Ichiloto\Editor\Backup\BackupWriter(Ichiloto\Editor\Backup\BackupSettings::fromProject($root), $root));
+
+    expect($result->skippedRelocations)->toBe(['home'])
+        ->and(getPhysicalOccupancyMap($session)->isDirty())->toBeTrue()
+        ->and(readSaveManifest($root)['contentVersion'])->toBe(0);
+});
+
+it('reviews the relocation in the terminal editor before Ctrl+S writes both', function () {
+    $root = mapLineRemovalProject();
+    $editor = mapLineEditor($root);
+    getEditorProperty($editor, 'workspace')->getMapByIndex(0)->setTileSymbol(1, 2, '#');
+
+    pressKeys($editor, "\x13");
+    $labels = array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label');
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue()
+        ->and($labels)->toContain('Save and move saves', 'Move saves on x 1, y 2 to x 1, y 1')
+        ->and(readSaveManifest($root)['contentVersion'])->toBe(0);
+
+    pressKeys($editor, "\033[B", "\r");
+    expect(readSaveManifest($root)['contentVersion'])->toBe(1)
+        ->and(getEditorProperty($editor, 'workspace')->getMapByIndex(0)->isDirty())->toBeFalse();
+});
+
+it('writes the relocations of every map saved together as one manifest step', function () {
+    $root = mapLineRemovalProject();
+    // A door from home's floor into town makes the town's floor reachable too.
+    $home = $root . '/assets/Maps/home';
+    file_put_contents($home . '/home.event.php', Ichiloto\Engine\Field\MapGridSource::buildSource("      \n T  W \n      \n D  S \n      ", 'AUTHORED'));
+    file_put_contents($home . '/home.data.php', str_replace("'events' => [", "'events' => [\n    'T' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\TransferPlayerTrigger',"
+        . " 'data' => ['destinationMap' => 'town', 'spawnPoint' => ['x' => 2, 'y' => 1], 'spawnSprite' => ['North']]],", (string) file_get_contents($home . '/home.data.php')));
+    $workspace = Ichiloto\Editor\ProjectWorkspace::fromProject($root);
+    foreach ($workspace->maps as $map) {
+        if (in_array($map->mapId, ['home', 'town'], true)) {
+            // Home's wall leaves its door to town reachable, so both maps have floor to land saves on.
+            [$x, $y] = $map->mapId === 'home' ? [1, 2] : [3, 1];
+            $map->setLayerCell($map->getLayers()[0]['id'], $x, $y, '#');
+        }
+    }
+    $recorded = [];
+
+    Ichiloto\Editor\Storage\SaveSet::commit($root, WorkspaceSave::prepareAll($workspace, static function ($step) use (&$recorded): void { $recorded[] = $step; }));
+
+    $steps = readSaveManifest($root)['migrations'];
+    expect($steps)->toHaveCount(1)
+        ->and(array_values(array_unique(array_column($steps[0]['relocations'], 'map'))))->toBe(['home', 'town'])
+        ->and(readSaveManifest($root)['contentVersion'])->toBe(1)
+        ->and($recorded)->toHaveCount(1);
+});
+
+it('lands no save inside a bound event its own cell was not already in', function () {
+    $root = mapLineRemovalProject();
+    $home = $root . '/assets/Maps/home/home.data.php';
+    // An automatic event bound to a named area on the floor a relocation would otherwise choose.
+    file_put_contents($home, str_replace("'events' => [", "'areas' => [['id' => 'porch', 'area' => ['cells' => [[2, 4]]]]],\n  'events' => [\n    'Z' => ['class' => 'Ichiloto\\\\Engine\\\\Events\\\\Triggers\\\\ScriptEventTrigger',"
+        . " 'subject' => ['kind' => 'area', 'id' => 'porch'], 'data' => ['mode' => 'auto', 'reusable' => true, 'scriptId' => 'home-only']],",
+        (string) file_get_contents($home)));
+    $session = EditorSession::open($root);
+    $map = $session->readMap('home');
+    $session->paint('home', $map['revision'], $map['baseLayer'], [[2, 3]], '#');
+
+    $question = $session->saveMap('home');
+
+    expect($question['relocations'])->toHaveCount(1)
+        ->and($question['relocations'][0]['to'])->not->toBe([2, 4]);
+});
+
+it('moves saves off floor the change walls off, and lands none in that pocket', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    $map = $session->readMap('home');
+    // A wall at (3, 1) closes the north-west corner off behind the water and the bed: floor the player reaches
+    // today and no route reaches once saved, so its saves move too, and the wall's own saves land outside it.
+    $session->paint('home', $map['revision'], $map['baseLayer'], [[3, 1]], '#');
+
+    expect($session->saveMap('home')['relocations'])->toBe([
+        ['map' => 'home', 'cells' => [[1, 1], [1, 2]], 'to' => [1, 4]],
+        ['map' => 'home', 'cells' => [[2, 1], [3, 1]], 'to' => [4, 2]],
+    ]);
+});
+
+it('moves saves off floor an event moved onto, where loading would start it', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    // The event at (4, 1) moves one cell west onto open floor; the floor it leaves takes the saves.
+    $session->moveEvent('home', $session->readMap('home')['revision'], 'W', -1, 0);
+
+    expect($session->saveMap('home')['relocations'])->toBe([['map' => 'home', 'cells' => [[3, 1]], 'to' => [4, 1]]]);
+});
+
+it('moves no save off a map the player reaches nowhere, such as one just made and not yet joined to the world', function () {
+    $root = mapLineRemovalProject();
+    $session = EditorSession::open($root);
+    $session->createMap('Far field', null, 160, 120, 'far-field');
+    $map = $session->readMap('far-field');
+    // Its blank floor is walkable on disk; walling all of it in would otherwise plan a landing for every cell.
+    $cells = [];
+    for ($y = 0; $y < 120; $y++) {
+        for ($x = 0; $x < 160; $x++) {
+            $cells[] = [$x, $y];
+        }
+    }
+    $session->paint('far-field', $map['revision'], $map['baseLayer'], $cells, '#');
+    $manifest = (string) file_get_contents($root . '/assets/Data/save-compatibility.php');
+    $started = microtime(true);
+    $saved = $session->saveMap('far-field');
+
+    // Searching a landing for each of its cells took minutes on a map this size.
+    expect(microtime(true) - $started)->toBeLessThan(20.0)
+        ->and($saved)->toHaveKey('saved')
+        ->and((string) file_get_contents($root . '/assets/Data/save-compatibility.php'))->toBe($manifest);
+});
+
+it('moves no save off a map a change set made, though something already led to it and its blank files are on disk', function () {
+    $root = mapLineRemovalProject();
+    // The start already leads to the map the change set will make.
+    file_put_contents($root . '/assets/Data/system.php', " ['player' => "
+        . "['destinationMap' => 'cellar', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['South']]]];\n");
+    $manifest = (string) file_get_contents($root . '/assets/Data/save-compatibility.php');
+    $session = EditorSession::open($root);
+    $session->beginChanges();
+    $session->createMap('Cellar', null, 6, 4, 'cellar');
+    $map = $session->readMap('cellar');
+    $session->paint('cellar', $map['revision'], $map['baseLayer'], [[0, 0], [1, 0], [2, 0], [3, 0], [4, 0], [5, 0], [0, 1], [5, 1], [0, 2], [5, 2],
+        [0, 3], [1, 3], [2, 3], [3, 3], [4, 3], [5, 3]], '#');
+
+    // Whatever else the check finds of the unfinished map, it plans no save onto or off it.
+    $outcome = $session->commitChanges(checkOnly: true);
+    expect($outcome['relocated'])->toBe([])
+        ->and((string) ($outcome['reason'] ?? ''))->not->toContain('Saves')
+        ->and((string) file_get_contents($root . '/assets/Data/save-compatibility.php'))->toBe($manifest);
+});
diff --git a/tests/Unit/Session/SelectionSessionTest.php b/tests/Unit/Session/SelectionSessionTest.php
new file mode 100644
index 00000000..0ca38ad5
--- /dev/null
+++ b/tests/Unit/Session/SelectionSessionTest.php
@@ -0,0 +1,93 @@
+readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4');
+
+    return array_map(static fn(array $row): string => implode('', $row), $layer['rows']);
+}
+
+it('copies a block and pastes it elsewhere on its layer as one undo step', function () {
+    $session = selectionSession();
+
+    expect($session->copySelection('test-map', 'map:4', 1, 0, 2, 1))->toBe(['layer' => 'map:4', 'width' => 2, 'height' => 1]);
+    expect($session->pasteSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 1, 1))
+        ->toMatchArray(['status' => 'applied', 'changed' => 2])
+        ->and(readSelectionRows($session))->toBe([' /  ', ' /  ']);
+
+    $session->undo();
+    expect(readSelectionRows($session))->toBe([' /  ', ' xx ']);
+});
+
+it('cuts a block, leaving its cells empty, and puts it back where it is pasted', function () {
+    $session = selectionSession();
+
+    expect($session->cutSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 1, 1, 2, 1))
+        ->toMatchArray(['width' => 2, 'height' => 1, 'changed' => 2])
+        ->and(readSelectionRows($session))->toBe([' /  ', '    ']);
+    $session->pasteSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 2, 1);
+    expect(readSelectionRows($session))->toBe([' /  ', '  xx']);
+});
+
+it('asks which piece a pasted glyph is when several draw it', function () {
+    $session = selectionSession([
+        'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['61']]],
+        'barrel' => ['name' => 'Barrel', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['62']]],
+    ]);
+    $session->copySelection('test-map', 'map:4', 1, 1, 1, 1);
+
+    expect($session->pasteSelection('test-map', $session->readMap('test-map')['revision'], 'map:4', 0, 0))
+        ->toMatchArray(['status' => 'question', 'glyph' => 'x'])
+        ->and(readSelectionRows($session)[0])->toBe(' /  ');
+});
+
+it('refuses an empty clipboard, another layer\'s block, or a selection off the map, changing nothing', function () {
+    $session = selectionSession();
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->pasteSelection('test-map', $revision, 'map:4', 0, 0))->toThrow(SessionRefusal::class, 'clipboard is empty')
+        ->and(fn() => $session->copySelection('test-map', 'map:4', 3, 1, 2, 1))->toThrow(SessionRefusal::class, 'does not fit');
+    $session->copySelection('test-map', 'map:4', 0, 0, 1, 1);
+    $other = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] !== 'map:4' && ! $layer['event'])['id'];
+    expect(fn() => $session->pasteSelection('test-map', $revision, $other, 0, 0))->toThrow(SessionRefusal::class, 'before pasting')
+        ->and($session->readMap('test-map')['dirty'])->toBeFalse();
+});
+
+it('pastes a block copied on one map onto the layer of the same name on another, whatever its id there', function () {
+    $session = selectionSession();
+    $created = $session->createMap('Yard', 'home', 4, 2)['map'];
+    $session->createLayer($created, $session->readMap($created)['revision'], 'fixtures', false);
+    $session->createLayer($created, $session->readMap($created)['revision'], 'buildings', false);
+    $buildings = array_find($session->readMap($created)['layers'], static fn(array $layer): bool => $layer['name'] === 'buildings')['id'];
+    $fixtures = array_find($session->readMap($created)['layers'], static fn(array $layer): bool => $layer['name'] === 'fixtures')['id'];
+    // Its own map numbers the layer differently from the test map's buildings layer.
+    expect($buildings)->not->toBe('map:4');
+
+    $session->copySelection('test-map', 'map:4', 1, 0, 2, 2);
+    expect(fn() => $session->pasteSelection($created, $session->readMap($created)['revision'], $fixtures, 0, 0))
+        ->toThrow(SessionRefusal::class, 'from the Buildings layer');
+    expect($session->pasteSelection($created, $session->readMap($created)['revision'], $buildings, 2, 0))
+        ->toMatchArray(['status' => 'applied', 'changed' => 3]);
+    $rows = array_find($session->readMap($created)['layers'], static fn(array $layer): bool => $layer['id'] === $buildings)['rows'];
+    expect(array_map(static fn(array $row): string => implode('', $row), $rows))->toBe(['  / ', '  xx']);
+});
diff --git a/tests/Unit/Session/SignAuthoringTest.php b/tests/Unit/Session/SignAuthoringTest.php
new file mode 100644
index 00000000..3986eb06
--- /dev/null
+++ b/tests/Unit/Session/SignAuthoringTest.php
@@ -0,0 +1,218 @@
+ [\n"
+        . "  'lamp' => ['name' => 'Lamp', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Props/Lamp.png']],\n"
+        . "  'wood-sign' => ['name' => 'Wood Sign', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sign' => ['asset' => 'Graphics/Boards/Wood.png',"
+        . " 'fontRole' => 'sign', 'left' => 8, 'right' => 8, 'density' => 1, 'padding' => [4, 2, 4, 2], 'minimumWidth' => 24, 'maximumWidth' => 160]],\n"
+        . "]];\n");
+
+    return [$root, EditorSession::open($root)];
+}
+
+function findSignDatabaseRow(EditorSession $session, string $category, int $index, string $field): ?array
+{
+    return array_find($session->readDatabaseRecord($category, $index)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+function applySignDatabaseRow(EditorSession $session, string $category, int $index, string $field, string $value): array
+{
+    $row = findSignDatabaseRow($session, $category, $index, $field) ?? throw new RuntimeException('Missing row ' . $field);
+
+    return $session->applyDatabaseRecord($category, $index, $row['key'], $value);
+}
+
+function findSignPlacementRow(EditorSession $session, string $id, string $field): ?array
+{
+    return array_find($session->readWorldObject('test-map', $id)['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+it('authors a sign board resource with the Engine\'s board, cut, padding and width fields', function () {
+    [$root, $session] = createSignProject();
+    $index = array_search('Wood Sign', $session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records'], true);
+
+    expect(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation')['value'])->toBe(FieldResourceFields::SIGN)
+        ->and(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sign.fontRole'))->toMatchArray(['reference' => 'text_roles'])
+        ->and(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sprites2d.asset'))->toBeNull();
+    applySignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sign.padding', '6, 2, 6, 3');
+    $session->saveDatabase(FieldResourceFields::CATEGORY);
+
+    expect(FieldPresentationCatalog::load($root . '/assets')->getResource('wood-sign')->sign->padding)->toBe([6, 2, 6, 3]);
+});
+
+it('switches a resource between an image and a sign board, keeping the image chosen', function () {
+    [$root, $session] = createSignProject();
+    $index = array_search('Lamp', $session->listDatabaseRecords(FieldResourceFields::CATEGORY)['records'], true);
+    applySignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation', FieldResourceFields::SIGN);
+
+    expect(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'sign.asset')['value'])->toBe('Graphics/Props/Lamp.png')
+        ->and(findSignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation')['value'])->toBe(FieldResourceFields::SIGN);
+    applySignDatabaseRow($session, FieldResourceFields::CATEGORY, $index, 'presentation', FieldResourceFields::IMAGE);
+    $session->saveDatabase(FieldResourceFields::CATEGORY);
+
+    $lamp = FieldPresentationCatalog::load($root . '/assets')->getResource('lamp');
+    expect($lamp->sign)->toBeNull()->and($lamp->sprites)->not->toBeNull();
+});
+
+it('creates the text catalogue with its schema and a role set in the project\'s own font', function () {
+    [$root, $session] = createSignProject();
+    $session->createDatabaseRecord(TextRoleFields::CATEGORY);
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'id', 'sign');
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'fontSize', '14');
+    $session->saveDatabase(TextRoleFields::CATEGORY);
+
+    $role = ProjectTextCatalog::load($root . '/assets')->getRole('sign');
+    expect((require $root . '/assets/Data/Presentation/text.php')['schema'])->toBe(ProjectTextCatalog::SCHEMA)
+        ->and($role->fonts[0]->asset)->toBe('Fonts/Open.ttf')
+        ->and($role->fontSize)->toBe(14.0)
+        ->and(new ReferenceCatalog(ProjectWorkspace::fromProject($root))->valuesFor('text_roles'))->toBe(['sign'])
+        ->and(fn() => applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'id', 'Sign Font'))->toThrow(SessionRefusal::class);
+});
+
+it('gives a role an outline and shadow as plain data the Engine loads, and takes them away whole', function () {
+    [$root, $session] = createSignProject();
+    $session->createDatabaseRecord(TextRoleFields::CATEGORY);
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'id', 'sign');
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'effects', TextRoleFields::OUTLINE_AND_SHADOW);
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.shadow.opacity', '0.5');
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.shadow.color.kind', 'ansi16');
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.outline.width', '1');
+    $session->saveDatabase(TextRoleFields::CATEGORY);
+
+    $effects = ProjectTextCatalog::load($root . '/assets')->getRole('sign')->glyphEffects;
+    expect($effects->shadowOpacity)->toBe(0.5)
+        ->and($effects->shadowOffsetX)->toBe(1.0)
+        ->and($effects->outlineWidth)->toBe(1.0)
+        ->and($effects->shadowColor->toArray())->toBe(['kind' => 'ansi16', 'index' => 0])
+        ->and((string) file_get_contents($root . '/assets/Data/Presentation/text.php'))->not->toContain('new ');
+    applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'effects', TextRoleFields::NO_EFFECTS);
+    $session->saveDatabase(TextRoleFields::CATEGORY);
+
+    expect(ProjectTextCatalog::load($root . '/assets')->getRole('sign')->glyphEffects)->toBeNull()
+        ->and(findSignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'glyphEffects.shadow.opacity'))->toBeNull();
+});
+
+it('keeps a text file built from constructor calls read-only, refusing an edit before writing anything', function () {
+    [$root] = createSignProject();
+    $path = $root . '/assets/Data/Presentation/text.php';
+    file_put_contents($path, " 'ichiloto.text/1', 'roles' => ['sign' => ['fonts' => [['asset' => 'Fonts/Open.ttf']], 'fontSize' => 12,"
+        . " 'foreground' => ['kind' => 'rgb', 'r' => 1, 'g' => 2, 'b' => 3], 'glyphEffects' => new CanvasGlyphEffects(1,"
+        . " PresentationColor::rgb(0, 0, 0), 1, 1, 0, 1, PresentationColor::rgb(0, 0, 0))]]];\n");
+    $source = (string) file_get_contents($path);
+    $session = EditorSession::open($root);
+
+    // Colours are built through their factories, so a rewritten file calling their constructor could not load.
+    expect($session->readDatabaseRecord(TextRoleFields::CATEGORY, 0)['readOnly'])->toContain('PresentationColor')
+        ->and(fn() => applySignDatabaseRow($session, TextRoleFields::CATEGORY, 0, 'fontSize', '14'))->toThrow(SessionRefusal::class)
+        ->and((string) file_get_contents($path))->toBe($source)
+        ->and(ProjectTextCatalog::load($root . '/assets')->getRole('sign')->glyphEffects->shadowOffsetX)->toBe(1.0);
+});
+
+it('places a sign with the text it shows under a translation key of its own id', function () {
+    [$root, $session] = createSignProject();
+    $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'inn-sign', 1, 1, 'wood-sign', 'Inn');
+
+    expect($session->readMap('test-map')['worldObjects'][0])->toBe(['id' => 'inn-sign', 'resource' => 'wood-sign', 'anchor' => ['x' => 1, 'y' => 1],
+        'label' => ['message' => 'signs.inn-sign', 'default' => 'Inn']])
+        ->and(findSignPlacementRow($session, 'inn-sign', 'label.default')['value'])->toBe('Inn');
+    $row = findSignPlacementRow($session, 'inn-sign', 'label.default');
+    $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'inn-sign')['revision'], 'inn-sign', $row['key'], 'Weapons');
+    $session->saveMap('test-map');
+
+    $data = require $root . '/assets/Maps/test-map/test-map.data.php';
+    $placement = WorldObjectDefinition::readMap($data, loadLayeredMap($root)->getLayerSet(),
+        catalog: FieldPresentationCatalog::load($root . '/assets'))[0];
+    expect($placement->label->default)->toBe('Weapons');
+});
+
+it('refuses a sign without text, and text on what is not a sign, writing nothing', function () {
+    [, $session] = createSignProject();
+    $before = $session->readMap('test-map');
+
+    expect(fn() => $session->createWorldObject('test-map', $before['revision'], 'blank-sign', 1, 1, 'wood-sign', ' '))->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->createWorldObject('test-map', $before['revision'], 'lamp-1', 1, 1, 'lamp', 'Lamp'))->toThrow(SessionRefusal::class)
+        ->and($session->readMap('test-map'))->toBe($before);
+});
+
+it('gives a placement a label when its resource becomes a sign, and takes it away when it stops being one', function () {
+    [, $session] = createSignProject();
+    $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'lamp-1', 1, 1, 'lamp');
+    $apply = static function (string $field, string $value) use ($session): void {
+        $row = findSignPlacementRow($session, 'lamp-1', $field);
+        $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'lamp-1')['revision'], 'lamp-1', $row['key'], $value);
+    };
+
+    $apply('resource', 'wood-sign');
+    expect($session->readMap('test-map')['worldObjects'][0]['label'])->toBe(['message' => 'signs.lamp-1', 'default' => 'Wood Sign']);
+    $apply('resource', 'lamp');
+    expect($session->readMap('test-map')['worldObjects'][0])->not->toHaveKey('label');
+});
+
+it('turns an object created at a cell into a sign placement through its Field Resource row, and back', function () {
+    [$root, $session] = createSignProject();
+    $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], 'gate-sign', 1, 1);
+    $apply = static function (string $field, string $value) use ($session): void {
+        $row = findSignPlacementRow($session, 'gate-sign', $field) ?? throw new RuntimeException('Missing row ' . $field);
+        $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'gate-sign')['revision'], 'gate-sign', $row['key'], $value);
+    };
+
+    // A new object has no art yet, and still offers the resource it may place.
+    expect(findSignPlacementRow($session, 'gate-sign', 'resource'))->not->toBeNull();
+
+    $apply('resource', 'wood-sign');
+    $placed = $session->readMap('test-map')['worldObjects'][0];
+    expect($placed)->toMatchArray(['id' => 'gate-sign', 'resource' => 'wood-sign', 'anchor' => ['x' => 1, 'y' => 1],
+            'label' => ['message' => 'signs.gate-sign', 'default' => 'Wood Sign']])
+        ->and($placed)->not->toHaveKeys(['pivot', 'sprites2d']);
+
+    $apply('label.default', 'North Gate');
+    expect($session->readMap('test-map')['worldObjects'][0]['label']['default'])->toBe('North Gate');
+
+    // It saves as the same placement.
+    $session->saveMap('test-map');
+    $saved = (require $root . '/assets/Maps/test-map/test-map.data.php')['worldObjects'][0];
+    expect($saved)->toMatchArray(['id' => 'gate-sign', 'resource' => 'wood-sign',
+            'label' => ['message' => 'signs.gate-sign', 'default' => 'North Gate']])
+        ->and($saved)->not->toHaveKeys(['pivot', 'sprites2d']);
+
+    // Undo steps back through the text and the resource to the object as it was created.
+    $session->undo();
+    $session->undo();
+    expect($session->readMap('test-map')['worldObjects'][0])->toMatchArray(['id' => 'gate-sign', 'pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null])
+        ->and($session->readMap('test-map')['worldObjects'][0])->not->toHaveKeys(['resource', 'label']);
+
+    // Choosing no resource again makes it an object with no art, without its label.
+    $apply('resource', 'wood-sign');
+    $apply('resource', '');
+    expect($session->readMap('test-map')['worldObjects'][0])->toMatchArray(['pivot' => ['x' => .5, 'y' => 1.0], 'sprites2d' => null])
+        ->and($session->readMap('test-map')['worldObjects'][0])->not->toHaveKeys(['resource', 'label']);
+});
diff --git a/tests/Unit/Session/SkillRetirementTest.php b/tests/Unit/Session/SkillRetirementTest.php
new file mode 100644
index 00000000..265c9a44
--- /dev/null
+++ b/tests/Unit/Session/SkillRetirementTest.php
@@ -0,0 +1,109 @@
+stats->attack * 6')]),
+        new SpecialSkill('New Cut', 'Cuts well.', '', 4, 0, effects: [new HPDamageSkillEffect('$user->stats->attack * 2')]),
+        new SpecialSkill('Guard Up', 'Guards.', '', 3, 0),
+        new MagicSkill('Spark', 'Shocks.', '', 5, 0),
+    );
+    $actor = $root . '/assets/Data/Actors/Kaelion.php';
+    file_put_contents($actor, str_replace("    'images' => [", <<<'PHP'
+    // What Kaelion knows and can still learn.
+    'abilities' => [
+      'learned' => ['Old Cut', 'Guard Up'],
+      'learnables' => [
+        ['skill' => 'New Cut', 'requirement' => ['goldCost' => 10]],
+        ['skill' => 'Old Cut', 'requirement' => ['goldCost' => 20]],
+      ],
+    ],
+    'images' => [
+PHP, (string) file_get_contents($actor)));
+    $classes = $root . '/assets/Data/classes.php';
+    file_put_contents($classes, preg_replace("/'name' => 'Vanguard',\n/",
+        "'name' => 'Vanguard',\n    'skillsToLearn' => [\n      ['level' => 2, 'skill' => 'Old Cut'],\n    ],\n", (string) file_get_contents($classes), 1));
+
+    return $root;
+}
+
+it('keeps a skill something names from being deleted or renamed, saying what names it', function () {
+    $session = EditorSession::open(skillRetirementProject());
+    $nameRow = array_find($session->readDatabaseRecord('skills', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'name');
+
+    expect(fn() => $session->deleteDatabaseRecord('skills', 0))->toThrow(SessionRefusal::class, 'Actor Kaelion (abilities.learned.0)')
+        ->and(fn() => $session->applyDatabaseRecord('skills', 0, $nameRow['key'], 'Renamed Cut'))->toThrow(SessionRefusal::class, 'Class Vanguard')
+        ->and($session->deleteDatabaseRecord('skills', 3)['records'])->toBe(['Old Cut', 'New Cut', 'Guard Up']);
+});
+
+it('moves everything that names a skill onto its replacement, keeping comments, and lets saves read the replacement', function () {
+    $root = skillRetirementProject();
+    $session = EditorSession::open($root);
+
+    $question = $session->retireSkill(0, 'New Cut');
+    $written = $session->retireSkill(0, 'New Cut', 'write', $question['confirm']);
+    $actor = (string) file_get_contents($root . '/assets/Data/Actors/Kaelion.php');
+    $data = (require $root . '/assets/Data/Actors/Kaelion.php')['data'];
+    $classes = require $root . '/assets/Data/classes.php';
+    $manifest = require $root . '/assets/Data/save-compatibility.php';
+
+    expect($question['status'])->toBe('question')
+        ->and($written['changed'])->toBeTrue()
+        ->and($data['abilities']['learned'])->toBe(['New Cut', 'Guard Up'])
+        // The actor could already learn the replacement, so the old learnable goes.
+        ->and(array_column($data['abilities']['learnables'], 'skill'))->toBe(['New Cut'])
+        ->and($classes[0]['skillsToLearn'])->toBe([['level' => 2, 'skill' => 'New Cut']])
+        ->and($actor)->toContain('// What Kaelion knows and can still learn.')
+        ->and($manifest['aliases']['abilities'])->toBe([['from' => 'Old Cut', 'to' => 'New Cut']])
+        ->and(EditorSession::open($root)->deleteDatabaseRecord('skills', 0)['records'])->toBe(['New Cut', 'Guard Up', 'Spark']);
+});
+
+it('refuses moving a skill onto itself, an unknown skill, or across spells and abilities', function () {
+    $session = EditorSession::open(skillRetirementProject());
+
+    expect(fn() => $session->retireSkill(0, 'Old Cut'))->toThrow(SessionRefusal::class, 'its own place')
+        ->and(fn() => $session->retireSkill(0, 'Missing'))->toThrow(SessionRefusal::class, 'no skill named Missing')
+        ->and(fn() => $session->retireSkill(0, 'Spark'))->toThrow(SessionRefusal::class, 'both be spells or both abilities');
+});
+
+it('removes a skill retired without a replacement from everything that names it, and saves simply drop it', function () {
+    $root = skillRetirementProject();
+    $session = EditorSession::open($root);
+
+    $question = $session->retireSkill(0);
+    $session->retireSkill(0, null, 'write', $question['confirm']);
+    $data = (require $root . '/assets/Data/Actors/Kaelion.php')['data'];
+    $classes = require $root . '/assets/Data/classes.php';
+
+    expect($question['question'])->toContain('Remove Old Cut from everything that names it')
+        ->and($data['abilities']['learned'])->toBe(['Guard Up'])
+        ->and(array_column($data['abilities']['learnables'], 'skill'))->toBe(['New Cut'])
+        ->and($classes[0]['skillsToLearn'])->toBe([])
+        ->and((require $root . '/assets/Data/save-compatibility.php')['aliases'])->toBe([])
+        ->and(EditorSession::open($root)->deleteDatabaseRecord('skills', 0)['records'])->toBe(['New Cut', 'Guard Up', 'Spark']);
+});
+
+it('has nothing to write when nothing names a skill and saves already follow it', function () {
+    $root = skillRetirementProject();
+    $session = EditorSession::open($root);
+    $question = $session->retireSkill(0, 'New Cut');
+    $session->retireSkill(0, 'New Cut', 'write', $question['confirm']);
+
+    expect(EditorSession::open($root)->retireSkill(0, 'New Cut'))->toBe(['changed' => false, 'records' => ['Old Cut', 'New Cut', 'Guard Up', 'Spark']])
+        ->and(EditorSession::open($root)->retireSkill(3))->toBe(['changed' => false, 'records' => ['Old Cut', 'New Cut', 'Guard Up', 'Spark']]);
+});
diff --git a/tests/Unit/Session/SkitBackdropAuthoringTest.php b/tests/Unit/Session/SkitBackdropAuthoringTest.php
new file mode 100644
index 00000000..a191e598
--- /dev/null
+++ b/tests/Unit/Session/SkitBackdropAuthoringTest.php
@@ -0,0 +1,185 @@
+ '',", "'region' => 'Synthetic Vale',", (string) file_get_contents($map)));
+    $skit = $root . '/assets/Data/Skits/breakfast-banter.php';
+    file_put_contents($skit, str_replace("'where' => 'happyville/town-center',", "'where' => 'test-map',", (string) file_get_contents($skit)));
+
+    return $root;
+}
+
+function findSkitBackdropMapRow(EditorSession $session): array
+{
+    return findLandmarkRow($session, ['target' => MapInspector::SKIT_BACKDROP_TARGET]);
+}
+
+function findSkitBackdropRecordRow(EditorSession $session, string $category, int $index, string $field): array
+{
+    return array_find($session->readDatabaseRecord($category, $index)['rows'],
+        static fn(array $row): bool => ($row['key']['field'] ?? null) === $field)
+        ?? throw new RuntimeException("{$category} {$index} has no {$field} row");
+}
+
+/** @return list The validation messages about skit backdrops. */
+function findSkitBackdropIssues(EditorSession $session): array
+{
+    return array_values(array_map(static fn(array $issue): string => $issue['message'], array_filter($session->validateProject()['issues'],
+        static fn(array $issue): bool => str_contains($issue['message'], 'backdrop') || str_contains($issue['message'], 'No map is in region'))));
+}
+
+it('chooses a map\'s skit backdrop with the shared image picker, refusing a file the project lacks, as one undo step', function () {
+    $root = createSkitBackdropProject();
+    $session = EditorSession::open($root);
+    $row = findSkitBackdropMapRow($session);
+    expect($row['label'])->toBe('Skit Backdrop')
+        ->and($row['value'])->toBe('None: skits here show no backdrop')
+        ->and($row['reference'])->toBe('png_assets')
+        // The GUI editor shows and picks the value as a picture under assets, from this row alone.
+        ->and($row['media'])->toBe(['kind' => 'image', 'root' => 'assets'])
+        ->and($row['noneLabel'])->toBe(MapInspector::SKIT_BACKDROP_NONE);
+
+    expect(fn() => applyLandmarkRow($session, ['target' => MapInspector::SKIT_BACKDROP_TARGET], 'Graphics/Skits/Missing.png'))
+        ->toThrow(SessionRefusal::class, 'is not a PNG in the project\'s assets');
+    applyLandmarkRow($session, ['target' => MapInspector::SKIT_BACKDROP_TARGET], 'Graphics/Skits/Square.png');
+    expect(getSessionMap($session)->getMapDataField([SkitBackdrop::MAP_KEY]))->toBe('Graphics/Skits/Square.png')
+        ->and(findSkitBackdropMapRow($session)['value'])->toBe('Graphics/Skits/Square.png');
+    $session->undo();
+    expect(getSessionMap($session)->hasMapDataField([SkitBackdrop::MAP_KEY]))->toBeFalse();
+    $session->redo();
+    $session->saveMap('test-map');
+    $saved = require $root . '/assets/Maps/test-map/test-map.data.php';
+    expect($saved[SkitBackdrop::MAP_KEY])->toBe('Graphics/Skits/Square.png')
+        ->and($saved['description'])->toBe('A tiny fixture map.');
+
+    // Replacing the file keeps the binding: the path is the identity, never the bytes.
+    writeTilesetTestPng($root . '/assets/Graphics/Skits/Square.png', 64, 40);
+    expect(findSkitBackdropMapRow(EditorSession::open($root))['value'])->toBe('Graphics/Skits/Square.png');
+
+    applyLandmarkRow($session, ['target' => MapInspector::SKIT_BACKDROP_TARGET], '');
+    expect(getSessionMap($session)->hasMapDataField([SkitBackdrop::MAP_KEY]))->toBeFalse();
+});
+
+it('gives a region the backdrop its maps show when they choose none, on the Regions page', function () {
+    $root = createSkitBackdropProject();
+    $session = EditorSession::open($root);
+    $index = $session->createDatabaseRecord(RegionFields::CATEGORY)['index'];
+    $region = findSkitBackdropRecordRow($session, RegionFields::CATEGORY, $index, 'id');
+    expect($region['reference'])->toBe('map_regions');
+    $session->applyDatabaseRecord(RegionFields::CATEGORY, $index, $region['key'], 'Synthetic Vale');
+    $backdrop = findSkitBackdropRecordRow($session, RegionFields::CATEGORY, $index, SkitBackdrop::MAP_KEY);
+    expect($backdrop['reference'])->toBe('png_assets')->and($backdrop['media'])->toBe(['kind' => 'image', 'root' => 'assets'])
+        ->and($backdrop['value'])->toBe(RegionFields::BACKDROP_NONE);
+    $session->applyDatabaseRecord(RegionFields::CATEGORY, $index, $backdrop['key'], 'Graphics/Skits/Vale.png');
+
+    // Unsaved, the map already says what its skits show.
+    expect(findSkitBackdropMapRow($session)['value'])->toBe('None: region Synthetic Vale\'s, Graphics/Skits/Vale.png');
+    $session->saveDatabase(RegionFields::CATEGORY);
+    $regions = RegionCatalog::load($root . '/assets');
+    expect($regions->getNames())->toBe(['Synthetic Vale'])->and($regions->getSkitBackdrop('Synthetic Vale'))->toBe('Graphics/Skits/Vale.png');
+
+    // A map's own backdrop wins over its region's.
+    applyLandmarkRow($session, ['target' => MapInspector::SKIT_BACKDROP_TARGET], 'Graphics/Skits/Square.png');
+    expect(findSkitBackdropMapRow($session)['value'])->toBe('Graphics/Skits/Square.png');
+});
+
+it('gives a skit its own backdrop only as an optional override in Database > Skits', function () {
+    $root = createSkitBackdropProject();
+    $session = EditorSession::open($root);
+    $row = findSkitBackdropRecordRow($session, 'skits', 0, SkitBackdrop::SKIT_KEY);
+    expect($row['label'])->toBe('Backdrop')->and($row['reference'])->toBe('png_assets')
+        ->and($row['media'])->toBe(['kind' => 'image', 'root' => 'assets'])
+        ->and($row['value'])->toBe(RecordSchemaCatalog::SKIT_BACKDROP_NONE);
+    $session->applyDatabaseRecord('skits', 0, $row['key'], 'Graphics/Skits/Square.png');
+    $session->saveDatabase('skits');
+    expect((require $root . '/assets/Data/Skits/breakfast-banter.php')[SkitBackdrop::SKIT_KEY])->toBe('Graphics/Skits/Square.png');
+
+    $session->applyDatabaseRecord('skits', 0, findSkitBackdropRecordRow($session, 'skits', 0, SkitBackdrop::SKIT_KEY)['key'], '');
+    $session->saveDatabase('skits');
+    expect(require $root . '/assets/Data/Skits/breakfast-banter.php')->not->toHaveKey(SkitBackdrop::SKIT_KEY);
+});
+
+it('warns where a skit would show no backdrop and about backdrops and regions the game cannot use', function () {
+    $root = createSkitBackdropProject();
+    $session = EditorSession::open($root);
+    expect(findSkitBackdropIssues($session))->toBe(['It plays on map test-map, which shows it no backdrop.']);
+
+    applyLandmarkRow($session, ['target' => MapInspector::SKIT_BACKDROP_TARGET], 'Graphics/Skits/Square.png');
+    expect(findSkitBackdropIssues($session))->toBe([]);
+
+    // A file removed after it was chosen, and a region record no map is in.
+    unlink($root . '/assets/Graphics/Skits/Square.png');
+    $index = $session->createDatabaseRecord(RegionFields::CATEGORY)['index'];
+    $session->applyDatabaseRecord(RegionFields::CATEGORY, $index, findSkitBackdropRecordRow($session, RegionFields::CATEGORY, $index, 'id')['key'], 'Old Region');
+    $issues = findSkitBackdropIssues($session);
+    sort($issues);
+    expect($issues)->toHaveCount(2)
+        ->and($issues[0])->toBe('No map is in region Old Region.')
+        ->and($issues[1])->toContain('Skits here show no backdrop', 'Graphics/Skits/Square.png cannot be drawn');
+});
+
+it('refuses the retired per-skit backgrounds of the dialogue catalogue, naming the upgrade', function () {
+    expect(DialogueCatalogFile::checkCatalog(['schema' => DialogueCatalogFile::SCHEMA, 'skits' => ['a' => ['background' => 'a.png']]], '/nowhere'))
+        ->toContain('Run `ichiloto upgrade`');
+});
+
+// -- Terminal editor ---------------------------------------------------------
+
+it('picks a map\'s skit backdrop by path in the Terminal inspector and clears it with None', function () {
+    $root = createSkitBackdropProject();
+    $editor = metadataEditor($root);
+    expect(inspectorValueOf($editor, 'Skit Backdrop'))->toBe('None: skits here show no backdrop');
+
+    selectInspectorField($editor, 'Skit Backdrop');
+    callEditorMethod($editor, 'activateInspectorField');
+    $values = array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'value');
+    expect($values[0])->toBe('')->and($values)->toContain('Graphics/Skits/Square.png', 'Graphics/Skits/Vale.png');
+    callEditorMethod($editor, 'closeEventOptionDialog', 'cancelled');
+
+    pickInspectorReference($editor, 'Skit Backdrop', 'Graphics/Skits/Square.png');
+    expect(mapDataOf($editor)[SkitBackdrop::MAP_KEY])->toBe('Graphics/Skits/Square.png')
+        ->and(inspectorValueOf($editor, 'Skit Backdrop'))->toBe('Graphics/Skits/Square.png');
+    callEditorMethod($editor, 'getSelectedMap')->save();
+    expect((require $root . '/assets/Maps/test-map/test-map.data.php')[SkitBackdrop::MAP_KEY])->toBe('Graphics/Skits/Square.png');
+
+    pickInspectorReference($editor, 'Skit Backdrop', '');
+    expect(mapDataOf($editor))->not->toHaveKey(SkitBackdrop::MAP_KEY);
+});
+
+it('opens the shared image picker for a skit\'s Backdrop and a region\'s Skit Backdrop in the Terminal database', function (string $category, string $field) {
+    $root = createSkitBackdropProject();
+    if ($category === RegionFields::CATEGORY) {
+        file_put_contents($root . '/assets/' . RegionCatalog::FILE, ' RegionCatalog::SCHEMA,
+            'regions' => ['Synthetic Vale' => []]], true) . ';');
+    }
+    $editor = deletionEditor($root);
+    openDatabaseCategory($editor, $category);
+    $fields = callEditorMethod($editor, 'getDatabaseSettingsFields');
+    $row = $fields[array_search($field, array_column($fields, 'field'), true)];
+    expect($row['reference'])->toBe('png_assets')
+        ->and(callEditorMethod($editor, 'openReferencePicker', $row))->toBeTrue()
+        ->and(getEditorProperty($editor, 'referencePicker')->isOpen())->toBeTrue();
+})->with([
+    'skit override' => ['skits', SkitBackdrop::SKIT_KEY],
+    'region backdrop' => [RegionFields::CATEGORY, SkitBackdrop::MAP_KEY],
+]);
diff --git a/tests/Unit/Session/StackedPieceSessionTest.php b/tests/Unit/Session/StackedPieceSessionTest.php
new file mode 100644
index 00000000..e537b631
--- /dev/null
+++ b/tests/Unit/Session/StackedPieceSessionTest.php
@@ -0,0 +1,232 @@
+ ['layers' => ['relief-a', 'relief-b']]];
+    $data['pieces'] = [
+        // A 2 x 2 crag and a one-cell rock, decoration only.
+        'crag' => ['name' => 'Crag', 'layer' => 'terrain', 'glyphs' => ['  ', '  '], 'tiles' => ['relief-a' => ['1 2', '3 4']]],
+        'rock' => ['name' => 'Rock', 'layer' => 'terrain', 'glyphs' => [' '], 'tiles' => ['relief-a' => ['9']]],
+    ];
+    file_put_contents($path, ">> The map's stack layers, by row. */
+function readStackLayers(EditorSession $session): array
+{
+    $layers = [];
+    foreach ($session->readTiles('test-map')['layers'] as $layer) {
+        if (str_starts_with($layer['name'], 'relief-')) {
+            $layers[$layer['name']] = $layer['rows'];
+        }
+    }
+
+    return $layers;
+}
+
+function placeStackedPiece(EditorSession $session, string $piece, int $x, int $y): array
+{
+    return $session->placePiece('test-map', $session->readMap('test-map')['revision'], $piece, [$x, $y], [$x, $y]);
+}
+
+it('lays overlapping pieces in depth order whichever is placed first, creating the stack\'s layers in order', function (array $order) {
+    $session = stackedPieceSession();
+    $terrain = static fn(): array => array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['name'] === 'terrain')['rows'];
+    $glyphs = $terrain();
+    foreach ($order as [$piece, $x, $y]) {
+        expect(placeStackedPiece($session, $piece, $x, $y))->toMatchArray(['status' => 'applied', 'changed' => true]);
+    }
+
+    // The crag at (1, 0) stands on row 1; the rock at (1, 1) on row 1 too, centred further west, so it draws behind.
+    // The rock at (2, 0) stands on row 0, north of the crag, and draws behind it as well.
+    expect(readStackLayers($session))->toBe([
+        'relief-a' => [[0, 1, 9, 0], [0, 9, 4, 0]],
+        'relief-b' => [[0, 0, 2, 0], [0, 3, 0, 0]],
+    ])
+        ->and(array_slice(array_column($session->readTiles('test-map')['layers'], 'name'), -2))->toBe(['relief-a', 'relief-b'])
+        // Decoration only: no glyph changes.
+        ->and($terrain())->toBe($glyphs);
+})->with([
+    'crag first' => [[['crag', 1, 0], ['rock', 1, 1], ['rock', 2, 0]]],
+    'rocks first' => [[['rock', 2, 0], ['rock', 1, 1], ['crag', 1, 0]]],
+]);
+
+it('lays copies at listed origins in one request as one undo step, as single placements would, and refuses one off the map', function () {
+    $session = stackedPieceSession();
+    $single = stackedPieceSession();
+    placeStackedPiece($single, 'rock', 2, 0);
+    placeStackedPiece($single, 'rock', 1, 1);
+
+    expect($session->placePieceCopies('test-map', $session->readMap('test-map')['revision'], 'rock', [[2, 0], [1, 1]]))
+        ->toMatchArray(['status' => 'applied', 'changed' => true, 'copies' => 2])
+        ->and(readStackLayers($session))->toBe(readStackLayers($single));
+    $session->undo();
+    expect(array_filter(readStackLayers($session), static fn(array $rows): bool => array_sum(array_merge(...$rows)) > 0))->toBe([]);
+
+    expect(fn() => $session->placePieceCopies('test-map', $session->readMap('test-map')['revision'], 'crag', [[0, 0], [3, 1]]))
+        ->toThrow(SessionRefusal::class, 'does not fit at (3, 1)');
+});
+
+it('lays a piece again on the same cells without doubling it, and undoes a placement as one step', function () {
+    $session = stackedPieceSession();
+    placeStackedPiece($session, 'crag', 0, 0);
+    $once = readStackLayers($session);
+    placeStackedPiece($session, 'crag', 0, 0);
+    expect(readStackLayers($session))->toBe($once);
+
+    // The rock at (1, 1) is centred further east than the crag on the same row, so it draws in front.
+    placeStackedPiece($session, 'rock', 1, 1);
+    expect(readStackLayers($session))->toBe(['relief-a' => [[1, 2, 0, 0], [3, 4, 0, 0]], 'relief-b' => [[0, 0, 0, 0], [0, 9, 0, 0]]]);
+    $session->undo();
+    expect(readStackLayers($session))->toBe($once);
+});
+
+it('refuses a cell that would hold more tiles than the stack has layers, changing nothing', function () {
+    $session = stackedPieceSession();
+    placeStackedPiece($session, 'crag', 0, 0);
+    placeStackedPiece($session, 'crag', 1, 0);
+    $before = readStackLayers($session);
+
+    expect(fn() => placeStackedPiece($session, 'rock', 1, 1))->toThrow(SessionRefusal::class, 'which has 2 layers')
+        ->and(readStackLayers($session))->toBe($before);
+});
+
+/**
+ * The stacked crag and rock on the terrain-kind test map ({@see terrainKindProject()}), converted to its meadow kind,
+ * so painting the path kind clears the stack the terrain owns.
+ */
+function stackedKindSession(): EditorSession
+{
+    $root = terrainKindProject();
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $data = require $path;
+    $data['stacks'] = ['relief' => ['layers' => ['relief-a', 'relief-b']]];
+    $data['pieces'] = [
+        'crag' => ['name' => 'Crag', 'layer' => 'terrain', 'glyphs' => ['  ', '  '], 'tiles' => ['relief-a' => ['1 2', '3 4']]],
+        'rock' => ['name' => 'Rock', 'layer' => 'terrain', 'glyphs' => [' '], 'tiles' => ['relief-a' => ['9']]],
+    ];
+    file_put_contents($path, "convertToKinds('test-map', $session->readMap('test-map')['revision'], ['.' => 'meadow']);
+    placeStackedPiece($session, 'crag', 1, 0);
+    placeStackedPiece($session, 'rock', 3, 0);
+
+    return $session;
+}
+
+function paintStackedKind(EditorSession $session, array $from, array $to): void
+{
+    $session->paintKind('test-map', $session->readMap('test-map')['revision'], 'path', $from, $to);
+}
+
+it('keeps a stacked object whole when a kind is painted on only some of its cells, so painting beside it never crops it', function () {
+    $session = stackedKindSession();
+    expect(readStackLayers($session))->toBe(['relief-a' => [[0, 1, 2, 9], [0, 3, 4, 0]]]);
+
+    // The path takes the crag's lower-left cell and the whole rock: the crag still shows on three other cells.
+    paintStackedKind($session, [1, 1], [1, 1]);
+    paintStackedKind($session, [3, 0], [3, 0]);
+
+    expect(readStackLayers($session))->toBe(['relief-a' => [[0, 1, 2, 0], [0, 3, 4, 0]]]);
+});
+
+it('clears a stacked object, every tile of it, when one paint covers all of its cells', function () {
+    $session = stackedKindSession();
+
+    paintStackedKind($session, [1, 0], [2, 1]);
+
+    expect(readStackLayers($session))->toBe(['relief-a' => [[0, 0, 0, 9], [0, 0, 0, 0]]]);
+    $session->undo();
+    expect(readStackLayers($session))->toBe(['relief-a' => [[0, 1, 2, 9], [0, 3, 4, 0]]]);
+});
+
+/**
+ * The stacked crag and rock of {@see stackedKindSession()} beside a mound: a stacked obstacle whose middle cell blocks
+ * the ground, its footprint glyph on the buildings layer above the terrain kinds.
+ */
+function stackedObstacleSession(): EditorSession
+{
+    $root = terrainKindProject();
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $data = require $path;
+    $data['stacks'] = ['relief' => ['layers' => ['relief-a', 'relief-b']]];
+    $data['pieces'] = [
+        'crag' => ['name' => 'Crag', 'layer' => 'terrain', 'glyphs' => ['  ', '  '], 'tiles' => ['relief-a' => ['1 2', '3 4']]],
+        'rock' => ['name' => 'Rock', 'layer' => 'terrain', 'glyphs' => [' '], 'tiles' => ['relief-a' => ['9']]],
+        'mound' => ['name' => 'Mound', 'layer' => 'buildings', 'glyphs' => [' n '], 'tiles' => ['relief-a' => ['30 31 32']],
+            'occupancy' => [[null, \Ichiloto\Engine\Events\Enumerations\CollisionType::SOLID, null]]],
+    ];
+    file_put_contents($path, "convertToKinds('test-map', $session->readMap('test-map')['revision'], ['.' => 'meadow']);
+
+    return $session;
+}
+
+/** @return list The buildings layer's glyphs, a row at a time. */
+function readStackedBuildings(EditorSession $session): array
+{
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['name'] === 'buildings');
+
+    return array_map(static fn(array|string $row): string => is_array($row) ? implode('', $row) : $row, $layer['rows']);
+}
+
+it('stamps a stacked obstacle\'s footprint glyph above the terrain, and painting a kind over it takes the glyph with the object', function () {
+    $session = stackedObstacleSession();
+    placeStackedPiece($session, 'mound', 0, 1);
+
+    expect(readStackLayers($session))->toBe(['relief-a' => [[0, 0, 0, 0], [30, 31, 32, 0]]])
+        ->and(readStackedBuildings($session)[1][1])->toBe('n');
+
+    // Painting the path beside the mound leaves it whole; painting all of its cells clears it and its footprint.
+    paintStackedKind($session, [0, 1], [1, 1]);
+    expect(readStackLayers($session)['relief-a'][1])->toBe([30, 31, 32, 0])
+        ->and(readStackedBuildings($session)[1][1])->toBe('n');
+    paintStackedKind($session, [0, 1], [2, 1]);
+    expect(readStackLayers($session)['relief-a'][1])->toBe([0, 0, 0, 0])
+        ->and(readStackedBuildings($session)[1][1])->toBe(' ');
+    $session->undo();
+    expect(readStackLayers($session)['relief-a'][1])->toBe([30, 31, 32, 0])
+        ->and(readStackedBuildings($session)[1][1])->toBe('n');
+});
+
+it('moves each stacked object with the layer it belongs to when a selection is cut and pasted', function () {
+    $session = stackedObstacleSession();
+    placeStackedPiece($session, 'rock', 3, 0);
+    placeStackedPiece($session, 'mound', 0, 1);
+    $layerId = static fn(string $name): string => array_find($session->readMap('test-map')['layers'],
+        static fn(array $layer): bool => $layer['name'] === $name)['id'];
+
+    // Cutting the terrain over the whole map lifts the rock, a terrain object, and leaves the mound, a buildings object.
+    $session->cutSelection('test-map', $session->readMap('test-map')['revision'], $layerId('terrain'), 0, 0, 4, 2);
+    expect(readStackLayers($session)['relief-a'])->toBe([[0, 0, 0, 0], [30, 31, 32, 0]]);
+    $session->pasteSelection('test-map', $session->readMap('test-map')['revision'], $layerId('terrain'), 0, 0);
+    expect(readStackLayers($session)['relief-a'])->toBe([[0, 0, 0, 9], [30, 31, 32, 0]]);
+
+    // Cutting the buildings row lifts the mound's tiles with its glyph, and pasting it a row up lays them there.
+    $session->cutSelection('test-map', $session->readMap('test-map')['revision'], $layerId('buildings'), 0, 1, 3, 1);
+    expect(readStackLayers($session)['relief-a'])->toBe([[0, 0, 0, 9], [0, 0, 0, 0]])
+        ->and(readStackedBuildings($session)[1][1])->toBe(' ');
+    $session->pasteSelection('test-map', $session->readMap('test-map')['revision'], $layerId('buildings'), 0, 0);
+    expect(readStackLayers($session)['relief-a'])->toBe([[30, 31, 32, 9], [0, 0, 0, 0]])
+        ->and(readStackedBuildings($session)[0][1])->toBe('n');
+});
diff --git a/tests/Unit/Session/SummonBattlePreviewSessionTest.php b/tests/Unit/Session/SummonBattlePreviewSessionTest.php
new file mode 100644
index 00000000..93e6dceb
--- /dev/null
+++ b/tests/Unit/Session/SummonBattlePreviewSessionTest.php
@@ -0,0 +1,118 @@
+|null The summon's art as the frame's canvas draws it. */
+function findSummonArt(array $frame): ?array
+{
+    return array_find($frame['canvas']['images'] ?? [], static fn(array $image): bool => $image['asset'] === 'Graphics/Summons/Wisp.png');
+}
+
+it('previews a summon cast by the battle test party at its troop, for both renderers, seeking freely', function () {
+    $session = EditorSession::open(summonBattlePreviewProject());
+    $first = $session->readSummonBattlePreview(0, 0);
+    $target = $first['phases']['target']['start'] + 1;
+    $atTarget = $session->readSummonBattlePreview(0, $target);
+
+    expect($first)->toMatchArray(['caster' => 'Kaelion', 'troop' => 'Pair', 'fps' => 120])
+        ->and($first['targets'])->not->toBe([])
+        ->and($first['totalFrames'])->toBeGreaterThan($target)
+        ->and($atTarget['phase'])->toBe('target')
+        ->and(findSummonArt($atTarget))->not->toBeNull()
+        ->and($atTarget['terminalLines'])->not->toBe([])
+        ->and($atTarget['diagnostics'])->toBe([])
+        // A seek past the end holds the last frame; seeking back draws the same frame again.
+        ->and($session->readSummonBattlePreview(0, PHP_INT_MAX)['frame'])->toBe($first['totalFrames'] - 1)
+        ->and($session->readSummonBattlePreview(0, $target))->toBe($atTarget)
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
+
+it('paces the previewed summon as the battle paces it: its move read for the summon announcement time, the rest as an attack', function () {
+    $root = summonBattlePreviewProject();
+    $phases = EditorSession::open($root)->readSummonBattlePreview(0, 0)['phases'];
+    $engine = new BattlePacing(BattlePace::SLOW, BattlePace::SLOW);
+    $frames = static fn(float $seconds): int => max(1, (int) ceil($seconds * 120));
+    $attack = $engine->getTurnTimings(new AttackAction('Attack'));
+
+    expect($phases['announce']['length'])->toBe($frames($engine->getAnnouncementSeconds(BattleActionCategory::SUMMON)))
+        ->and($phases['advance']['length'])->toBe($frames($attack->stepForward))
+        ->and($phases['source']['length'])->toBe($frames($attack->actionAnimation))
+        ->and($phases['reaction']['length'])->toBe($frames($attack->statChanges))
+        ->and($phases['return']['length'])->toBe($frames($attack->stepBack))
+        ->and($phases['finish']['length'])->toBe($frames($attack->turnOver));
+
+    // The reading time the project authors in Configuration is the one the preview reads.
+    $workspace = ProjectWorkspace::fromProject($root);
+    $settings = $workspace->getRecordDatabase('configuration');
+    $settings->setField(array_search(BattleActionCategory::SUMMON->getAnnouncementConfigPath(BattlePace::SLOW),
+        $settings->getEntryLabels(), true), 'value', '2.5');
+    $workspace->config->save();
+    $authored = EditorSession::open($root)->readSummonBattlePreview(0, 0)['phases'];
+
+    expect($authored['announce']['length'])->toBe(300)
+        ->and($authored['advance'])->toBe($phases['advance'])
+        ->and($authored['reaction']['length'])->toBe($phases['reaction']['length']);
+});
+
+it('previews the summon as it stands, unsaved edits included', function () {
+    $session = EditorSession::open(summonBattlePreviewProject());
+    $target = $session->readSummonBattlePreview(0, 0)['phases']['target']['start'] + 1;
+    $resting = findSummonArt($session->readSummonBattlePreview(0, $target, reducedMotion: true));
+    $rest = array_find($session->readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === 'Rest Frame');
+
+    $session->applyDatabaseRecord('cutscenes/summon', 0, $rest['key'], '0');
+
+    expect($resting['sourceRect']['x'])->toBe(8)
+        ->and(findSummonArt($session->readSummonBattlePreview(0, $target, reducedMotion: true))['sourceRect']['x'])->toBe(0);
+});
+
+it('refuses a summon no one in the battle test party may call, naming why', function () {
+    $session = EditorSession::open(summonBattlePreviewProject(['mode' => 'characters', 'characters' => ['Liora'], 'tenancy' => 'exclusive']));
+
+    expect(fn() => $session->readSummonBattlePreview(0, 0))->toThrow(SessionRefusal::class, 'No one in the battle test party may call Lantern Wisp')
+        ->and(fn() => $session->readSummonBattlePreview(4, 0))->toThrow(SessionRefusal::class);
+});
+
+it('previews the summon in the terminal arena alone, reading no graphical image', function () {
+    $root = summonBattlePreviewProject();
+    // Every graphical image gone: the terminal arena never needs one.
+    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/assets/Graphics', FilesystemIterator::SKIP_DOTS)) as $file) {
+        if (str_ends_with($file->getFilename(), '.png')) {
+            unlink($file->getPathname());
+        }
+    }
+    $session = EditorSession::open($root);
+    $first = $session->readSummonBattlePreview(0, 0, presentation: EffectPresentation::TERMINAL);
+    $atTarget = $session->readSummonBattlePreview(0, $first['phases']['target']['start'] + 1, presentation: EffectPresentation::TERMINAL);
+
+    expect($first['caster'])->toBe('Kaelion')
+        ->and($atTarget['phase'])->toBe('target')
+        ->and($atTarget['canvas'])->toBeNull()
+        ->and($atTarget['terminalLines'])->not->toBe([])
+        ->and(fn() => $session->readSummonBattlePreview(0, 0))->toThrow(SessionRefusal::class);
+});
+
+it('draws a frame of the summon\'s own timeline where the battle plays it, in either presentation', function () {
+    $session = EditorSession::open(summonBattlePreviewProject());
+    foreach ([EffectPresentation::GRAPHICAL, EffectPresentation::TERMINAL] as $presentation) {
+        $twelfth = $session->readSummonBattlePreview(0, 0, presentation: $presentation, authoredFrame: 12);
+        $first = $session->readSummonBattlePreview(0, 0, presentation: $presentation, authoredFrame: -5);
+        $last = $session->readSummonBattlePreview(0, 0, presentation: $presentation, authoredFrame: 999);
+
+        expect($twelfth['authoredFrames']['target'])->toBe(12)
+            ->and($twelfth['phase'])->toBe('target')
+            ->and($first['frame'])->toBe($first['phases']['target']['start'])
+            ->and($last['frame'])->toBe($last['phases']['target']['start'] + $last['phases']['target']['length'] - 1);
+    }
+});
diff --git a/tests/Unit/Session/SummonSequenceSessionTest.php b/tests/Unit/Session/SummonSequenceSessionTest.php
new file mode 100644
index 00000000..4fb7ba5b
--- /dev/null
+++ b/tests/Unit/Session/SummonSequenceSessionTest.php
@@ -0,0 +1,107 @@
+readDatabaseRecord('cutscenes/summon', 0)['rows'], static fn(array $row): bool => trim($row['label']) === $label)
+        ?? throw new RuntimeException(sprintf('No %s row.', $label));
+}
+
+it('separates a summon\'s timeline into terminal and graphical sequences as one undo step, keeping its definition shared', function () {
+    $root = cutsceneProject();
+    $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp';
+    $dataBefore = (string) file_get_contents($folder . '/lantern-wisp.data.php');
+    $session = EditorSession::open($root);
+
+    expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['presentation'])->toBeNull()
+        ->and(fn() => $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical'))->toThrow(SessionRefusal::class, 'one sequence');
+
+    expect($session->separateCutsceneSequences('cutscenes/summon', 0))->toBe(['changed' => true, 'presentation' => 'terminal'])
+        ->and($session->describeCutsceneTimeline('cutscenes/summon', 0)['presentation'])->toBe('terminal');
+    $session->undo();
+    expect($session->describeCutsceneTimeline('cutscenes/summon', 0)['presentation'])->toBeNull();
+    $session->redo();
+
+    // The graphical sequence is edited on its own; the name stays the definition's.
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+    $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'FPS')['key'], '24');
+    $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'Name')['key'], 'Lantern Wraith');
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'terminal');
+    expect(summonRow($session, 'FPS')['value'])->toBe('12');
+    expect($session->saveAll()['failures'])->toBe([]);
+
+    $timeline = require $folder . '/lantern-wisp.timeline.php';
+    expect(array_keys($timeline))->toBe(['formatVersion', 'presentations'])
+        ->and(array_keys($timeline['presentations']))->toBe(['terminal', 'graphical'])
+        ->and($timeline['presentations']['terminal']['fps'])->toBe(12)
+        ->and($timeline['presentations']['graphical']['fps'])->toBe(24)
+        ->and(array_keys($timeline['presentations']['graphical']))->toBe(['fps', 'lengthFrames', 'tracks', 'cues'])
+        ->and((string) file_get_contents($folder . '/lantern-wisp.data.php'))->toBe(str_replace("'Lantern Wisp'", "'Lantern Wraith'", $dataBefore));
+
+    // The Engine plays each renderer its own sequence.
+    $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::SUMMON, 'lantern-wisp');
+    expect($asset->compiledSummon(EffectPresentation::TERMINAL)->fps)->toBe(12)
+        ->and($asset->compiledSummon(EffectPresentation::GRAPHICAL)->fps)->toBe(24);
+});
+
+it('adds a sequence key a summon sequence did not hold to that sequence, never to the definition', function () {
+    $root = cutsceneProject();
+    $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp';
+    $session = EditorSession::open($root);
+    $session->separateCutsceneSequences('cutscenes/summon', 0);
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+
+    $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'Rest Frame')['key'], '12');
+    $session->saveAll();
+
+    $timeline = require $folder . '/lantern-wisp.timeline.php';
+    expect($timeline['presentations']['graphical']['restFrame'])->toBe(12)
+        ->and($timeline['presentations']['terminal'])->not->toHaveKey('restFrame')
+        ->and((string) file_get_contents($folder . '/lantern-wisp.data.php'))->not->toContain('restFrame');
+});
+
+it('duplicates a summon or effect with separate sequences whole, whichever sequence is shown', function () {
+    $root = cutsceneProject();
+    $session = EditorSession::open($root);
+    $session->separateCutsceneSequences('cutscenes/summon', 0);
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+    $session->applyDatabaseRecord('cutscenes/summon', 0, summonRow($session, 'FPS')['key'], '24');
+
+    $copy = $session->duplicateDatabaseRecord('cutscenes/summon', 0);
+    $session->saveAll();
+
+    $timeline = require $root . '/assets/Cutscenes/Summons/lantern-wisp-copy/lantern-wisp-copy.timeline.php';
+    $data = require $root . '/assets/Cutscenes/Summons/lantern-wisp-copy/lantern-wisp-copy.data.php';
+    expect($copy['index'])->toBe(1)
+        ->and(array_keys($timeline['presentations']))->toBe(['terminal', 'graphical'])
+        ->and($timeline['presentations']['terminal']['fps'])->toBe(12)
+        ->and($timeline['presentations']['graphical']['fps'])->toBe(24)
+        ->and($data['id'])->toBe('lantern-wisp-copy');
+});
+
+it('opens a summon read-only when a sequence declares a key its definition holds', function () {
+    $root = cutsceneProject();
+    $folder = $root . '/assets/Cutscenes/Summons/lantern-wisp';
+    file_put_contents($folder . '/lantern-wisp.timeline.php', " 1, 'presentations' => [\n"
+        . "  'terminal' => ['fps' => 12, 'lengthFrames' => 2, 'tracks' => [], 'cues' => [], 'name' => 'Shadow'],\n"
+        . "  'graphical' => ['fps' => 12, 'lengthFrames' => 2, 'tracks' => [], 'cues' => []],\n]];\n");
+
+    $asset = CutsceneLibrary::fromProject($root)->find(CutsceneType::SUMMON, 'lantern-wisp');
+
+    expect($asset->isEditable())->toBeFalse()
+        ->and($asset->readOnlyReason())->toContain('the terminal sequence also declares "name"');
+});
diff --git a/tests/Unit/Session/SummonStageSessionTest.php b/tests/Unit/Session/SummonStageSessionTest.php
new file mode 100644
index 00000000..683b4cd4
--- /dev/null
+++ b/tests/Unit/Session/SummonStageSessionTest.php
@@ -0,0 +1,175 @@
+readDatabaseRecord('cutscenes/summon', 0, $frame)['rows'] as $row) {
+        $rows[trim($row['label'])] ??= $row;
+    }
+
+    return $rows;
+}
+
+it('gives only the graphical sequence a stage, as one undo step, with rows of its own', function () {
+    $session = EditorSession::open(stagedSummonProject());
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+
+    expect(stageRows($session))->not->toHaveKey('Stage Canvas');
+    expect($session->setCutsceneStage('cutscenes/summon', 0, true))->toBe(['changed' => true, 'stage' => true]);
+    $rows = stageRows($session);
+    expect($rows['Stage Canvas']['value'])->toBe('1280, 720')
+        ->and($rows['Stage Start Frame']['value'])->toBe('0')
+        ->and($rows['Stage Restore Frame']['value'])->toBe('23')
+        ->and($rows['Stage Background']['value'])->toBe('black')
+        ->and($rows['Stage Subjects']['value'])->toBe('0')
+        ->and($rows['Stage Camera']['value'])->toBe('1')
+        ->and($rows['Stage Covers']['value'])->toBe('0');
+
+    // The terminal sequence never draws a stage.
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'terminal');
+    expect(stageRows($session))->not->toHaveKey('Stage Canvas');
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+
+    $session->undo();
+    expect(stageRows($session))->not->toHaveKey('Stage Canvas');
+    $session->redo();
+    expect(stageRows($session))->toHaveKey('Stage Canvas');
+    expect($session->setCutsceneStage('cutscenes/summon', 0, false))->toBe(['changed' => true, 'stage' => false])
+        ->and(stageRows($session))->not->toHaveKey('Stage Canvas');
+});
+
+it('refuses a stage for a summon whose timeline is shared by every renderer', function () {
+    $session = EditorSession::open(cutsceneProject());
+
+    expect(fn() => $session->setCutsceneStage('cutscenes/summon', 0, true))->toThrow(SessionRefusal::class, 'separate lantern-wisp');
+});
+
+it('authors a stage subject with a named point, places the art on it, and saves what the Engine plays', function () {
+    $root = stagedSummonProject();
+    $session = EditorSession::open($root);
+    $session->setCutsceneStage('cutscenes/summon', 0, true);
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+    $apply = static fn(array $row, string $value) => $session->applyDatabaseRecord('cutscenes/summon', 0, $row['key'], $value);
+
+    // A subject: who stands where, in a box of its own.
+    $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]);
+    $apply(stageRows($session, ['stage.subjects'])['Subject 1 Id'], 'visitor');
+    $apply(stageRows($session, ['stage.subjects'])['Subject 1 Position'], '640, 600');
+    $apply(stageRows($session, ['stage.subjects'])['Subject 1 Size'], '300, 500.5');
+    // Its chest, a point within that box.
+    $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true);
+    $point = stageRows($session, ['stage.subjects']);
+    $session->applyDatabaseRecordValues('cutscenes/summon', 0, [
+        ['key' => $point['Subject 1 Point 1 Id']['key'], 'value' => 'chest'],
+        ['key' => $point['Subject 1 Point 1 X']['key'], 'value' => '0.5'],
+        ['key' => $point['Subject 1 Point 1 Y']['key'], 'value' => '0.35'],
+    ], 'Chest point');
+
+    // The art stands on the stage, on the visitor's chest, picked rather than typed.
+    $apply(stageRows($session, ['tracks'])['Track 1 Anchor'], 'stage');
+    $track = stageRows($session, ['tracks']);
+    expect($track)->toHaveKeys(['Track 1 Subject', 'Track 1 Subject Point', 'Track 1 Offset', 'Track 1 Z Index', 'Track 1 Keyframe 1 Opacity'])
+        ->not->toHaveKeys(['Track 1 Facing', 'Track 1 Cell Width', 'Track 1 Attachment']);
+    $choices = $session->listReferences(null, 'stage_subjects', ['category' => 'cutscenes/summon', 'index' => 0]);
+    expect(array_column($choices, 'value'))->toContain('visitor');
+    $apply($track['Track 1 Subject'], 'visitor');
+    $apply(stageRows($session, ['tracks'])['Track 1 Subject Point'], 'chest');
+    $apply(stageRows($session, ['tracks'])['Track 1 Keyframe 1 Opacity'], '0');
+
+    expect($session->saveAll()['failures'])->toBe([]);
+    $graphical = (require $root . '/assets/Cutscenes/Summons/lantern-wisp/lantern-wisp.timeline.php')['presentations']['graphical'];
+    expect(array_keys($graphical))->toBe(['fps', 'lengthFrames', 'restFrame', 'stage', 'tracks', 'cues'])
+        ->and($graphical['stage']['subjects'])->toBe([['id' => 'visitor', 'position' => ['x' => 640, 'y' => 600], 'size' => ['width' => 300, 'height' => 500.5],
+            'attachments' => [['id' => 'chest', 'x' => 0.5, 'y' => 0.35]]]])
+        ->and($graphical['tracks'][0]['anchor'])->toBe('stage')
+        ->and($graphical['tracks'][0]['placement'])->toBe(['subject' => 'visitor', 'attachment' => 'chest'])
+        ->and($graphical['tracks'][0]['keyframes'][0]['opacity'])->toBe(0.0);
+
+    $compiled = CutsceneLibrary::fromProject($root)->find(CutsceneType::SUMMON, 'lantern-wisp')->compiledSummon(EffectPresentation::GRAPHICAL);
+    expect($compiled->defaults['stage']['subjects'][0]['id'])->toBe('visitor');
+});
+
+it('names the stage subjects and their points to pick from, and nothing for a summon without one', function () {
+    $session = EditorSession::open(stagedSummonProject());
+    $context = ['category' => 'cutscenes/summon', 'index' => 0];
+
+    expect($session->listReferences(null, 'stage_subjects', $context))->toBe([]);
+    $session->setCutsceneStage('cutscenes/summon', 0, true);
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+    $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]);
+    $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true);
+
+    expect(array_column($session->listReferences(null, 'stage_subjects', $context), 'value'))->toBe(['subject'])
+        ->and($session->listReferences(null, 'stage_attachments', $context))->toBe([['value' => 'point', 'label' => 'point (on subject)']]);
+});
+
+it('describes the stage for a timeline editor: subjects with their boxes and points, camera and cover keys, and where stage art is placed', function () {
+    $root = stagedSummonProject();
+    $session = EditorSession::open($root);
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+    $timeline = $session->describeCutsceneTimeline('cutscenes/summon', 0);
+    expect($timeline['canHaveStage'])->toBeTrue()
+        ->and($timeline['tracks'][0]['art']['pivotDefaults'])->toBe(['battle' => ['x' => 0.5, 'y' => 0.5]])
+        ->and($timeline['stage'])->toBeNull();
+
+    $session->setCutsceneStage('cutscenes/summon', 0, true);
+    $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]);
+    $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true);
+    $session->applyDatabaseRecord('cutscenes/summon', 0, stageRows($session, ['tracks'])['Track 1 Anchor']['key'], 'stage');
+    $session->applyDatabaseRecord('cutscenes/summon', 0, stageRows($session, ['tracks'])['Track 1 Subject']['key'], 'subject');
+    $session->applyDatabaseRecord('cutscenes/summon', 0, stageRows($session, ['tracks'])['Track 1 Keyframe 1 Offset']['key'], '4, -2.5');
+    $timeline = $session->describeCutsceneTimeline('cutscenes/summon', 0);
+
+    expect($timeline['stage'])->toMatchArray(['canvas' => '1280, 720', 'startFrame' => 0, 'restoreFrame' => 23])
+        ->and($timeline['stage']['subjects'])->toHaveCount(1)
+        ->and($timeline['stage']['subjects'][0])->toMatchArray(['id' => 'subject', 'position' => ['x' => 0.0, 'y' => 0.0],
+            'size' => ['width' => 100.0, 'height' => 100.0], 'pivot' => ['x' => 0.5, 'y' => 1.0]])
+        ->and($timeline['stage']['subjects'][0]['points'][0])->toMatchArray(['id' => 'point', 'x' => 0.5, 'y' => 0.5])
+        ->and($timeline['stage']['subjects'][0]['points'][0]['xKey']['field'])->toBe('subject0Attachment0X')
+        ->and($timeline['stage']['camera'])->toHaveCount(1)
+        ->and($timeline['stage']['camera'][0])->toMatchArray(['id' => 'initial', 'frame' => 0])
+        ->and($timeline['stage']['covers'])->toBe([])
+        ->and($timeline['tracks'][0]['art'])->toMatchArray(['anchor' => 'stage', 'subject' => 'subject',
+            'placement' => ['position' => ['x' => 0.0, 'y' => 0.0], 'size' => null, 'pivot' => ['x' => 0.5, 'y' => 0.5]]])
+        ->and($timeline['tracks'][0]['art'])->not->toHaveKey('attachmentOptions')
+        ->and($timeline['tracks'][0]['art']['pivotDefaults'])->toBe(['stage' => ['x' => 0.5, 'y' => 0.5]])
+        ->and($timeline['tracks'][0]['keyframes'][0]['offset'])->toBe(['x' => 4.0, 'y' => -2.5]);
+
+    // The terminal sequence never has one.
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'terminal');
+    expect($session->describeCutsceneTimeline('cutscenes/summon', 0))->toMatchArray(['canHaveStage' => false, 'stage' => null]);
+});
+
+it('names the entry each list row belongs to and its name within it, so one selected entry can be shown on its own', function () {
+    $session = EditorSession::open(stagedSummonProject());
+    $session->selectCutscenePresentation('cutscenes/summon', 0, 'graphical');
+    $session->setCutsceneStage('cutscenes/summon', 0, true);
+    $session->addDatabaseItem('cutscenes/summon', 0, ['frame' => ['stage.subjects']]);
+    $session->addDatabaseItem('cutscenes/summon', 0, stageRows($session, ['stage.subjects'])['Subject 1 Id']['key'], true);
+    $tracks = stageRows($session, ['tracks']);
+    $subjects = stageRows($session, ['stage.subjects']);
+
+    expect($tracks['Track 1 Id'])->toMatchArray(['entry' => [0], 'entryLabel' => 'Track 1', 'name' => 'Id'])
+        ->and($tracks['Track 1 Keyframe 1 Sheet Frame'])->toMatchArray(['entry' => [0, 0], 'name' => 'Sheet Frame'])
+        ->and($subjects['Subject 1 Size'])->toMatchArray(['entry' => [0], 'name' => 'Size'])
+        ->and($subjects['Subject 1 Point 1 X'])->toMatchArray(['entry' => [0, 0], 'entryLabel' => 'Point 1', 'name' => 'X'])
+        // The record's own rows belong to no entry.
+        ->and(stageRows($session)['Name'])->not->toHaveKey('entry');
+});
diff --git a/tests/Unit/Session/TerrainKindSessionTest.php b/tests/Unit/Session/TerrainKindSessionTest.php
new file mode 100644
index 00000000..60787ccf
--- /dev/null
+++ b/tests/Unit/Session/TerrainKindSessionTest.php
@@ -0,0 +1,435 @@
+getValue($session)->getMapByIndex(0);
+}
+
+/** @return list A layer's rows. */
+function terrainKindRows(EditorSession $session, string $layerId = 'map:1'): array
+{
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === $layerId);
+
+    return array_map(static fn(array $row): string => implode('', $row), $layer['rows']);
+}
+
+/** @return array>> Tile rows by tile layer name. */
+function terrainKindTiles(EditorSession $session): array
+{
+    return array_column(array_map(static fn(array $layer): array => [$layer['name'], $layer['rows']], $session->readTiles('test-map')['layers']), 1, 0);
+}
+
+function convertTerrainKindMap(EditorSession $session, array $mapping = ['.' => 'meadow']): array
+{
+    return $session->convertToKinds('test-map', $session->readMap('test-map')['revision'], $mapping);
+}
+
+function paintTerrainKind(EditorSession $session, string $kind, array $from, array $to): array
+{
+    return $session->paintKind('test-map', $session->readMap('test-map')['revision'], $kind, $from, $to);
+}
+
+describe('before conversion', function () {
+    it('lists the tileset\'s kinds with their glyph, colour, passage and art, and says the map must be converted first', function () {
+        $listed = EditorSession::open(terrainKindProject())->listKinds('test-map');
+
+        expect($listed['painted'])->toBeFalse()
+            ->and($listed['issue'])->toContain('not painted in terrain kinds yet')
+            ->and($listed['issue'])->toContain('map:convert-kinds test-map')
+            ->and(array_column($listed['kinds'], 'id'))->toBe(['meadow', 'path', 'grove', 'peaks'])
+            ->and($listed['kinds'][2])->toMatchArray(['glyph' => '♣', 'color' => 'green', 'passage' => 'walk', 'encounters' => 'grove',
+                'ground' => 'Meadow', 'atlas' => 'vegetation', 'awaitingArt' => false, 'tileLayers' => ['canopy', 'floor'], 'layerIssue' => null])
+            ->and($listed['kinds'][3])->toMatchArray(['passage' => 'solid', 'encounters' => null, 'awaitingArt' => true]);
+    });
+
+    it('refuses to paint a kind on a map that is not painted in kinds', function () {
+        $session = EditorSession::open(terrainKindProject());
+
+        expect(fn() => paintTerrainKind($session, 'meadow', [0, 0], [0, 0]))->toThrow(SessionRefusal::class, 'Convert it first');
+    });
+});
+
+describe('conversion', function () {
+    it('previews each mapped glyph\'s cells and the collision it changes, changing nothing', function () {
+        $root = terrainKindProject("...=\n....");
+        $session = EditorSession::open($root);
+        $hashes = sourceHashTree($root);
+
+        $preview = $session->previewKindConversion('test-map', ['.' => 'meadow', '=' => 'path']);
+
+        // The empty dictionary leaves every glyph solid; the kinds open the terrain cells nothing stands on.
+        expect($preview['declares'])->toBeTrue()
+            ->and($preview['mappings'])->toBe([
+                ['glyph' => '.', 'kind' => 'meadow', 'name' => 'Meadow', 'layer' => 'terrain', 'cells' => 7, 'awaitingArt' => false],
+                ['glyph' => '=', 'kind' => 'path', 'name' => 'Path', 'layer' => 'terrain', 'cells' => 1, 'awaitingArt' => false],
+            ])
+            ->and($preview['unmapped'])->toBe([])
+            ->and($preview['collision'])->toMatchArray(['changed' => 5, 'blocked' => 0, 'opened' => 5])
+            ->and($preview['collision']['cells'])->toBe([[0, 0], [2, 0], [3, 0], [0, 1], [3, 1]])
+            ->and(terrainKindMap($session)->isDirty())->toBeFalse()
+            ->and(sourceHashTree($root))->toBe($hashes);
+    });
+
+    it('lists what a map holds, and refuses to leave a glyph that already names a kind unmapped, so nothing is reread unseen', function () {
+        $session = EditorSession::open(terrainKindProject("...=\n...."));
+
+        $preview = $session->previewKindConversion('test-map', []);
+        expect($preview['unmapped'])->toBe([['glyph' => '.', 'layer' => 'terrain', 'cells' => 7], ['glyph' => '=', 'layer' => 'terrain', 'cells' => 1]])
+            // Both already name kinds, so both must be mapped before converting.
+            ->and($preview['reread'])->toBe($preview['unmapped'])
+            ->and(fn() => convertTerrainKindMap($session, ['.' => 'meadow']))
+            ->toThrow(SessionRefusal::class, "'=' on terrain (1 cells), which name terrain kinds")
+            ->and(fn() => convertTerrainKindMap($session, []))->toThrow(SessionRefusal::class, 'Map at least one old glyph');
+    });
+
+    it('opts the map in and paints every mapped cell with its kind as one undo step, clearing the old glyph\'s tiles', function () {
+        $session = EditorSession::open(terrainKindProject("..x.\n...."));
+        // An old glyph's own tile on a layer the terrain owns, which the kind does not draw.
+        $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'canopy', [[2, 0]], 3008, 'Old tile');
+
+        $result = convertTerrainKindMap($session, ['.' => 'meadow', 'x' => 'peaks']);
+
+        $map = terrainKindMap($session);
+        expect($result)->toMatchArray(['status' => 'applied', 'changed' => true, 'cells' => 8])
+            ->and($map->getMapDataField(['terrainKinds']))->toBeTrue()
+            ->and(terrainKindRows($session))->toBe(['..^.', '....'])
+            ->and($map->getLayerColor('map:1', 2, 0))->toBe('#a0522d')
+            // Peaks await their art, so they draw nothing, not even their meadow: the glyph shows graphically.
+            ->and(terrainKindTiles($session)['canopy'][0])->toBe([0, 0, 0, 0])
+            ->and(terrainKindTiles($session)['floor'][0])->toBe([2816, 2816, 0, 2816])
+            ->and($session->listKinds('test-map')['painted'])->toBeTrue();
+
+        $session->undo();
+        expect($map->hasMapDataField(['terrainKinds']))->toBeFalse()
+            ->and(terrainKindRows($session))->toBe(['..x.', '....'])
+            ->and(terrainKindTiles($session)['canopy'][0][2])->toBe(3008)
+            ->and(terrainKindTiles($session)['floor'][0][2])->toBe(2816);
+    });
+
+    it('leaves the kinds behind when the map changes to a kind whose tileset declares none, as one undo step', function () {
+        $root = terrainKindProject("..x.\n....");
+        // A plain tileset, its passage the collision dictionary's.
+        writeTestTileset($root, 'plain');
+        $session = EditorSession::open($root);
+        convertTerrainKindMap($session, ['.' => 'meadow', 'x' => 'peaks']);
+        $map = terrainKindMap($session);
+        $kindRow = ['target' => 'map-kind', 'field' => 'tileset'];
+
+        $question = $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $kindRow, 'plain');
+        expect($question['status'])->toBe('question')
+            ->and($question['answers'][1]['description'])->toContain('with no terrain kinds in Plain they block or let pass as the collision dictionary says');
+        $session->applyInspector('test-map', $session->readMap('test-map')['revision'], $kindRow, 'plain', 'clear');
+        expect($map->getMapDataField(['tileset']))->toBe('plain')
+            ->and($map->hasMapDataField(['terrainKinds']))->toBeFalse()
+            ->and(array_filter($session->validateMap('test-map')['issues'],
+                static fn(array $issue): bool => str_contains($issue['message'], 'has no terrain kinds')))->toBe([]);
+
+        $session->undo();
+        expect($map->getMapDataField(['tileset']))->toBe('home')
+            ->and($map->getMapDataField(['terrainKinds']))->toBeTrue();
+    });
+
+    it('lets the kinds take over the map\'s atlas classifications of the glyphs they convert or name, keeping the rest', function () {
+        $root = terrainKindProject("..x.\n..##");
+        $data = $root . '/assets/Maps/test-map/test-map.data.php';
+        // The old map classifies its glyphs itself: '.' and 'x' convert to kinds, '^' names one, '#' stays as it is.
+        file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'atlas' => ['terrain' => ['terrain' => ['symbols' => "
+            . "['.' => 'ground', 'x' => 'obstacle', '^' => 'obstacle', '#' => 'boundary']]]],", (string) file_get_contents($data)));
+        $session = EditorSession::open($root);
+
+        expect(array_column($session->previewKindConversion('test-map', ['.' => 'meadow', 'x' => 'peaks'])['atlasBindings'], 'glyph'))
+            ->toBe(['.', 'x', '^']);
+        convertTerrainKindMap($session, ['.' => 'meadow', 'x' => 'peaks']);
+
+        $map = terrainKindMap($session);
+        expect($map->getMapDataField(['atlas']))->toBe(['terrain' => ['terrain' => ['symbols' => ['#' => 'boundary']]]]);
+        $session->undo();
+        expect($map->getMapDataField(['atlas', 'terrain', 'terrain', 'symbols']))->toBe(['.' => 'ground', 'x' => 'obstacle', '^' => 'obstacle', '#' => 'boundary']);
+    });
+
+    it('converts through a change batch, as agents and scripts change maps, writing nothing when only checked', function () {
+        $root = terrainKindProject();
+        $hashes = sourceHashTree($root);
+
+        $checked = ChangeBatch::apply($root, [['method' => 'kinds.convert', 'params' => ['map' => 'test-map', 'mapping' => ['.' => 'meadow']]]],
+            checkOnly: true);
+
+        // The fixture's own unrelated findings refuse the commit; the conversion itself applied.
+        expect($checked['status'])->not->toBe('failed')
+            ->and($checked['maps'])->toBe(['test-map'])
+            ->and($checked['written'])->toBe([])
+            ->and(sourceHashTree($root))->toBe($hashes);
+    });
+});
+
+describe('painting', function () {
+    it('paints a feature with its glyph, its autotile and its ground\'s, as previewed, in one undo step', function () {
+        $session = EditorSession::open(terrainKindProject());
+        convertTerrainKindMap($session);
+
+        expect($session->previewKind('test-map', 'grove', [0, 0], [1, 0])['cells'])->toBe([[0, 0, '♣', 'green'], [1, 0, '♣', 'green']])
+            ->and(paintTerrainKind($session, 'grove', [0, 0], [1, 0]))->toMatchArray(['changed' => true, 'cells' => 2])
+            ->and(terrainKindRows($session))->toBe(['♣♣..', '....'])
+            ->and(terrainKindTiles($session)['canopy'][0])->toBe([2912, 2912, 0, 0])
+            ->and(terrainKindTiles($session)['floor'][0])->toBe([2816, 2816, 2816, 2816])
+            ->and($session->readKindAt('test-map', 0, 0))->toBe(['kind' => 'grove'])
+            // The buildings' slash stands over a meadow cell; the kind beneath is still found.
+            ->and($session->readKindAt('test-map', 1, 0))->toBe(['kind' => 'grove']);
+
+        // A kind painted over another takes the old one's own tiles with it.
+        paintTerrainKind($session, 'path', [1, 0], [1, 0]);
+        expect(terrainKindRows($session))->toBe(['♣=..', '....'])
+            ->and(terrainKindTiles($session)['canopy'][0])->toBe([2912, 0, 0, 0])
+            ->and(terrainKindTiles($session)['floor'][0])->toBe([2816, 2864, 2816, 2816]);
+
+        $session->undo();
+        $session->undo();
+        expect(terrainKindRows($session))->toBe(['....', '....'])
+            ->and(terrainKindTiles($session))->not->toHaveKey('canopy');
+    });
+
+    it('paints a kind on listed cells in one undo step, as an area paint would, and refuses a cell off the map', function () {
+        $session = EditorSession::open(terrainKindProject());
+        convertTerrainKindMap($session);
+        $paint = static fn(array $cells): array => $session->paintKindCells('test-map', $session->readMap('test-map')['revision'], 'grove', $cells);
+
+        expect($paint([[0, 0], [3, 1], [0, 0]]))->toMatchArray(['changed' => true, 'cells' => 2])
+            ->and(terrainKindRows($session))->toBe(['♣...', '...♣'])
+            ->and(terrainKindTiles($session)['canopy'])->toBe([[2912, 0, 0, 0], [0, 0, 0, 2912]]);
+
+        $session->undo();
+        expect(terrainKindRows($session))->toBe(['....', '....']);
+
+        expect(fn() => $paint([[1, 0], [4, 0]]))->toThrow(SessionRefusal::class, 'x 4, y 0 is off test-map')
+            ->and(terrainKindRows($session))->toBe(['....', '....']);
+    });
+
+    it('takes passage from the kind, and on declared occupancy writes it where the kind is the top layer', function () {
+        $session = EditorSession::open(terrainKindProject());
+        convertTerrainKindMap($session);
+        $map = terrainKindMap($session);
+        expect($map->getResolvedCollisionMap()[0])->toBe([7, 1, 7, 7]);
+
+        $session->migratePhysicalOccupancy('test-map', $session->readMap('test-map')['revision']);
+        paintTerrainKind($session, 'peaks', [0, 1], [3, 1]);
+
+        // The buildings' x keeps its own cells; the peaks' collision lands beside them.
+        expect($map->getMapDataField(['occupancy', 1]))->toBe([CollisionType::SOLID, CollisionType::SOLID, CollisionType::SOLID, CollisionType::SOLID])
+            ->and($map->getMapDataField(['occupancy', 0, 0]))->toBe(CollisionType::ENCOUNTER);
+        paintTerrainKind($session, 'path', [0, 1], [3, 1]);
+        expect($map->getMapDataField(['occupancy', 1]))->toBe([CollisionType::NONE, CollisionType::SOLID, CollisionType::SOLID, CollisionType::NONE]);
+
+        $session->undo();
+        expect($map->getMapDataField(['occupancy', 1, 0]))->toBe(CollisionType::SOLID)
+            ->and(terrainKindRows($session)[1])->toBe('^^^^');
+    });
+
+    it('reports cells whose glyph and tiles part company, and cells of kinds awaiting art, as warnings', function () {
+        $root = terrainKindProject();
+        $data = $root . '/assets/Maps/test-map/test-map.data.php';
+        file_put_contents($data, str_replace("'tileset' => 'home',", "'tileset' => 'home', 'terrainKinds' => true,", (string) file_get_contents($data)));
+        // Path's autotile under a meadow glyph, as an edit outside the editors leaves it.
+        writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "2816 2816 2816 2816\n2864 2816 2816 2816");
+        $session = EditorSession::open($root);
+        paintTerrainKind($session, 'peaks', [3, 0], [3, 0]);
+
+        $issues = array_values(array_filter($session->validateProject(['test-map'])['issues'],
+            static fn(array $issue): bool => str_starts_with((string) $issue['code'], 'terrain.')));
+
+        expect(array_map(static fn(array $issue): array => [$issue['code'], $issue['severity']], $issues))->toBe([
+            ['terrain.disagreement', 'warning'],
+            ['terrain.awaiting-art', 'warning'],
+        ])
+            ->and($issues[0]['message'])->toContain('At (0, 1)')
+            ->and($issues[1]['message'])->toContain('Peaks is awaiting its art');
+    });
+});
+
+describe('authoring kinds', function () {
+    /** @return array> The tileset record's rows by field id. */
+    function terrainKindRecordRows(EditorSession $session): array
+    {
+        $rows = [];
+        foreach ($session->readDatabaseRecord('tilesets', 0)['rows'] as $row) {
+            if (is_string($row['key']['field'] ?? null)) {
+                $rows[$row['key']['field']] = $row;
+            }
+        }
+
+        return $rows;
+    }
+
+    it('adds and edits a kind in Database > Tilesets, choosing its ground among the tileset\'s ground kinds, and saves it source-preserving', function () {
+        $root = terrainKindProject();
+        $path = $root . '/assets/Data/Tilesets/home.php';
+        file_put_contents($path, str_replace("toBe(['', 'path'])
+            ->and($rows['kind2Ground']['options'])->toBe(['', 'meadow', 'path']);
+
+        // The heading adds an entry at the head of the list.
+        $session->addDatabaseItem('tilesets', 0, $rows['kindList']['key']);
+        $set = static fn(string $field, string $value) => $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)[$field]['key'], $value);
+        $set('kind0Id', 'thicket');
+        $set('kind0Name', 'Thicket');
+        $set('kind0Glyph', '%');
+        $set('kind0Ground', 'meadow');
+        $set('kind0Encounters', 'grove');
+        $set('kind0Atlas', 'vegetation');
+        $session->saveDatabase('tilesets');
+
+        $thicket = Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home')->kinds['thicket'];
+        expect((string) file_get_contents($path))->toContain('// Kept by the editor.')
+            ->and($thicket->ground)->toBe('meadow')
+            ->and($thicket->awaitingArt)->toBeTrue()
+            ->and($thicket->encounters)->toBe('grove')
+            // Awaiting its art, it draws nothing until its autotile is bound.
+            ->and($thicket->piece->tiles)->toBe([]);
+    });
+
+    it('adds a thicket in Database > Tilesets and names it on a kind, choosing among the tileset\'s thickets, saved source-preserving', function () {
+        $root = terrainKindProject();
+        $path = $root . '/assets/Data/Tilesets/home.php';
+        file_put_contents($path, str_replace("toBe([''])
+            ->and($rows['kind2Thicket']['value'])->toBe('(hides nobody)');
+
+        $session->addDatabaseItem('tilesets', 0, $rows['thicketList']['key']);
+        $set = static fn(string $field, string $value) => $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)[$field]['key'], $value);
+        $set('thicket0Id', 'woods');
+        $set('thicket0Name', 'Woods');
+        $set('thicket0Depth', '24');
+        $set('thicket0Opacity', '0.5');
+        $set('thicket0Rustle', 'false');
+        $set('thicket0Tiles', '4, 5');
+        expect(terrainKindRecordRows($session)['kind2Thicket']['options'])->toBe(['', 'woods']);
+        $set('kind2Thicket', 'woods');
+        $session->saveDatabase('tilesets');
+
+        $tileset = Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home');
+        expect((string) file_get_contents($path))->toContain('// Kept by the editor.')
+            ->and($tileset->thickets['woods'])->toMatchArray(['name' => 'Woods', 'depth' => 24, 'opacity' => 0.5, 'rustle' => false, 'tiles' => [4, 5]])
+            ->and($tileset->kinds['grove']->thicket)->toBe('woods')
+            // The grove's own autotile, in any shape, and the listed tiles hide a character in the woods.
+            ->and($tileset->getThicket(2912 + 7)?->id)->toBe('woods')
+            ->and($tileset->getThicket(5)?->id)->toBe('woods')
+            ->and($tileset->getThicket(2816))->toBeNull();
+
+        // Choosing none again hides nobody in the grove.
+        $set('kind2Thicket', '');
+        $session->saveDatabase('tilesets');
+        expect(Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home')->kinds['grove']->thicket)->toBeNull();
+    });
+
+    it('refuses a thicket the Engine would refuse, and keeps thickets the terminal Database does not show', function () {
+        $root = terrainKindProject();
+        $path = $root . '/assets/Data/Tilesets/home.php';
+        $data = require $path;
+        $data['thickets'] = ['woods' => ['name' => 'Woods', 'depth' => 24, 'opacity' => 0.0]];
+        $data['kinds']['grove']['thicket'] = 'woods';
+        file_put_contents($path, "getFrameSettingsFields(0, []), static fn(array $field): bool => isset($field['field'])),
+            'field');
+        expect(array_filter($fields, static fn(string $field): bool => str_contains(strtolower($field), 'thicket')))->toBe([]);
+        $terminal->setField(0, 'name', 'Home tiles');
+        $terminal->save();
+        $tileset = Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home');
+        expect($tileset->name)->toBe('Home tiles')
+            ->and($tileset->kinds['grove']->thicket)->toBe('woods')
+            ->and($tileset->thickets['woods']->depth)->toBe(24);
+
+        // A depth deeper than a cell is reported by its file, as the Engine would refuse it.
+        $session = EditorSession::open($root);
+        $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)['thicket0Depth']['key'], '60');
+        $session->saveDatabase('tilesets');
+        $issues = array_values(array_filter($session->validateProject()['issues'],
+            static fn(array $issue): bool => str_contains($issue['message'], 'depth must be 1 to 48')));
+        expect(array_column($issues, 'where'))->toContain('assets/Data/Tilesets/home.php');
+    });
+
+    it('chooses a kind\'s corner sheet among the project\'s PNG assets, so its edges are drawn on the corners between cells', function () {
+        $root = terrainKindProject();
+        writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Meadow.corners.png', 8, 8);
+        $session = EditorSession::open($root);
+        $field = Ichiloto\Editor\Database\ProjectRecordDatabase::subFieldId('kind', 0, 'art.corners');
+        $row = terrainKindRecordRows($session)[$field];
+
+        expect($row['label'])->toBe('Terrain kind 1 Corner Sheet')
+            ->and($row['reference'] ?? null)->toBe('png_assets')
+            ->and($row['value'])->toBe('(edges in each cell)');
+        $session->applyDatabaseRecord('tilesets', 0, $row['key'], 'Graphics/Tilesets/Meadow.corners.png');
+        $session->saveDatabase('tilesets');
+        $meadow = Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home')->kinds['meadow'];
+        expect($meadow->cornerEdged)->toBeTrue()
+            ->and($meadow->art)->toBe(['layer' => 'floor', 'tile' => 2816, 'corners' => 'Graphics/Tilesets/Meadow.corners.png'])
+            // The kind is still painted with the same glyph and autotile.
+            ->and($meadow->piece->tiles)->toBe(['floor' => [['2816']]]);
+
+        // Choosing none draws its edges in each cell again.
+        $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)[$field]['key'], '');
+        $session->saveDatabase('tilesets');
+        expect(Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home')->kinds['meadow']->cornerEdged)->toBeFalse();
+    });
+
+    it('names a corner-edged kind\'s join group, so kinds of one group share one edge', function () {
+        $root = terrainKindProject();
+        writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Meadow.corners.png', 8, 8);
+        $session = EditorSession::open($root);
+        $corners = Ichiloto\Editor\Database\ProjectRecordDatabase::subFieldId('kind', 0, 'art.corners');
+        $joins = Ichiloto\Editor\Database\ProjectRecordDatabase::subFieldId('kind', 0, 'art.joins');
+        $row = terrainKindRecordRows($session)[$joins];
+
+        expect($row['label'])->toBe('Terrain kind 1 Joins')
+            ->and($row['value'])->toBe('(edges against every kind)');
+        $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)[$corners]['key'], 'Graphics/Tilesets/Meadow.corners.png');
+        $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)[$joins]['key'], 'green');
+        $session->saveDatabase('tilesets');
+        $tileset = Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home');
+        expect($tileset->kinds['meadow']->joins)->toBe('green')
+            ->and($tileset->kinds['meadow']->art)->toBe(['layer' => 'floor', 'tile' => 2816, 'corners' => 'Graphics/Tilesets/Meadow.corners.png',
+                'joins' => 'green'])
+            ->and($tileset->getCornerJoins())->toBe([2816 => [2816]]);
+
+        // Leaving it empty draws the kind's edge against every other kind again.
+        $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)[$joins]['key'], '');
+        $session->saveDatabase('tilesets');
+        expect(Ichiloto\Engine\Rendering\Tilesets\Tileset::load($root . '/assets', 'home')->kinds['meadow']->joins)->toBeNull();
+    });
+
+    it('reports a tileset\'s kinds awaiting art, and refuses a kind the Engine would refuse', function () {
+        $session = EditorSession::open(terrainKindProject());
+        $warning = array_values(array_filter($session->validateProject()['issues'],
+            static fn(array $issue): bool => str_contains($issue['message'], 'awaiting art:')));
+        expect($warning)->toHaveCount(1)
+            ->and($warning[0]['message'])->toContain('1 terrain kind is awaiting art: Peaks');
+
+        $session->applyDatabaseRecord('tilesets', 0, terrainKindRecordRows($session)['kind0Glyph']['key'], '.');
+        expect(implode(' ', array_column($session->validateProject()['issues'], 'message')))->toContain('glyph needs its terminal colour');
+    });
+});
diff --git a/tests/Unit/Session/TilesCarryGlyphsSessionTest.php b/tests/Unit/Session/TilesCarryGlyphsSessionTest.php
new file mode 100644
index 00000000..eb247bb6
--- /dev/null
+++ b/tests/Unit/Session/TilesCarryGlyphsSessionTest.php
@@ -0,0 +1,186 @@
+ ['name' => 'Chest', 'layer' => 'buildings', 'glyphs' => ['m'], 'tiles' => ['furniture' => ['60']]],
+        // The sofa's back rises into the row above its seat.
+        'sofa' => ['name' => 'Sofa', 'layer' => 'buildings', 'glyphs' => ['  ', '##'], 'tiles' => ['furniture' => ['70 71', '72 73']]],
+        // A window draws one tile over two glyph cells.
+        'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['X', 'X'], 'tiles' => ['windows' => ['384', '0']]],
+        // Two pieces drawing the same tile.
+        'stool' => ['name' => 'Stool', 'layer' => 'buildings', 'glyphs' => ['o'], 'tiles' => ['furniture' => ['80']]],
+        'pot' => ['name' => 'Pot', 'layer' => 'buildings', 'glyphs' => ['p'], 'tiles' => ['furniture' => ['80']]],
+        'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines',
+            'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']],
+        // A rug has no glyph: its tiles stand for nothing in the terminal.
+        'rug' => ['name' => 'Rug', 'layer' => 'buildings', 'glyphs' => [' '], 'tiles' => ['rug' => ['90']]],
+    ]);
+
+    return EditorSession::open($root);
+}
+
+/** @return list The buildings layer, row by row. */
+function readSessionBuildingRows(EditorSession $session): array
+{
+    $layer = array_find($session->readMap('test-map')['layers'], static fn(array $layer): bool => $layer['id'] === 'map:4');
+
+    return array_map(static fn(array $row): string => implode('', $row), $layer['rows']);
+}
+
+/** @return list> A tile layer's tiles, row by row; none where the map has no such layer. */
+function readSessionTiles(EditorSession $session, string $layer): array
+{
+    $found = array_find($session->readTiles('test-map')['layers'], static fn(array $candidate): bool => $candidate['name'] === $layer);
+
+    return $found['rows'] ?? [];
+}
+
+function stampSessionTiles(EditorSession $session, string $layer, array $cells, array $choices = []): array
+{
+    return $session->stampTiles('test-map', $session->readMap('test-map')['revision'], $layer, $cells, 'Place tiles', $choices);
+}
+
+it('writes the glyph a placed tile stands for and removes it with the tile, each one undo step', function () {
+    $session = tilesCarryGlyphsSession();
+
+    expect(stampSessionTiles($session, 'furniture', [[0, 0, 60]]))->toMatchArray(['status' => 'applied', 'changed' => 1, 'glyphs' => 1])
+        ->and(readSessionBuildingRows($session)[0])->toBe('m/  ');
+
+    stampSessionTiles($session, 'furniture', [[0, 0, 0]]);
+    expect(readSessionBuildingRows($session)[0])->toBe(' /  ')
+        ->and(readSessionTiles($session, 'furniture')[0][0])->toBe(0);
+
+    $session->undo();
+    expect(readSessionBuildingRows($session)[0])->toBe('m/  ')
+        ->and(readSessionTiles($session, 'furniture')[0][0])->toBe(60);
+    $session->undo();
+    expect(readSessionBuildingRows($session)[0])->toBe(' /  ');
+});
+
+it('moves a piece with its glyphs and every tile of it when one stamp erases it and places it again', function () {
+    $session = tilesCarryGlyphsSession();
+    stampSessionTiles($session, 'furniture', [[0, 1, 72], [1, 1, 73]]);
+    // The seat's glyphs arrive with the back in the row above.
+    expect(readSessionBuildingRows($session))->toBe([' /  ', '##x '])
+        ->and(readSessionTiles($session, 'furniture'))->toBe([[70, 71, 0, 0], [72, 73, 0, 0]]);
+
+    stampSessionTiles($session, 'furniture', [[0, 1, 0], [1, 1, 0], [2, 1, 72], [3, 1, 73]]);
+    expect(readSessionBuildingRows($session))->toBe([' /  ', '  ##'])
+        ->and(readSessionTiles($session, 'furniture'))->toBe([[0, 0, 70, 71], [0, 0, 72, 73]]);
+
+    // The whole move is one step.
+    $session->undo();
+    expect(readSessionBuildingRows($session))->toBe([' /  ', '##x '])
+        ->and(readSessionTiles($session, 'furniture'))->toBe([[70, 71, 0, 0], [72, 73, 0, 0]]);
+});
+
+it('takes the glyph a covered tile stood for, with the rest of its tiles', function () {
+    $session = tilesCarryGlyphsSession();
+    stampSessionTiles($session, 'furniture', [[0, 1, 72], [1, 1, 73]]);
+
+    // Covering the sofa's back removes the seat glyph it belongs to and that seat's other tiles.
+    stampSessionTiles($session, 'furniture', [[0, 0, 60]]);
+    expect(readSessionBuildingRows($session))->toBe(['m/  ', ' #x '])
+        ->and(readSessionTiles($session, 'furniture'))->toBe([[60, 71, 0, 0], [0, 73, 0, 0]]);
+});
+
+it('brings a piece\'s glyph cells that draw no tile with the one that does', function () {
+    $session = tilesCarryGlyphsSession();
+
+    stampSessionTiles($session, 'windows', [[3, 0, 384]]);
+    expect(readSessionBuildingRows($session))->toBe([' / X', ' xxX']);
+
+    stampSessionTiles($session, 'windows', [[3, 0, 0]]);
+    expect(readSessionBuildingRows($session))->toBe([' /  ', ' xx ']);
+});
+
+it('joins and leaves a wall, each wall cell taking the shape its neighbours give it', function () {
+    $session = tilesCarryGlyphsSession();
+
+    stampSessionTiles($session, 'walls', [[2, 0, 5888], [3, 0, 5888]]);
+    expect(readSessionBuildingRows($session)[0])->toBe(' /--');
+
+    stampSessionTiles($session, 'walls', [[3, 0, 0]]);
+    expect(readSessionBuildingRows($session)[0][3])->toBe(' ')
+        ->and(readSessionTiles($session, 'walls')[0])->toBe([0, 0, 5888, 0]);
+});
+
+it('leaves glyphs alone for a tile that stands for nothing', function () {
+    $session = tilesCarryGlyphsSession();
+
+    expect(stampSessionTiles($session, 'rug', [[0, 0, 90], [3, 1, 90]]))->toMatchArray(['changed' => 2, 'glyphs' => 0])
+        ->and(readSessionBuildingRows($session))->toBe([' /  ', ' xx ']);
+});
+
+/** Wood and stone walls, and a glyph-less cap that draws the stone wall's own tile. */
+function stoneCapSession(): EditorSession
+{
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: [
+        'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines',
+            'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']],
+        'stone-wall' => ['name' => 'Stone wall', 'layer' => 'buildings', 'connects' => 'lines',
+            'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5936']],
+        'stone-cap' => ['name' => 'Stone cap (visual)', 'layer' => 'buildings', 'glyphs' => [' '], 'tiles' => ['walls' => ['5936']]],
+    ]);
+
+    return EditorSession::open($root);
+}
+
+it('lets a tile a glyph-less piece also draws stand on a blank cell without bringing a glyph', function () {
+    $session = stoneCapSession();
+
+    expect(stampSessionTiles($session, 'walls', [[0, 0, 5936], [3, 1, 5936]]))->toMatchArray(['status' => 'applied', 'changed' => 2, 'glyphs' => 0])
+        ->and(readSessionBuildingRows($session))->toBe([' /  ', ' xx ']);
+});
+
+it('tops a wall with the shared tile as the wall whose glyph is already there, keeping the glyph', function () {
+    $session = stoneCapSession();
+    // A wood wall running down the first column.
+    $session->paint('test-map', $session->readMap('test-map')['revision'], 'map:4', [[0, 0], [0, 1]], '|', null, ['|' => 'wall:vertical']);
+    expect(array_map(static fn(string $row): string => $row[0], readSessionBuildingRows($session)))->toBe(['|', '|'])
+        ->and(array_column(readSessionTiles($session, 'walls'), 0))->toBe([5888, 5888]);
+
+    // The wood wall's tiles leave with their role, and the stone wall arrives on the same glyphs.
+    stampSessionTiles($session, 'walls', [[0, 0, 5936], [0, 1, 5936]]);
+    expect(array_map(static fn(string $row): string => $row[0], readSessionBuildingRows($session)))->toBe(['|', '|'])
+        ->and(array_column(readSessionTiles($session, 'walls'), 0))->toBe([5936, 5936]);
+});
+
+it('asks which glyph a tile shared by several pieces stands for, changing nothing until answered', function () {
+    $session = tilesCarryGlyphsSession();
+
+    $asked = stampSessionTiles($session, 'furniture', [[0, 0, 80]]);
+    expect($asked['status'])->toBe('question')
+        ->and($asked['tile'])->toBe('80')
+        ->and(array_column($asked['roles'], 'label'))->toBe(['Stool', 'Pot'])
+        ->and(readSessionBuildingRows($session)[0])->toBe(' /  ')
+        ->and($session->listUnsavedChanges())->toBe([]);
+
+    stampSessionTiles($session, 'furniture', [[0, 0, 80]], ['80' => $asked['roles'][1]['key']]);
+    expect(readSessionBuildingRows($session)[0])->toBe('p/  ');
+});
+
+it('refuses a tile whose glyph would fall outside the map, changing nothing', function () {
+    $session = tilesCarryGlyphsSession();
+
+    // The sofa's back on the last row would need its seat below the map.
+    expect(fn() => stampSessionTiles($session, 'furniture', [[0, 1, 70]]))->toThrow(SessionRefusal::class, 'outside the map')
+        ->and(readSessionBuildingRows($session))->toBe([' /  ', ' xx '])
+        ->and($session->listUnsavedChanges())->toBe([]);
+});
diff --git a/tests/Unit/Session/TilesetAuthoringSessionTest.php b/tests/Unit/Session/TilesetAuthoringSessionTest.php
new file mode 100644
index 00000000..cf48fd39
--- /dev/null
+++ b/tests/Unit/Session/TilesetAuthoringSessionTest.php
@@ -0,0 +1,140 @@
+ 'Fence', 'layer' => 'buildings', 'connects' => TilesetPiece::LINES,
+        'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'],
+        'tiles' => ['walls' => '2816', 'trim' => ['horizontal' => '5', 'vertical' => '6', 'corner' => '7']]];
+}
+
+it('previews every cardinal neighbourhood using shared piece shaping and Engine autotile resolution', function () {
+    $piece = TilesetPiece::fromArray('fence', connectedAuthoringPiece(), 'Test');
+    $samples = ConnectedPiecePreview::buildSamples($piece);
+    expect(array_column($samples, 'mask'))->toBe(range(0, 15));
+    foreach ($samples as $sample) {
+        $mask = $sample['mask'];
+        $shape = $piece->getLineShape((bool) ($mask & 1), (bool) ($mask & 2), (bool) ($mask & 4), (bool) ($mask & 8));
+        $drawn = $sample['piece'];
+        expect($sample['shape'])->toBe($shape)
+            ->and($drawn->getSourceGrid()[2][2])->toBe($piece->getSourceShapeGrid()[$shape])
+            ->and($drawn->tiles['trim'][2][2])->toBe($piece->shapeTiles['trim'][$shape])
+            ->and($drawn->glyphs[0])->toBe(array_fill(0, 5, ' '))
+            ->and($drawn->glyphs[4])->toBe(array_fill(0, 5, ' '));
+        $raw = array_map(static fn(array $row): array => array_map(static fn(string $glyph): int => $glyph === ' ' ? 0 : 2816, $row), $drawn->glyphs);
+        expect(array_map(static fn(array $row): array => array_map(intval(...), $row), $drawn->tiles['walls']))
+            ->toBe(AutotileShape::resolveLayer($raw));
+    }
+    expect($samples[0]['piece']->tiles['walls'][2][2])->not->toBe($samples[15]['piece']->tiles['walls'][2][2]);
+});
+
+it('exports canonical shapes and live record keys for sheet authoring including incomplete tile rows', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece(),
+        'rug' => ['name' => 'Rug', 'layer' => 'fixtures', 'glyphs' => ['  ', '  '], 'tiles' => ['rugs' => ['0']]]]);
+    $session = EditorSession::open($root);
+    $preview = $session->readTilesetPreview(0);
+    expect($preview['issue'])->toContain('must match its glyphs')
+        ->and($preview['connectionShapes'])->toBe(TilesetPiece::LINE_SHAPES)
+        ->and($preview['authoring'][1])->toMatchArray(['id' => 'rug', 'width' => 2, 'height' => 2, 'issue' => null]);
+    $layer = $preview['authoring'][1]['layers'][0];
+    $session->applyDatabaseRecord('tilesets', 0, $layer['key'], "0 5\n8 9");
+    $repaired = $session->readTilesetPreview(0);
+    expect($repaired['issue'])->toBeNull()
+        ->and($repaired['pieces'][0]['connections'])->toHaveCount(16)
+        ->and($repaired['pieces'][0]['connections'][0]['operations'])->not->toBeEmpty()
+        ->and($repaired['pieces'][1]['connections'])->toBe([]);
+});
+
+it('assigns sheet identities through generic record edits with undo redo and source preserving save reload', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece(),
+        'rug' => ['name' => 'Rug', 'layer' => 'fixtures', 'glyphs' => ['  ', '  '],
+            'tiles' => ['rugs' => ['1 2', '3 4'], 'details' => ['0 0', '0 5']]]]);
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $original = str_replace("readTilesetPreview(0)['authoring'];
+    $session->applyDatabaseRecord('tilesets', 0, $authors[1]['layers'][0]['key'], "0 17\n2816 2864");
+    $session->applyDatabaseRecord('tilesets', 0, $authors[0]['layers'][1]['key'], 'horizontal: 5, vertical: 42, corner: 7');
+    $session->undo();
+    expect((array) $session->readTilesetPreview(0)['authoring'][0]['layers'][1]['shapes'])
+        ->toBe(['horizontal' => '5', 'vertical' => '6', 'corner' => '7']);
+    $session->undo();
+    $session->saveDatabase('tilesets');
+    expect(file_get_contents($path))->toBe($original);
+    $session->redo();
+    $session->redo();
+    $session->saveDatabase('tilesets');
+    $saved = (string) file_get_contents($path);
+    $tileset = Tileset::load($root . '/assets', 'home');
+    expect($saved)->toContain('// Preserve authored notes.', '-')
+        ->and($tileset->pieces['rug']->tiles)->toBe(['rugs' => [['0', '17'], ['2816', '2864']], 'details' => [['0', '0'], ['0', '5']]])
+        ->and($tileset->pieces['rug']->glyphs)->toBe([[' ', ' '], [' ', ' ']])
+        ->and($tileset->pieces['fence']->shapeTiles['trim'])->toBe(['horizontal' => '5', 'vertical' => '42', 'corner' => '7'])
+        ->and($tileset->pieces['fence']->shapeTiles['walls'])->toBe(array_fill_keys(TilesetPiece::LINE_SHAPES, '2816'))
+        ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapBefore);
+    $reloaded = EditorSession::open($root)->readTilesetPreview(0);
+    expect($reloaded['issue'])->toBeNull()
+        ->and($reloaded['authoring'][0]['id'])->toBe('fence')
+        ->and($reloaded['pieces'][0]['connections'][5]['shape'])->toBe('vertical');
+});
+
+it('creates a piece and graphical layer through the existing add paths without deriving gameplay from artwork', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root);
+    $session = EditorSession::open($root);
+    $session->addDatabaseItem('tilesets', 0, ['frame' => []]);
+    $piece = $session->readTilesetPreview(0)['authoring'][0];
+    $session->applyDatabaseRecord('tilesets', 0, ['frame' => [], 'field' => 'piece0Id'], 'mat');
+    $session->applyDatabaseRecord('tilesets', 0, ['frame' => [], 'field' => 'piece0Glyphs'], "  \n  ");
+    $session->addDatabaseItem('tilesets', 0, $piece['key'], true);
+    $layer = $session->readTilesetPreview(0)['authoring'][0]['layers'][0];
+    $session->applyDatabaseRecord('tilesets', 0, $layer['key'], "1 2\n9 10");
+    $session->saveDatabase('tilesets');
+    $piece = Tileset::load($root . '/assets', 'home')->pieces['mat'];
+    expect($piece->glyphs)->toBe([[' ', ' '], [' ', ' ']])
+        ->and($piece->tiles)->toBe(['tiles' => [['1', '2'], ['9', '10']]])
+        ->and($piece->layer)->toBe('fixtures');
+});
+
+it('refuses unsupported tileset source edits without rewriting authored code', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece()]);
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $source = (string) file_get_contents($path);
+    $source = str_replace("'walls' => '2816'", "'walls' => (string) (2800 + 16)", $source);
+    file_put_contents($path, $source);
+    $session = EditorSession::open($root);
+    $key = $session->readTilesetPreview(0)['authoring'][0]['layers'][0]['key'];
+    expect($key)->toBeArray()->and($session->readTilesetPreview(0)['issue'])->toBeNull();
+    expect(fn() => $session->applyDatabaseRecord('tilesets', 0, $key, '2864'))->toThrow(SessionRefusal::class)
+        ->and(file_get_contents($path))->toBe($source)
+        ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse();
+});
+
+it('reports unreadable connected artwork while preserving every glyph topology and authoring target', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: ['fence' => connectedAuthoringPiece()]);
+    unlink($root . '/assets/Graphics/Tilesets/Home_A2.png');
+    $preview = EditorSession::open($root)->readTilesetPreview(0);
+    expect($preview['unreadable'])->not->toBeEmpty()
+        ->and($preview['pieces'][0]['connections'])->toHaveCount(16)
+        ->and($preview['authoring'][0]['layers'][0]['key'])->toBeArray();
+    foreach ($preview['pieces'][0]['connections'] as $connection) {
+        expect($connection['issue'])->not->toBeNull()
+            // The usable B-sheet trim remains visible; a missing A2 sheet must not hide it.
+            ->and($connection['operations'])->not->toBeEmpty()
+            ->and($connection['picture'][2][2]['symbol'])->not->toBe(' ');
+    }
+});
diff --git a/tests/Unit/Session/TilesetOccupancySessionTest.php b/tests/Unit/Session/TilesetOccupancySessionTest.php
new file mode 100644
index 00000000..bb6b1223
--- /dev/null
+++ b/tests/Unit/Session/TilesetOccupancySessionTest.php
@@ -0,0 +1,297 @@
+ 'Synthetic object', 'layer' => 'fixtures', 'glyphs' => ['xx', ' x'],
+        'tiles' => ['objects' => ['1 0', '0 2']]];
+    writeTestTileset($root, pieces: $pieces ?? ['object' => $piece]);
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    file_put_contents($path, str_replace("readTilesetPreview($index)['authoring'], static fn(array $piece): bool => $piece['id'] === $id)
+        ?? throw new RuntimeException("No synthetic piece {$id}.");
+}
+
+it('edits recipes through shared record history and source preserving save undo redo', function () {
+    $old = [[CollisionType::SOLID, null], [CollisionType::NONE, CollisionType::COUNTER]];
+    $root = createFootprintAuthoringProject(['occupancy' => $old]);
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    file_put_contents($path, str_replace("'occupancy' =>", "// Preserve this interior footprint note.\n      'occupancy' =>", (string) file_get_contents($path)));
+    $mapBefore = sourceHashTree($root . '/assets/Maps');
+    $session = EditorSession::open($root);
+    $record = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::all(true)['tilesets'], graphical: true)->getRecordByIndex(0);
+    expect($record->isEditable())->toBeTrue($record->getReadOnlyReason() ?? 'The synthetic source must remain editable.');
+    $piece = readFootprintAuthoringPiece($session);
+    expect($piece)->toMatchArray(['width' => 2, 'height' => 2, 'occupancy' => [[1, null], [0, 10]],
+        'occupancyDeclared' => true, 'occupancyIssue' => null])
+        ->and($piece['occupancyKey'])->toBeArray();
+    $row = array_find($session->readDatabaseRecord('tilesets', 0)['rows'], static fn(array $row): bool =>
+        ($row['key']['field'] ?? null) === 'piece0Occupancy');
+    expect($piece['occupancyKey'])->toBe($row['key']);
+    $mask = [[null, 0], [10, 1]];
+    expect($session->setTilesetOccupancy(0, 'home', 'object', $piece['occupancy'], $mask))
+        ->toMatchArray(['changed' => true, 'occupancy' => $mask])
+        ->and($session->setTilesetOccupancy(0, 'home', 'object', $mask, $mask)['changed'])->toBeFalse();
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)
+        ->toBe([[null, CollisionType::NONE], [CollisionType::COUNTER, CollisionType::SOLID]])
+        ->and(file_get_contents($path))->toContain('// Keep the author', '// Preserve this interior footprint note.', 'CollisionType::COUNTER', 'xx');
+    expect($session->undo())->toMatchArray(['databases' => ['tilesets'], 'maps' => []]);
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($old)
+        ->and(file_get_contents($path))->toContain('// Keep the author', '// Preserve this interior footprint note.');
+    $session->redo();
+    $session->saveDatabase('tilesets');
+    expect(EditorSession::open($root)->readTilesetPreview(0)['authoring'][0]['occupancy'])->toBe($mask)
+        ->and(sourceHashTree($root . '/assets/Maps'))->toBe($mapBefore);
+});
+
+it('adds an all null recipe and removes the field rather than persisting explicit null', function () {
+    $root = createFootprintAuthoringProject();
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $session = EditorSession::open($root);
+    $mask = [[null, null], [null, null]];
+    expect(readFootprintAuthoringPiece($session))->toMatchArray(['occupancy' => null, 'occupancyDeclared' => false]);
+    $session->setTilesetOccupancy(0, 'home', 'object', null, $mask);
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($mask)
+        ->and(file_get_contents($path))->toContain("'occupancy'");
+    $session->setTilesetOccupancy(0, 'home', 'object', $mask, null);
+    $session->saveDatabase('tilesets');
+    expect(file_get_contents($path))->not->toContain("'occupancy'")
+        ->and(readFootprintAuthoringPiece($session))->toMatchArray(['occupancy' => null, 'occupancyDeclared' => false]);
+    $session->undo();
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($mask);
+    $session->redo();
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBeNull();
+});
+
+it('retains glyph geometry and a repair key for a wrong size recipe independently of invalid graphics', function () {
+    $root = createFootprintAuthoringProject(['occupancy' => [[CollisionType::SOLID]], 'tiles' => ['objects' => ['invalid']], 'effect' => '../invalid']);
+    $session = EditorSession::open($root);
+    $piece = readFootprintAuthoringPiece($session);
+    expect($piece)->toMatchArray(['width' => 2, 'height' => 2, 'issue' => null, 'occupancy' => [[1]]])
+        ->and($piece['occupancyIssue'])->toContain('occupancy must have 2 rows')
+        ->and($piece['occupancyKey'])->toBeArray();
+    expect($session->setTilesetOccupancy(0, 'home', 'object', [[1]], [[null, 0], [1, 10]])['changed'])->toBeTrue()
+        ->and(readFootprintAuthoringPiece($session)['occupancyIssue'])->toBeNull();
+});
+
+it('exposes invalid recipe diagnostics without inventing a mask and permits explicit record row repair', function (mixed $bad) {
+    $root = createFootprintAuthoringProject(['occupancy' => $bad]);
+    $session = EditorSession::open($root);
+    $piece = readFootprintAuthoringPiece($session);
+    expect($piece)->toMatchArray(['width' => 2, 'height' => 2, 'issue' => null, 'occupancy' => null, 'occupancyDeclared' => true])
+        ->and($piece['occupancyIssue'])->toContain('occupancy')
+        ->and($piece['occupancyKey'])->toBeArray()
+        ->and(fn() => $session->setTilesetOccupancy(0, 'home', 'object', null, [[1, null], [0, 10]]))
+        ->toThrow(SessionRefusal::class, 'cannot be compared safely');
+    expect($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse();
+    $session->applyDatabaseRecord('tilesets', 0, $piece['occupancyKey'], '[[1,null],[0,10]]');
+    $session->saveDatabase('tilesets');
+    expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)
+        ->toBe([[CollisionType::SOLID, null], [CollisionType::NONE, CollisionType::COUNTER]])
+        ->and(readFootprintAuthoringPiece($session)['occupancyIssue'])->toBeNull();
+})->with([
+    'explicit null' => [null], 'scalar' => ['bad'], 'non list' => [['row' => [CollisionType::SOLID]]],
+    'integer instead of enum' => [[[1, null], [null, null]]],
+    'pass through' => [[[CollisionType::PASS_THROUGH, null], [null, null]]],
+    'unsupported enum' => [[[SourceWriterUnit::FIRST, null], [null, null]]],
+]);
+
+it('uses one shared connected recipe and preserves connected shapes when editing it', function () {
+    $root = createFootprintAuthoringProject(['connects' => 'lines', 'glyphs' => [
+        'horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['objects' => '12']]);
+    $session = EditorSession::open($root);
+    $session->setTilesetOccupancy(0, 'home', 'object', null, [[10]]);
+    $session->saveDatabase('tilesets');
+    $piece = Tileset::load($root . '/assets', 'home')->pieces['object'];
+    expect($piece->occupancy)->toBe([[CollisionType::COUNTER]])
+        ->and([$piece->width, $piece->height])->toBe([1, 1])
+        ->and($piece->getSourceShapeGrid())->toBe(['horizontal' => '-', 'vertical' => '|', 'corner' => '+'])
+        ->and(fn() => $session->setTilesetOccupancy(0, 'home', 'object', [[10]], [[10, 10]]))
+        ->toThrow(SessionRefusal::class, 'must be 1 cells wide');
+});
+
+it('refuses stale record identity piece identity and expected recipe without any mutation', function () {
+    $root = createFootprintAuthoringProject(['occupancy' => [[CollisionType::NONE, null], [null, null]]]);
+    $session = EditorSession::open($root);
+    $before = sourceHashTree($root);
+    foreach ([
+        [0, 'other', 'object', [[0, null], [null, null]], [[1, null], [null, null]]],
+        [0, 'home', 'missing', [[0, null], [null, null]], [[1, null], [null, null]]],
+        [0, 'home', 'object', null, [[1, null], [null, null]]],
+        [0, 'home', 'object', [[1, null], [null, null]], [[1, null], [null, null]]],
+    ] as $args) {
+        expect(fn() => $session->setTilesetOccupancy(...$args))->toThrow(SessionRefusal::class)
+            ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse()
+            ->and(sourceHashTree($root))->toBe($before);
+    }
+    $session->setTilesetOccupancy(0, 'home', 'object', [[0, null], [null, null]], [[1, null], [null, null]]);
+    expect(fn() => $session->setTilesetOccupancy(0, 'home', 'object', [[0, null], [null, null]], [[10, null], [null, null]]))
+        ->toThrow(SessionRefusal::class, 'changed')
+        ->and(readFootprintAuthoringPiece($session)['occupancy'])->toBe([[1, null], [null, null]]);
+});
+
+it('resolves the fresh occupancy row by stable piece id after another piece is removed', function () {
+    $root = createFootprintAuthoringProject(pieces: [
+        'first' => ['name' => 'First', 'layer' => 'fixtures', 'glyphs' => ['x']],
+        'object' => ['name' => 'Target', 'layer' => 'fixtures', 'glyphs' => ['x']],
+    ]);
+    $session = EditorSession::open($root);
+    expect(readFootprintAuthoringPiece($session)['occupancyKey']['field'])->toBe('piece1Occupancy');
+    $session->removeDatabaseItem('tilesets', 0, ['field' => 'piece0Name', 'frame' => []]);
+    expect($session->setTilesetOccupancy(0, 'home', 'object', null, [[1]])['changed'])->toBeTrue()
+        ->and(readFootprintAuthoringPiece($session)['occupancyKey']['field'])->toBe('piece0Occupancy');
+    $session->saveDatabase('tilesets');
+    expect(array_keys(Tileset::load($root . '/assets', 'home')->pieces))->toBe(['object'])
+        ->and(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe([[CollisionType::SOLID]]);
+});
+
+it('refuses unsupported computed recipe source before any record or history mutation', function () {
+    $root = createFootprintAuthoringProject();
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $source = <<<'PHP'
+     'Home', 'sheets' => ['B' => 'Graphics/Tilesets/Home_B.png'], 'pieces' => [
+        'object' => ['name' => 'Computed object', 'layer' => 'fixtures', 'glyphs' => ['xx', 'xx'],
+            'occupancy' => (static fn(): array => [[CollisionType::NONE, null], [null, null]])()],
+    ]];
+    PHP;
+    file_put_contents($path, $source);
+    $session = EditorSession::open($root);
+    $before = sourceHashTree($root);
+    expect(fn() => $session->setTilesetOccupancy(0, 'home', 'object', [[0, null], [null, null]], [[1, null], [null, null]]))
+        ->toThrow(SessionRefusal::class)
+        ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse()
+        ->and(readFootprintAuthoringPiece($session)['occupancy'])->toBe([[0, null], [null, null]])
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and($session->undo())->toMatchArray(['label' => null, 'maps' => [], 'databases' => []]);
+});
+
+it('omits the recipe field from TUI schemas while preserving authored recipes during unrelated terminal edits', function () {
+    $mask = [[CollisionType::SOLID, null], [CollisionType::NONE, CollisionType::COUNTER]];
+    $root = createFootprintAuthoringProject(['occupancy' => $mask]);
+    $terminal = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::all(false)['tilesets']);
+    $graphical = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::all(true)['tilesets'], graphical: true);
+    expect(array_column($terminal->getSettingsFields(0), 'field'))->not->toContain('piece0Occupancy')
+        ->and(array_column($graphical->getSettingsFields(0), 'field'))->toContain('piece0Occupancy');
+    $terminal->setField(0, 'name', 'Updated name');
+    $terminal->save();
+    expect(Tileset::load($root . '/assets', 'home')->pieces['object']->occupancy)->toBe($mask);
+});
+
+it('refuses invalid recipe geometry at the dedicated Engine validation boundary without partial edits', function (array $mask) {
+    $root = createFootprintAuthoringProject();
+    $session = EditorSession::open($root);
+    $before = sourceHashTree($root);
+    expect(fn() => $session->setTilesetOccupancy(0, 'home', 'object', null, $mask))->toThrow(SessionRefusal::class)
+        ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse()
+        ->and(sourceHashTree($root))->toBe($before);
+})->with(['empty' => [[]], 'short height' => [[[1, null]]], 'ragged' => [[[1], [null, null]]],
+    'wide' => [[[1, null, null], [null, null, null]]]]);
+
+it('validates recipe RPC identities expected baseline and JSON list types before editing', function () {
+    $root = createFootprintAuthoringProject();
+    $input = fopen('php://memory', 'r');
+    $output = fopen('php://memory', 'w+');
+    $diagnostics = fopen('php://memory', 'w+');
+    $host = new SessionHost($input, $output, $diagnostics);
+    $request = static fn(string $method, array $params): array => $host->handle(json_encode(
+        ['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    try {
+        expect($request('hello', ['project' => $root, 'protocol' => SessionHost::PROTOCOL]))->toHaveKey('result');
+        $params = ['index' => 0, 'tileset' => 'home', 'piece' => 'object', 'expected' => null, 'value' => [[1, null], [0, 10]]];
+        foreach ([['index' => '0'], ['tileset' => 'Home'], ['piece' => ''], ['value' => 'bad'], ['value' => 1],
+            ['value' => [[true, null], [0, 10]]], ['value' => [['1', null], [0, 10]]], ['value' => [[1.0, null], [0, 10]]],
+            ['value' => [[9, null], [0, 10]]], ['value' => [[999, null], [0, 10]]], ['value' => (object) ['0' => [1, null], '1' => [0, 10]]],
+            ['value' => [(object) ['0' => 1, '1' => null], [0, 10]]], ['expected' => [[9, null], [0, 10]]]] as $invalid) {
+            $wire = json_encode(['id' => 1, 'method' => 'tileset.setOccupancy', 'params' => $invalid + $params], JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION);
+            expect($host->handle($wire)['error']['kind'])->toBe('request');
+        }
+        foreach (['index', 'tileset', 'piece', 'expected', 'value'] as $missing) {
+            $invalid = $params;
+            unset($invalid[$missing]);
+            expect($request('tileset.setOccupancy', $invalid)['error']['kind'])->toBe('request');
+        }
+        expect($request('database.records', ['category' => 'tilesets'])['result']['dirty'])->toBeFalse()
+            ->and($request('tileset.setOccupancy', $params)['result'])->toMatchArray(['changed' => true, 'occupancy' => $params['value']])
+            ->and($request('tileset.setOccupancy', ['expected' => $params['value']] + $params)['result'])
+            ->toMatchArray(['changed' => false, 'occupancy' => $params['value']])
+            ->and($request('tileset.setOccupancy', $params)['error']['kind'])->toBe('refusal');
+        $params['expected'] = $params['value'];
+        $params['value'] = null;
+        expect($request('tileset.setOccupancy', $params)['result']['occupancy'])->toBeNull();
+        rewind($diagnostics);
+        expect(stream_get_contents($diagnostics))->toBe('');
+    } finally {
+        $host->run();
+        fclose($input);
+        fclose($output);
+        fclose($diagnostics);
+    }
+});
+
+it('returns a recipe RPC refusal without successful occupancy metadata when nonliteral authored source cannot be edited', function () {
+    $root = createFootprintAuthoringProject();
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    file_put_contents($path, <<<'PHP'
+     [
+        // Preserve this nonliteral, author-owned source computation.
+        'name' => 'Home', 'sheets' => ['B' => 'Graphics/Tilesets/Home_B.png'], 'pieces' => [
+            'object' => ['name' => 'Computed object', 'layer' => 'fixtures', 'glyphs' => ['xx', 'xx'],
+                'occupancy' => [[CollisionType::NONE, null], [null, null]]],
+        ],
+    ])();
+    PHP);
+    $input = fopen('php://memory', 'r');
+    $output = fopen('php://memory', 'w+');
+    $diagnostics = fopen('php://memory', 'w+');
+    $host = new SessionHost($input, $output, $diagnostics);
+    $request = static fn(string $method, array $params): array => $host->handle(json_encode(
+        ['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    $before = sourceHashTree($root);
+    try {
+        $request('hello', ['project' => $root, 'protocol' => SessionHost::PROTOCOL]);
+        $reply = $request('tileset.setOccupancy', ['index' => 0, 'tileset' => 'home', 'piece' => 'object',
+            'expected' => [[0, null], [null, null]], 'value' => [[1, null], [null, null]]]);
+        expect($reply)->not->toHaveKey('result')
+            ->and($reply['error']['kind'])->toBe('refusal')
+            ->and($request('database.records', ['category' => 'tilesets'])['result']['dirty'])->toBeFalse()
+            ->and($request('history.undo', [])['result'])->toMatchArray(['label' => null, 'maps' => [], 'databases' => []])
+            ->and(sourceHashTree($root))->toBe($before);
+        rewind($diagnostics);
+        expect(stream_get_contents($diagnostics))->toBe('');
+    } finally {
+        $host->run();
+        fclose($input);
+        fclose($output);
+        fclose($diagnostics);
+    }
+});
diff --git a/tests/Unit/Session/TilesetPreviewSessionTest.php b/tests/Unit/Session/TilesetPreviewSessionTest.php
new file mode 100644
index 00000000..ed61bce6
--- /dev/null
+++ b/tests/Unit/Session/TilesetPreviewSessionTest.php
@@ -0,0 +1,93 @@
+ The tileset record's row with the label. */
+function findTilesetRow(EditorSession $session, string $label): array
+{
+    return array_find($session->readDatabaseRecord('tilesets', 0)['rows'], static fn(array $row): bool => $row['label'] === $label);
+}
+
+it('lays out a tileset record\'s sheets as the tile palette does, with its marks and pieces drawn', function () {
+    $session = tilesetPreviewSession(
+        pieces: ['stool' => ['name' => 'Stool', 'layer' => 'buildings', 'glyphs' => ['o'], 'tiles' => ['furniture' => ['5']]]],
+        missingArt: 255,
+    );
+    $preview = $session->readTilesetPreview(0);
+
+    expect($preview['issue'])->toBeNull()
+        ->and($preview['unreadable'])->toBe([])
+        ->and(array_column($preview['palette']['tabs'], 'name'))->toBe(['A', 'B'])
+        ->and($preview['palette']['tabs'][1]['operations'])->not->toBeEmpty()
+        ->and($preview['missingArt'])->toBe(255)
+        // Only the A2 autotiles shown can be tables.
+        ->and($preview['tableTiles'])->not->toBeEmpty()
+        ->and(array_filter($preview['tableTiles'], static fn(int $tile): bool => $tile < 2816 || $tile >= 4352))->toBe([])
+        ->and($preview['pieces'][0])->toMatchArray(['id' => 'stool', 'name' => 'Stool', 'connected' => false, 'tileLayers' => ['furniture']])
+        ->and($preview['pieces'][0]['operations'])->not->toBeEmpty();
+});
+
+it('follows unsaved edits, and still shows the sheets of a tileset the Engine would refuse, saying why', function () {
+    $session = tilesetPreviewSession();
+    $session->applyDatabaseRecord('tilesets', 0, findTilesetRow($session, 'Sheet A2')['key'], '');
+
+    expect(array_column($session->readTilesetPreview(0)['palette']['tabs'], 'name'))->toBe(['B']);
+
+    $session->applyDatabaseRecord('tilesets', 0, findTilesetRow($session, 'Missing Art Tile')['key'], '3000');
+    $refused = $session->readTilesetPreview(0);
+    expect($refused['issue'])->toContain('missingArt')
+        ->and(array_column($refused['palette']['tabs'], 'name'))->toBe(['B']);
+});
+
+it('says how a picture reference\'s value is shown, and nothing for a reference that is not a picture', function () {
+    $session = tilesetPreviewSession();
+
+    expect(findTilesetRow($session, 'Sheet B')['media'])->toBe(['kind' => 'image', 'root' => 'assets'])
+        ->and(findTilesetRow($session, 'Sheet B')['value'])->toBe('Graphics/Tilesets/Home_B.png')
+        ->and(array_key_exists('media', findTilesetRow($session, 'Name')))->toBeFalse();
+});
+
+it('marks a tile above characters or as a table with a click, and a second click takes the mark away, each one undo step', function () {
+    $session = tilesetPreviewSession();
+
+    // Any shape of an autotile marks its kind, as the Engine reads the list.
+    expect($session->toggleTilesetMark(0, 'above', 2816 + 5))->toMatchArray(['changed' => true, 'marked' => true])
+        ->and($session->readTilesetPreview(0)['above'])->toBe([2816])
+        ->and(findTilesetRow($session, 'Above Characters')['value'])->toBe('2816');
+    $session->toggleTilesetMark(0, 'above', 7);
+    $session->toggleTilesetMark(0, 'tables', 2816);
+    expect($session->readTilesetPreview(0))->toMatchArray(['above' => [2816, 7], 'tables' => [2816], 'issue' => null]);
+
+    expect($session->toggleTilesetMark(0, 'above', 2816))->toMatchArray(['marked' => false])
+        ->and($session->readTilesetPreview(0)['above'])->toBe([7]);
+    $session->undo();
+    expect($session->readTilesetPreview(0)['above'])->toBe([2816, 7])
+        ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeTrue();
+});
+
+it('refuses a mark the tile cannot take, changing nothing', function () {
+    $session = tilesetPreviewSession();
+
+    expect(fn() => $session->toggleTilesetMark(0, 'tables', 7))->toThrow(SessionRefusal::class, 'Only A2 autotiles can be tables')
+        ->and(fn() => $session->toggleTilesetMark(0, 'above', 0))->toThrow(SessionRefusal::class, 'cannot be marked')
+        ->and(fn() => $session->toggleTilesetMark(0, 'counter', 7))->toThrow(SessionRefusal::class, 'not a tile mark')
+        ->and($session->listDatabaseRecords('tilesets')['dirty'])->toBeFalse();
+});
diff --git a/tests/Unit/Session/TreeBrushTest.php b/tests/Unit/Session/TreeBrushTest.php
new file mode 100644
index 00000000..57870fc3
--- /dev/null
+++ b/tests/Unit/Session/TreeBrushTest.php
@@ -0,0 +1,255 @@
+ Every solid cell inside the border. */
+function listInnerSolidCells(\Ichiloto\Editor\ProjectMap $map): array
+{
+    $cells = [];
+    foreach ($map->getResolvedCollisionMap() as $y => $row) {
+        foreach ($row as $x => $value) {
+            if ($x > 0 && $y > 0 && $x < 11 && $y < 7 && $value !== CollisionType::NONE->value) {
+                $cells[] = [$x, $y];
+            }
+        }
+    }
+
+    return $cells;
+}
+
+/** Whether every walkable cell reaches every other one, moving in four directions. */
+function isWalkableConnected(\Ichiloto\Editor\ProjectMap $map): bool
+{
+    $grid = $map->getResolvedCollisionMap();
+    $open = [];
+    foreach ($grid as $y => $row) {
+        foreach ($row as $x => $value) {
+            if ($value === CollisionType::NONE->value) {
+                $open["{$x},{$y}"] = [$x, $y];
+            }
+        }
+    }
+    $start = array_key_first($open);
+    $seen = [$start => true];
+    $queue = [$open[$start]];
+    while ($queue !== []) {
+        [$x, $y] = array_shift($queue);
+        foreach ([[1, 0], [-1, 0], [0, 1], [0, -1]] as [$dx, $dy]) {
+            $key = ($x + $dx) . ',' . ($y + $dy);
+            if (isset($open[$key]) && ! isset($seen[$key])) {
+                $seen[$key] = true;
+                $queue[] = $open[$key];
+            }
+        }
+    }
+
+    return count($seen) === count($open);
+}
+
+it('offers only placeable images with a trunk stamp as tree types', function () {
+    [$session] = openTreeBrushSession();
+
+    expect($session->listTreeTypes('test-map')['types'])->toBe(['oak' => 'Oak', 'pair' => 'Pair']);
+});
+
+it('plants trees over the region with solid trunks, and draws the same forest again as nothing new', function () {
+    [$session, $map] = openTreeBrushSession();
+    $revision = $session->readMap('test-map')['revision'];
+
+    $drawn = $session->drawTrees('test-map', $revision, listRectangleCells(1, 1, 10, 6), ['oak', 'pair']);
+    $objects = $session->readMap('test-map')['worldObjects'];
+    $trunks = listInnerSolidCells($map);
+
+    expect($drawn['planted'])->toBeGreaterThan(2)
+        ->and(count($objects))->toBe($drawn['planted'])
+        ->and(array_unique(array_column($objects, 'resource')))->toEqualCanonicalizing(['oak', 'pair'])
+        // Only trunks and the inner wall are solid; no trunk on the event, the NPC or the wall's gap.
+        ->and($trunks)->not->toContain([9, 2], [2, 2], [6, 6])
+        ->and(isWalkableConnected($map))->toBeTrue();
+    foreach ($objects as $object) {
+        expect($map->getResolvedCollisionMap()[$object['anchor']['y']][$object['anchor']['x']])->toBe(CollisionType::SOLID->value);
+    }
+
+    expect($session->drawTrees('test-map', $drawn['revision'], listRectangleCells(1, 1, 10, 6), ['oak', 'pair'])['planted'])->toBe(0);
+});
+
+it('grows the same forest from the same region on another copy of the map', function () {
+    $draw = static function (): array {
+        [$session] = openTreeBrushSession();
+        $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak', 'pair']);
+
+        return array_map(static fn(array $object): array => [$object['resource'], $object['anchor']], $session->readMap('test-map')['worldObjects']);
+    };
+
+    expect($draw())->toBe($draw());
+});
+
+it('draws the trees for the terminal on its own canopy gameplay layer, which the terminal shows', function () {
+    [$session, $map] = openTreeBrushSession();
+    $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(2, 4, 4, 6), ['oak']);
+    $object = $session->readMap('test-map')['worldObjects'][0];
+    [$x, $y] = [$object['anchor']['x'], $object['anchor']['y']];
+    $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy');
+    $terminal = $map->getLayerSet()->getComposedGrid();
+
+    expect($layer['decoration'])->toBeFalse()
+        ->and($map->getLayerSymbol($layer['id'], $x, $y))->toBe('|')
+        ->and([$map->getLayerSymbol($layer['id'], $x - 1, $y - 1), $map->getLayerSymbol($layer['id'], $x, $y - 1), $map->getLayerSymbol($layer['id'], $x + 1, $y - 1)])
+        ->toBe(['(', ')', ')'])
+        ->and($map->getLayerColor($layer['id'], $x, $y - 1))->toBe('bright-green')
+        // What the terminal draws is the tree, over the terrain it stands on.
+        ->and(TerminalText::stripAnsi($terminal[$y][$x]))->toBe('|')
+        ->and(TerminalText::stripAnsi($terminal[$y - 1][$x - 1]))->toBe('(')
+        ->and($map->getLayerSymbol($map->getBaseLayerId(), $x, $y))->toBe('.');
+});
+
+it('has each tree cover exactly the canopy cells it draws, so the graphical field shows its image there', function () {
+    [$session, $map] = openTreeBrushSession();
+    $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak', 'pair']);
+    $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['id'];
+    $drawn = $covered = [];
+    for ($y = 0; $y < 8; $y++) {
+        for ($x = 0; $x < 12; $x++) {
+            if ($map->getLayerSymbol($layer, $x, $y) !== ' ') {
+                $drawn[] = "{$x},{$y}";
+            }
+        }
+    }
+    foreach ($session->readMap('test-map')['worldObjects'] as $object) {
+        expect($object['covers']['layer'])->toBe('canopy')
+            ->and($object['covers']['tileLayers'])->toBe([])
+            ->and($object['covers']['cells'])->toContain([$object['anchor']['x'], $object['anchor']['y']]);
+        foreach ($object['covers']['cells'] as [$x, $y]) {
+            $covered[] = "{$x},{$y}";
+        }
+    }
+
+    // Every drawn cell is covered once, by one tree, and nothing else is.
+    expect($covered)->toHaveCount(count(array_unique($covered)))
+        ->and($covered)->toEqualCanonicalizing($drawn);
+});
+
+it('makes a canopy left as decoration, which the terminal never draws, gameplay on the next drawing', function () {
+    [$session, $map] = openTreeBrushSession();
+    $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']);
+    $expected = $session->readMap('test-map')['worldObjects'];
+    $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['id'];
+    // As the brush once left it: a decoration layer, and trees covering nothing.
+    $map->setMapDataField(['worldObjects'], array_map(static function (array $object): array {
+        unset($object['covers']);
+        return $object;
+    }, $expected));
+    $map->setLayerDecoration($layer, true);
+
+    $again = $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']);
+
+    expect($again['planted'])->toBe(0)
+        ->and(array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['decoration'])->toBeFalse()
+        ->and($session->readMap('test-map')['worldObjects'])->toBe($expected);
+});
+
+it('keeps the only gap through a wall open', function () {
+    [$session, $map] = openTreeBrushSession();
+    $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[6, 6], [5, 6], [7, 6]], ['oak']);
+
+    expect(isWalkableConnected($map))->toBeTrue()
+        ->and($map->getResolvedCollisionMap()[6][6])->toBe(CollisionType::NONE->value);
+});
+
+it('counts grass as walkable ground, so a trunk never closes a path through it', function () {
+    // Two rooms joined only by grass, floor and grass at y 3.
+    $session = EditorSession::open(createTreeBrushProject(true, ['############', '#...###....#', '#...###....#', '#...g.g....#',
+        '#...###....#', '#...###....#', '#...###....#', '############']));
+    $map = getPhysicalOccupancyMap($session);
+
+    $drawn = $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[4, 3], [5, 3], [6, 3]], ['oak']);
+
+    // The floor cell between the grass would cut the rooms apart; the grass itself is no place for a trunk.
+    expect($drawn['planted'])->toBe(0)
+        ->and(array_slice($map->getResolvedCollisionMap()[3], 4, 3))
+        ->toBe([CollisionType::ENCOUNTER->value, CollisionType::NONE->value, CollisionType::ENCOUNTER->value]);
+});
+
+it('erases the trees standing in a region, clearing their trunks and crowns, and undo brings them back', function () {
+    [$session, $map] = openTreeBrushSession();
+    $drawn = $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']);
+    $layer = array_find($map->getLayers(), static fn(array $layer): bool => $layer['name'] === 'canopy')['id'];
+    $before = $session->readMap('test-map')['worldObjects'];
+
+    $erased = $session->drawTrees('test-map', $drawn['revision'], listRectangleCells(1, 1, 5, 6), [], true);
+    $left = $session->readMap('test-map')['worldObjects'];
+
+    expect($erased['removed'])->toBeGreaterThan(0)
+        ->and(array_filter($left, static fn(array $object): bool => $object['anchor']['x'] <= 5))->toBe([])
+        ->and(array_filter(listInnerSolidCells($map), static fn(array $cell): bool => $cell[0] <= 5))->toBe([]);
+    for ($y = 0; $y < 8; $y++) {
+        expect($map->getLayerSymbol($layer, 1, $y))->toBe(' ');
+    }
+
+    $session->undo();
+    expect($session->readMap('test-map')['worldObjects'])->toBe($before);
+});
+
+it('refuses on a map without its own collision, and a drawing past the world-object limit', function () {
+    [$session] = openTreeBrushSession(false);
+    expect(fn() => $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']))
+        ->toThrow(SessionRefusal::class, 'Separate collision first');
+
+    [$session, $map] = openTreeBrushSession();
+    $map->setMapDataField(['worldObjects'], array_map(static fn(int $n): array => ['id' => "stone-{$n}", 'anchor' => ['x' => 1, 'y' => 1],
+        'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null], range(1, 127)));
+    expect(fn() => $session->drawTrees('test-map', $session->readMap('test-map')['revision'], listRectangleCells(1, 1, 10, 6), ['oak']))
+        ->toThrow(SessionRefusal::class, 'only 1 still fit');
+});
+
+it('refuses a type that is not a tree', function () {
+    [$session] = openTreeBrushSession();
+
+    expect(fn() => $session->drawTrees('test-map', $session->readMap('test-map')['revision'], [[3, 3]], ['counter']))
+        ->toThrow(SessionRefusal::class, "'counter' is not a tree type");
+});
+
+it('draws and erases trees over the terminal canvas selection, one undo step each', function () {
+    $editor = deletionEditor(createTreeBrushProject());
+    setEditorProperty($editor, 'focusedPane', 'canvas');
+    $map = static fn() => getEditorProperty($editor, 'workspace')->getMapByIndex(0);
+    expect($map()->mapId)->toBe('test-map');
+    $count = static fn(): int => count($map()->hasMapDataField(['worldObjects']) ? $map()->getMapDataField(['worldObjects']) : []);
+
+    setEditorProperty($editor, 'canvasSelection', ['x' => 1, 'y' => 1, 'width' => 10, 'height' => 6]);
+    openMapLinePaletteItem($editor, 'Map: Draw trees in the selection');
+    $planted = $count();
+    expect($planted)->toBeGreaterThan(2)
+        ->and(getEditorProperty($editor, 'statusMessage'))->toContain("Planted {$planted} trees");
+
+    openMapLinePaletteItem($editor, 'Map: Erase trees in the selection');
+    expect($count())->toBe(0);
+    pressKeys($editor, "\x1a");
+    expect($count())->toBe($planted);
+
+    setEditorProperty($editor, 'canvasSelection', null);
+    openMapLinePaletteItem($editor, 'Map: Draw trees in the selection');
+    expect(getEditorProperty($editor, 'statusMessage'))->toContain('Select a region of the canvas first')
+        ->and($count())->toBe($planted);
+});
diff --git a/tests/Unit/Session/TroopEventPagesSessionTest.php b/tests/Unit/Session/TroopEventPagesSessionTest.php
new file mode 100644
index 00000000..51cc097c
--- /dev/null
+++ b/tests/Unit/Session/TroopEventPagesSessionTest.php
@@ -0,0 +1,108 @@
+ ($row['key']['field'] ?? null) === $field)
+        ?? throw new RuntimeException("No row {$field}: " . implode(', ', array_filter(array_map(
+            static fn(array $row): ?string => $row['key']['field'] ?? null, $record['rows']))));
+}
+
+it('authors a troop event page with conditions and battle commands, saved as the Engine reads it', function () {
+    $root = troopPageProject();
+    $session = EditorSession::open($root);
+    $record = $session->readDatabaseRecord('troops', 0);
+
+    $session->addDatabaseItem('troops', 0, troopEventRow($record, 'pageList')['key']);
+    $record = $session->readDatabaseRecord('troops', 0);
+    $enemy = troopEventRow($record, 'page0Conditionsenemy');
+    expect($enemy['options'] ?? null)->toBe(['None', '1 · Regular Bat', '2 · Regular Bat']);
+
+    $session->applyDatabaseRecord('troops', 0, troopEventRow($record, 'page0Name')['key'], 'Concession');
+    $session->applyDatabaseRecord('troops', 0, $enemy['key'], '2 · Regular Bat');
+    $record = $session->readDatabaseRecord('troops', 0);
+    $session->applyDatabaseRecord('troops', 0, troopEventRow($record, 'page0ConditionsenemyHpPercent')['key'], '25');
+    $session->applyDatabaseRecord('troops', 0, troopEventRow($record, 'page0Conditionsturn')['key'], '0');
+
+    $commands = troopEventRow($session->readDatabaseRecord('troops', 0), 'page0Commands');
+    expect($commands['key']['frame'] ?? $commands['frame'] ?? null)->not->toBeNull();
+    $frame = $commands['frame'] ?? $commands['key']['frame'];
+    expect($frame)->toBe(['events', 0, 'commands']);
+
+    $session->addDatabaseItem('troops', 0, ['frame' => $frame]);
+    $inside = $session->readDatabaseRecord('troops', 0, $frame);
+    $type = troopEventRow($inside, 'command0Type');
+    expect($type['options'])->toContain('concede', 'end_battle', 'text')->not->toContain('transfer', 'shop', 'start_battle');
+
+    $session->applyDatabaseRecord('troops', 0, $type['key'], 'concede');
+    $inside = $session->readDatabaseRecord('troops', 0, $frame);
+    $session->applyDatabaseRecord('troops', 0, troopEventRow($inside, 'command0Enemy')['key'], '2 · Regular Bat');
+    $session->saveDatabase('troops');
+    clearstatcache();
+
+    $data = require $root . '/assets/Data/troops.php';
+    expect($data[0]['events'])->toBe([[
+        'span' => 'battle',
+        'conditions' => ['enemy' => 1, 'enemyHpPercent' => 25],
+        'commands' => [['type' => 'concede', 'enemy' => 1]],
+        'name' => 'Concession',
+    ]]);
+    expect(BattleEventPage::readPages($data[0]['events'], 'saved', 2, ScriptCommandCatalog::createEngineCatalog()))->toHaveCount(1);
+});
+
+it('refuses to save a page the Engine would refuse, saying what to fix', function () {
+    $root = troopPageProject();
+    $session = EditorSession::open($root);
+    $session->addDatabaseItem('troops', 0, troopEventRow($session->readDatabaseRecord('troops', 0), 'pageList')['key']);
+    $record = $session->readDatabaseRecord('troops', 0);
+    // An enemy chosen without its HP share is half a condition.
+    $session->applyDatabaseRecord('troops', 0, troopEventRow($record, 'page0Conditionsenemy')['key'], '1 · Regular Bat');
+    $before = (string) file_get_contents($root . '/assets/Data/troops.php');
+
+    expect(fn() => $session->saveDatabase('troops'))->toThrow('needs both enemy and enemyHpPercent');
+    expect((string) file_get_contents($root . '/assets/Data/troops.php'))->toBe($before);
+});
+
+it('sets a member\'s knockout floor on its own row', function () {
+    $root = troopPageProject();
+    $session = EditorSession::open($root);
+    $session->applyDatabaseRecord('troops', 0, troopEventRow($session->readDatabaseRecord('troops', 0), 'member0KnockoutFloorPercent')['key'], '25');
+    $session->saveDatabase('troops');
+
+    expect((require $root . '/assets/Data/troops.php')[0]['enemies'][0]['knockoutFloorPercent'])->toBe(25);
+});
+
+it('keeps field-only commands out of field scripts and battle-only ones out of them too', function () {
+    $types = Ichiloto\Editor\Database\RecordSchemaCatalog::getEventCommandTypes();
+    $battle = Ichiloto\Editor\Database\RecordSchemaCatalog::getEventCommandTypes(Ichiloto\Engine\Events\Interpreter\EventExecutionScope::BATTLE);
+
+    expect($types)->toContain('transfer', 'shop')->not->toContain('concede', 'end_battle')
+        ->and($battle)->toContain('concede', 'end_battle', 'branch')->not->toContain('transfer', 'shop', 'move_route');
+});
+
+it('keeps event pages naming the same members when a member is added or removed, and refuses removing a named one', function () {
+    $root = troopPageProject();
+    $troops = $root . '/assets/Data/troops.php';
+    file_put_contents($troops, str_replace("'name' => 'Pair',", "'name' => 'Pair',\n        'events' => [['conditions' => ['enemy' => 1, 'enemyHpPercent' => 25], 'commands' => [['type' => 'branch', 'conditions' => [], 'then' => [['type' => 'concede', 'enemy' => 1]]]]]],", (string) file_get_contents($troops)));
+    $session = EditorSession::open($root);
+    expect(fn() => $session->removeTroopMember(0, 1))->toThrow('names member 2')
+        ->and(fn() => $session->clearTroopMembers(0))->toThrow('names member 2');
+
+    $session->removeTroopMember(0, 0);
+    $session->saveDatabase('troops');
+    clearstatcache();
+    $page = (require $troops)[0]['events'][0];
+    expect($page['conditions']['enemy'])->toBe(0)->and($page['commands'][0]['then'][0]['enemy'])->toBe(0);
+
+    $session->undo();
+    $session->saveDatabase('troops');
+    clearstatcache();
+    $page = (require $troops)[0]['events'][0];
+    expect($page['conditions']['enemy'])->toBe(1)->and($page['commands'][0]['then'][0]['enemy'])->toBe(1);
+});
diff --git a/tests/Unit/Session/TroopFormationSessionTest.php b/tests/Unit/Session/TroopFormationSessionTest.php
new file mode 100644
index 00000000..1c1a18b8
--- /dev/null
+++ b/tests/Unit/Session/TroopFormationSessionTest.php
@@ -0,0 +1,145 @@
+readTroopFormation(0);
+    $leader = $formation['members'][0];
+
+    expect($formation['canvas'])->toBe(['width' => 1350, 'height' => 720])
+        ->and($formation['arenas'])->toBe([['id' => 'arena.road', 'name' => 'Road'], ['id' => 'arena.cave', 'name' => 'Cave']])
+        ->and($formation['arena'])->toBe('arena.cave')
+        ->and($formation['backgrounds'])->toHaveCount(1)
+        ->and($formation['backgrounds'][0])->toMatchArray(['asset' => 'Graphics/Battlebacks/Cave.png'])
+        // Feet may stand from the canvas top to the battle UI's top: nothing declares a ground.
+        ->and($formation['ground'])->toBe(['top' => 0.0, 'bottom' => 600.0, 'perspective' => 1.0, 'partyCompression' => 1.0, 'enemyCompression' => 1.0])
+        // A party of one stands in the formation's front slot, named for the starting party.
+        ->and($formation['party'])->toHaveCount(1)
+        ->and($formation['party'][0])->toMatchArray(['name' => 'Kaelion', 'ground' => ['x' => 1180.0, 'y' => 560.0]])
+        // A placed member stands at its place's depth on the ground; without art it is drawn by its bounds alone.
+        ->and($leader['placement'])->toBe(['x' => 260, 'depth' => 300, 'width' => 275, 'height' => 190])
+        ->and($leader['place'])->toBe(['x' => 260.0, 'depth' => 300.0])
+        ->and($leader['battler'])->toMatchArray(['ground' => ['x' => 260.0, 'y' => 300.0], 'image' => null, 'bodySpan' => null])
+        ->and($leader['battler']['diagnostics'])->toBe(['Base battler artwork unavailable: Regular Bat'])
+        // A member nobody placed is arranged by the battle beside the placed one, on the ground.
+        ->and($formation['members'][1])->toMatchArray(['enemy' => 'Regular Bat', 'placement' => null])
+        ->and($formation['members'][1]['battler']['ground']['y'])->toBeGreaterThan(0.0)->toBeLessThanOrEqual(600.0)
+        // The arena is only previewed: choosing another writes nothing; a background with no file is left out.
+        ->and($session->readTroopFormation(0, 'arena.road')['backgrounds'])->toBe([])
+        ->and($session->readTroopFormation(0, 'arena.road')['arena'])->toBe('arena.road')
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and(fn() => $session->readTroopFormation(0, 'arena.moon'))->toThrow(SessionRefusal::class, 'arena.moon');
+});
+
+it('sends the picture the battle draws at rest, for the frontend to paint with the renderer itself', function () {
+    $session = EditorSession::open(troopFormationProject());
+    $picture = $session->readTroopFormation(0)['picture'];
+
+    // The cave's background as the arena places it; the members have no art yet, so the battle draws only their names.
+    expect([$picture['width'], $picture['height']])->toBe([1350, 720])
+        ->and(array_column($picture['images'], 'asset'))->toBe(['Graphics/Battlebacks/Cave.png'])
+        ->and($picture['images'][0]['destination'])->toEqual(['x' => 0, 'y' => 0, 'width' => 1350, 'height' => 720])
+        // An arena whose background file is missing draws nothing behind the battlers, as in battle.
+        ->and($session->readTroopFormation(0, 'arena.road')['picture']['images'])->toBe([]);
+});
+
+it('moves a member by its battle placement as one undo step, keeping the troop file\'s comments and the terminal position', function () {
+    $root = troopFormationProject();
+    $session = EditorSession::open($root);
+    $read = $session->readDatabaseRecord('troops', 0);
+    $placement = array_find($read['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'member0GraphicalPlacement');
+
+    expect($placement)->toMatchArray(['kind' => 'text', 'value' => '260, 300, 275, 190']);
+    $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330, 275, 190');
+    expect($session->readTroopFormation(0)['members'][0]['placement'])->toBe(['x' => 410, 'depth' => 330, 'width' => 275, 'height' => 190])
+        ->and(fn() => $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330'))->toThrow(SessionRefusal::class, 'four numbers');
+
+    $session->saveDatabase('troops');
+    $written = (string) file_get_contents($root . '/assets/Data/troops.php');
+    expect($written)->toContain('// The leader, placed for the graphical battle.', "'position' => [15, 7]", "'x' => 410", "'depth' => 330")
+        ->and($session->undo()['label'] ?? null)->not->toBeNull()
+        ->and($session->readTroopFormation(0)['members'][0]['placement']['x'])->toBe(260);
+});
+
+it('keeps a member placement\'s display scale through an edit, and refuses one the battle would not accept', function () {
+    $root = troopFormationProject();
+    $troops = $root . '/assets/Data/troops.php';
+    file_put_contents($troops, str_replace("'height' => 190]]", "'height' => 190, 'displayScale' => 1.1]]", (string) file_get_contents($troops)));
+    $session = EditorSession::open($root);
+    $placement = array_find($session->readDatabaseRecord('troops', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'member0GraphicalPlacement');
+
+    expect($placement['value'])->toBe('260, 300, 275, 190, 1.1');
+    $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330, 275, 190, 1.1');
+    expect($session->readTroopFormation(0)['members'][0]['placement'])->toBe(['x' => 410, 'depth' => 330, 'width' => 275, 'height' => 190, 'displayScale' => 1.1])
+        ->and(fn() => $session->applyDatabaseRecord('troops', 0, $placement['key'], '410, 330, 275, 190, 0'))->toThrow(SessionRefusal::class);
+
+    $session->saveDatabase('troops');
+    expect((require $troops)[0]['enemies'][0]['graphicalPlacement'])->toBe(['x' => 410, 'depth' => 330, 'width' => 275, 'height' => 190, 'displayScale' => 1.1]);
+});
+
+it('aligns, arranges automatically and moves one member as one undo step each, through the Engine\'s arrangement', function () {
+    $root = troopFormationProject();
+    $session = EditorSession::open($root);
+    $arranged = $session->readTroopFormation(0)['members'];
+
+    // Moving one member places the other where it stands now, so it does not move.
+    $session->placeTroopMember(0, 0, 300, 120);
+    $members = $session->readTroopFormation(0)['members'];
+    expect($members[0]['placement'])->toMatchArray(['x' => 300, 'depth' => 120])
+        // Placements are written to two decimals.
+        ->and(array_values($members[1]['place']))->toEqualWithDelta(array_values($arranged[1]['place']), .01)
+        ->and([$members[1]['placement']['x'], $members[1]['placement']['depth']])
+        ->toEqualWithDelta([$arranged[1]['place']['x'], $arranged[1]['place']['depth']], .01);
+
+    $session->alignTroop(0);
+    $aligned = $session->readTroopFormation(0)['members'];
+    expect(array_column($aligned, 'placement'))->each->not->toBeNull()
+        // The troop faces the party: its middle stands at the middle of the theme's party formation (depths 180 and 40).
+        ->and((min($depths = array_map(static fn(array $member): float => $member['place']['depth'], $aligned)) + max($depths)) / 2)
+        ->toEqualWithDelta(110.0, .01);
+
+    $session->arrangeTroopAutomatically(0);
+    $automatic = $session->readTroopFormation(0)['members'];
+    expect(array_column($automatic, 'placement'))->toBe([null, null])
+        // Align writes the arrangement to two decimals.
+        ->and(array_merge(...array_map(array_values(...), array_column($automatic, 'place'))))
+        ->toEqualWithDelta(array_merge(...array_map(array_values(...), array_column($aligned, 'place'))), .01);
+
+    $session->undo();
+    expect(array_column($session->readTroopFormation(0)['members'], 'placement'))->each->not->toBeNull();
+    $session->saveDatabase('troops');
+    expect((string) file_get_contents($root . '/assets/Data/troops.php'))->toContain('// The leader, placed for the graphical battle.')
+        // Whole canvas units are written as integers, the rest to two decimals.
+        ->toMatch("/'width' => 1, 'height' => 1\]/")
+        ->not->toMatch("/'(x|depth)' => \\d+\\.(\\d{3,}|0+)[,\\]]/");
+});
+
+it('refuses a placement still at a fixed canvas height and names how to place it again', function () {
+    $root = troopFormationProject();
+    $troops = $root . '/assets/Data/troops.php';
+    file_put_contents($troops, str_replace("'depth' => 300", "'y' => 300", (string) file_get_contents($troops)));
+    $session = EditorSession::open($root);
+    expect($session->readTroopFormation(0)['members'][0]['issue'])->toContain('y is a fixed canvas height');
+    $session->arrangeTroopAutomatically(0);
+    expect($session->readTroopFormation(0)['members'][0])->not->toHaveKey('issue');
+});
+
+it('draws a member being dragged where it would stand, every other member where it stands, without writing', function () {
+    $root = troopFormationProject();
+    $session = EditorSession::open($root);
+    $before = (string) file_get_contents($root . '/assets/Data/troops.php');
+    $standing = $session->readTroopFormation(0)['members'];
+    $moving = $session->readTroopFormation(0, moving: [0, 500.0, 120.0])['members'];
+
+    expect($moving[0]['place'])->toBe(['x' => 500.0, 'depth' => 120.0])
+        ->and($moving[0]['battler']['ground'])->toBe(['x' => 500.0, 'y' => 480.0])
+        ->and($moving[1]['place'])->toBe($standing[1]['place'])
+        ->and($session->readTroopFormation(0)['members'][0]['place'])->toBe(['x' => 260.0, 'depth' => 300.0])
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and((string) file_get_contents($root . '/assets/Data/troops.php'))->toBe($before)
+        ->and(fn() => $session->readTroopFormation(0, moving: [5, 500.0, 120.0]))->toThrow(SessionRefusal::class, 'member 6');
+});
diff --git a/tests/Unit/Session/TroopPageSessionTest.php b/tests/Unit/Session/TroopPageSessionTest.php
new file mode 100644
index 00000000..5d8b6e22
--- /dev/null
+++ b/tests/Unit/Session/TroopPageSessionTest.php
@@ -0,0 +1,140 @@
+readTroopPage(0);
+
+    expect($page['members'])->toBe([
+        ['enemy' => 'Regular Bat', 'position' => [15, 7], 'placement' => ['x' => 260, 'depth' => 300, 'width' => 275, 'height' => 190]],
+        ['enemy' => 'Regular Bat', 'position' => [15, 20], 'placement' => null],
+    ])
+        ->and($page['enemies'])->toEqualCanonicalizing(['Regular Bat', 'Marsh Wisp'])
+        ->and($page['formation']['members'])->toHaveCount(2)
+        ->and($page['formationIssue'])->toBeNull()
+        ->and($page['align'])->toBe(['available' => true, 'reason' => null]);
+});
+
+it('reads a troop page without a graphical battle, saying why there is no formation', function () {
+    $root = troopPageProject();
+    unlink($root . '/assets/Data/Presentation/battle.php');
+    $page = EditorSession::open($root)->readTroopPage(0);
+
+    expect($page['members'])->toHaveCount(2)
+        ->and($page['formation'])->toBeNull()
+        ->and($page['formationIssue'])->toBeString()->not->toBe('');
+});
+
+it('adds a member by its enemy as one undo step, keeping the file\'s comments and keys it does not know', function () {
+    $root = troopPageProject();
+    $troops = $root . '/assets/Data/troops.php';
+    file_put_contents($troops, str_replace("'name' => 'Pair',", "'name' => 'Pair',\n        'id' => 'pair',\n        'events' => [['conditions' => ['turn' => 1]]],", (string) file_get_contents($troops)));
+    $session = EditorSession::open($root);
+
+    $added = $session->addTroopMember(0, 'Marsh Wisp');
+    // It stands on the terminal field where the Engine puts a new member beside the two bats, by their sprites.
+    $bat = ['/\\o/\\', " ' '"];
+    $place = new TerminalBattleEffects()->findTroopPlace([['position' => new Vector2(15, 7), 'sprite' => $bat],
+        ['position' => new Vector2(15, 20), 'sprite' => $bat]], ['~*~']);
+    expect($added['member'])->toBe(2)
+        ->and($session->readTroopPage(0)['members'][2])->toBe(['enemy' => 'Marsh Wisp', 'position' => [(int) $place->x, (int) $place->y], 'placement' => null])
+        ->and([(int) $place->x, (int) $place->y])->not->toBe([15, 7])
+        ->and(fn() => $session->addTroopMember(0, 'Dragon'))->toThrow(SessionRefusal::class, 'Dragon')
+        ->and(fn() => $session->addTroopMember(0, ''))->toThrow(SessionRefusal::class, 'Choose');
+
+    $session->saveDatabase('troops');
+    $written = (string) file_get_contents($troops);
+    $data = require $troops;
+    expect($written)->toContain('// The leader, placed for the graphical battle.')
+        ->and($data[0]['id'])->toBe('pair')
+        ->and($data[0]['events'])->toBe([['conditions' => ['turn' => 1]]])
+        ->and(array_column($data[0]['enemies'], 'enemy'))->toBe(['Regular Bat', 'Regular Bat', 'Marsh Wisp']);
+
+    $session->undo();
+    expect($session->readTroopPage(0)['members'])->toHaveCount(2);
+});
+
+it('removes one member and clears them all, each one undo step', function () {
+    $session = EditorSession::open(troopPageProject());
+    $session->addTroopMember(0, 'Marsh Wisp');
+
+    $session->removeTroopMember(0, 0);
+    expect(array_column($session->readTroopPage(0)['members'], 'enemy'))->toBe(['Regular Bat', 'Marsh Wisp'])
+        ->and(fn() => $session->removeTroopMember(0, 5))->toThrow(SessionRefusal::class, 'member 6');
+
+    expect($session->clearTroopMembers(0)['changed'])->toBeTrue()
+        ->and($session->readTroopPage(0)['members'])->toBe([])
+        ->and($session->clearTroopMembers(0)['changed'])->toBeFalse();
+
+    $session->undo();
+    expect(array_column($session->readTroopPage(0)['members'], 'enemy'))->toBe(['Regular Bat', 'Marsh Wisp']);
+    $session->undo();
+    expect($session->readTroopPage(0)['members'][0]['placement'])->toBe(['x' => 260, 'depth' => 300, 'width' => 275, 'height' => 190]);
+});
+
+it('names a troop after its members, numbering a name another troop has', function () {
+    $session = EditorSession::open(troopPageProject());
+    $session->addTroopMember(0, 'Marsh Wisp');
+
+    expect($session->nameTroopAfterMembers(0)['name'])->toBe('Regular Bat x 2, Marsh Wisp');
+    $session->undo();
+    expect($session->listDatabaseRecords('troops')['records'])->toBe(['Pair']);
+
+    $created = $session->createDatabaseRecord('troops');
+    expect($session->readTroopPage($created['index'])['members'])->toBe([])
+        ->and(fn() => $session->nameTroopAfterMembers($created['index']))->toThrow(SessionRefusal::class, 'Add a member first');
+    $session->addTroopMember($created['index'], 'Regular Bat');
+    $session->addTroopMember($created['index'], 'Regular Bat');
+    $session->addTroopMember($created['index'], 'Marsh Wisp');
+    $session->nameTroopAfterMembers(0);
+
+    expect($session->nameTroopAfterMembers($created['index'])['name'])->toBe('Regular Bat x 2, Marsh Wisp 2');
+});
+
+it('describes members by each enemy once, in order, with how many there are', function () {
+    expect(TroopAuthoring::describeMembers(['Bat', 'Rat', 'Bat', '']))->toBe('Bat x 2, Rat')
+        ->and(TroopAuthoring::describeMembers([]))->toBe('');
+});
+
+it('sets a troop\'s battle music from the project\'s tracks, and unsetting it leaves the battle theme to the project', function () {
+    $root = troopPageProject();
+    @mkdir($root . '/assets/Audio/BGM', 0o777, true);
+    file_put_contents($root . '/assets/Audio/BGM/Boss Theme.ogg', 'ogg');
+    $session = EditorSession::open($root);
+    $music = array_find($session->readDatabaseRecord('troops', 0)['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === 'bgm');
+
+    expect($music)->toMatchArray(['kind' => 'reference', 'reference' => 'bgm', 'value' => 'Project battle theme']);
+    $session->applyDatabaseRecord('troops', 0, $music['key'], 'Boss Theme');
+    $session->saveDatabase('troops');
+    expect((require $root . '/assets/Data/troops.php')[0]['bgm'])->toBe('Boss Theme');
+
+    $session->applyDatabaseRecord('troops', 0, $music['key'], '');
+    $session->saveDatabase('troops');
+    expect((require $root . '/assets/Data/troops.php')[0])->not->toHaveKey('bgm');
+});
+
+it('keeps a troop composed in a battle test as a new troop named after its members, keeping the file\'s comments', function () {
+    $root = troopPageProject();
+    $troops = $root . '/assets/Data/troops.php';
+    $keeper = new ProjectTroopKeeper($root);
+    $members = [['enemy' => 'Marsh Wisp', 'position' => [40, 12]],
+        ['enemy' => 'Regular Bat', 'position' => [12, 4], 'graphicalPlacement' => ['x' => 300.0, 'depth' => 40.5, 'width' => 120.0, 'height' => 90.0]]];
+
+    expect($keeper->keepTroop($members))->toBe('Kept as Marsh Wisp, Regular Bat in Troops.')
+        ->and($keeper->keepTroop($members))->toBe('Kept as Marsh Wisp, Regular Bat 2 in Troops.');
+    $data = require $troops;
+    expect((string) file_get_contents($troops))->toContain('// The leader, placed for the graphical battle.')
+        ->and(array_column($data, 'name'))->toBe(['Pair', 'Marsh Wisp, Regular Bat', 'Marsh Wisp, Regular Bat 2'])
+        ->and($data[1]['enemies'][0])->toBe(['enemy' => 'Marsh Wisp', 'position' => [40, 12]])
+        ->and($data[1]['enemies'][1]['graphicalPlacement'])->toBe(['x' => 300, 'depth' => 40.5, 'width' => 120, 'height' => 90])
+        // A member the project has no enemy for is refused, and nothing is written.
+        ->and(fn() => $keeper->keepTroop([['enemy' => 'Dragon', 'position' => [1, 2]]]))->toThrow(RuntimeException::class, 'Dragon')
+        ->and(count(require $troops))->toBe(3);
+});
diff --git a/tests/Unit/Session/WorldObjectAuthoringTest.php b/tests/Unit/Session/WorldObjectAuthoringTest.php
new file mode 100644
index 00000000..868d2a69
--- /dev/null
+++ b/tests/Unit/Session/WorldObjectAuthoringTest.php
@@ -0,0 +1,527 @@
+ [],", "'events' => [], /* map-owned identity */ 'worldObjects' => [
+        ['id' => 'fixture-prop', 'anchor' => ['x' => 1, 'y' => 1],
+            'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null],
+    ], 'tileLayers' => ['floor' => ['movesWith' => 'terrain']], 'preserved' => abs(7),", $source);
+    file_put_contents($file, $source);
+    writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 12, 16);
+    writeTilesetTestPng($root . '/assets/Graphics/Props/$Walking.png', 12, 16);
+    return [$root, $file, EditorSession::open($root)];
+}
+
+function applyWorldObjectRow(EditorSession $session, string $field, string $value): array
+{
+    $view = $session->readWorldObject('test-map', 'fixture-prop');
+    $row = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field)
+        ?? throw new RuntimeException('Missing world-object row ' . $field);
+    return $session->applyWorldObject('test-map', $view['revision'], 'fixture-prop', $row['key'], $value);
+}
+
+it('owns objects only in map source, with source-safe history save and reopen', function () {
+    [$root, $file, $session] = createWorldObjectProject();
+    $before = file_get_contents($file);
+    $map = $session->readMap('test-map');
+    expect(array_column($map['worldObjects'], 'id'))->toBe(['fixture-prop']);
+    $session->createWorldObject('test-map', $map['revision'], 'new-prop', 2, 1);
+    expect(file_get_contents($file))->toBe($before)
+        ->and($session->undo()['maps'])->toBe(['test-map']);
+    expect(array_column($session->readMap('test-map')['worldObjects'], 'id'))->toBe(['fixture-prop']);
+    $session->redo();
+    applyWorldObjectRow($session, 'anchor.x', '2');
+    applyWorldObjectRow($session, 'sprites2d.asset', 'Graphics/Props/Replaceable.png');
+    $session->saveMap('test-map');
+    expect(file_get_contents($file))->toContain('/* map-owned identity */', "'preserved' => abs(7)")
+        ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['sprites2d'])->toBe(['asset' => 'Graphics/Props/Replaceable.png']);
+    $revision = $session->readMap('test-map')['revision'];
+    $session->deleteWorldObject('test-map', $revision, 'new-prop');
+    $session->undo();
+    expect(array_column($session->readMap('test-map')['worldObjects'], 'id'))->toBe(['fixture-prop', 'new-prop']);
+});
+
+it('offers typed variants conditions coverage and known resource selections', function () {
+    [, , $session] = createWorldObjectProject();
+    $view = $session->readWorldObject('test-map', 'fixture-prop');
+    $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'variantList');
+    $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false);
+    expect(fn() => $session->saveMap('test-map'))->toThrow(SessionRefusal::class, 'conditions');
+    applyWorldObjectRow($session, 'variant0Conditions', 'switch:existing-condition');
+    applyWorldObjectRow($session, 'variant0Sprites2dasset', 'Graphics/Props/Replaceable.png');
+    $layer = array_find($session->readMap('test-map')['tileLayers']['layers'], static fn($layer): bool => $layer['name'] === 'floor')['owner'];
+    applyWorldObjectRow($session, 'covers.layer', $layer);
+    $session->placeWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 2, 1, true);
+    $object = $session->readMap('test-map')['worldObjects'][0];
+    expect($object['variants'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'existing-condition']])
+        ->and($object['covers']['cells'])->toBe([[1, 1], [2, 1]])
+        ->and($object['covers']['tileLayers'])->toBe([]);
+    $choices = $session->listReferences('test-map', 'world_object_tile_layers', ['id' => 'fixture-prop']);
+    expect(array_column($choices, 'value'))->toContain('floor');
+    applyWorldObjectRow($session, 'covers.tileLayers', 'floor');
+    expect(fn() => applyWorldObjectRow($session, 'covers.tileLayers', 'unknown'))->toThrow(SessionRefusal::class, 'Choose a tile layer');
+    $session->saveMap('test-map');
+});
+
+it('shares current-file walking roles and explicit absence without changing Terminal or occupancy', function () {
+    [$root, , $session] = createWorldObjectProject();
+    $before = $session->readMap('test-map');
+    $terminal = ProjectWorkspace::fromProject($root)->maps[0]->getLayerSet()->getComposedGrid();
+    applyWorldObjectRow($session, 'sprites2d.sheet', 'Graphics/Props/$Walking.png');
+    $row = array_find($session->readWorldObject('test-map', 'fixture-prop')['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'sprites2d.sheet');
+    expect($row['imagePreview']['issue'] ?? null)->toBeNull()
+        ->and(count($row['imagePreview']['frames']))->toBe(4);
+    applyWorldObjectRow($session, 'sprites2d.sheet', '');
+    expect($session->readMap('test-map')['worldObjects'][0]['sprites2d'])->toBeNull()
+        ->and($session->readMap('test-map')['occupancy'])->toBe($before['occupancy'])
+        ->and($session->readMap('test-map')['layers'])->toBe($before['layers']);
+    $session->saveMap('test-map');
+    expect(ProjectWorkspace::fromProject($root)->maps[0]->getLayerSet()->getComposedGrid())->toBe($terminal);
+});
+
+it('refuses stale revisions ids assets expressions and external source edits before mutation', function () {
+    [$root, $file, $session] = createWorldObjectProject();
+    $view = $session->readWorldObject('test-map', 'fixture-prop');
+    expect(fn() => $session->createWorldObject('test-map', $view['revision'], 'fixture-prop', 1, 1))->toThrow(SessionRefusal::class, 'Duplicate')
+        ->and(fn() => $session->createWorldObject('test-map', $view['revision'], 'Invalid Id', 1, 1))->toThrow(SessionRefusal::class, 'stable lowercase')
+        ->and(fn() => applyWorldObjectRow($session, 'sprites2d.asset', '../foreign.png'))->toThrow(SessionRefusal::class, 'Choose a current');
+    applyWorldObjectRow($session, 'anchor.x', '2');
+    expect(fn() => $session->deleteWorldObject('test-map', $view['revision'], 'fixture-prop'))->toThrow(SessionRefusal::class, 'changed since revision');
+    file_put_contents($file, str_replace("'preserved' => abs(7)", "'preserved' => abs(8)", (string) file_get_contents($file)));
+    expect(fn() => applyWorldObjectRow($session, 'anchor.x', '3'))->toThrow(SessionRefusal::class)
+        ->and($session->readMap('test-map')['worldObjects'][0]['anchor']['x'])->toBe(2);
+    $source = str_replace("'x' => 1", "'x' => abs(1)", (string) file_get_contents($file));
+    file_put_contents($file, $source);
+    $reloaded = EditorSession::open($root);
+    expect($reloaded->readWorldObject('test-map', 'fixture-prop')['issue'])->toContain('literal')
+        ->and(fn() => applyWorldObjectRow($reloaded, 'anchor.x', '3'))->toThrow(SessionRefusal::class)
+        ->and(file_get_contents($file))->toBe($source);
+});
+
+it('uses Engine coverage and current-frame projection, retaining glyphs for missing roles', function () {
+    [$root, , $session] = createWorldObjectProject();
+    $layer = $session->readMap('test-map')['layers'][0]['name'];
+    applyWorldObjectRow($session, 'covers.layer', $layer);
+    applyWorldObjectRow($session, 'sprites2d.asset', 'Graphics/Props/Replaceable.png');
+    $preview = drawAuthoringCanvas($session, 'test-map');
+    $sprite = array_find($preview['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite');
+    expect($sprite['value']['id'])->toBe('world-object:test-map:fixture-prop')
+        ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => 1.0]);
+    unlink($root . '/assets/Graphics/Props/Replaceable.png');
+    $missing = drawAuthoringCanvas($session, 'test-map');
+    expect($missing['diagnostics'])->toHaveCount(1)
+        ->and(array_filter($missing['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([]);
+    writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 24, 32);
+    expect(drawAuthoringCanvas($session, 'test-map')['diagnostics'])->toBe([]);
+});
+
+it('exports canvas owner revision and layer identities with transient variants and current asset paths', function () {
+    [$root, $file, $session] = createWorldObjectProject();
+    applyWorldObjectRow($session, 'sprites2d.asset', 'Graphics/Props/Replaceable.png');
+    applyWorldObjectRow($session, 'covers.layer', 'terrain');
+    applyWorldObjectRow($session, 'covers.tileLayers', 'floor');
+    applyWorldObjectRow($session, 'sprites2d.layer', '950');
+    $view = $session->readWorldObject('test-map', 'fixture-prop');
+    $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? '') === 'variantList');
+    $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false);
+    applyWorldObjectRow($session, 'variant0Id', 'absent');
+    applyWorldObjectRow($session, 'variant0Conditions', 'switch:existing-condition');
+    $before = $session->readMap('test-map');
+    $source = file_get_contents($file);
+    $base = drawAuthoringCanvas($session, 'test-map');
+    $absent = drawAuthoringCanvas($session, 'test-map', ['fixture-prop' => 'absent'], .25);
+    $hasFloor = static function (array $preview): bool {
+        foreach ($preview['operations'] as $op) {
+            if (($op['op'] ?? '') !== 'worldTiles' || $op['layerId'] !== 'tiles:floor') { continue; }
+            foreach ($op['rows'] as $row) {
+                if ($row['row'] === 1 && array_any($row['cells'], static fn(array $cell): bool => $cell['column'] === 1)) { return true; }
+            }
+        }
+        return false;
+    };
+    $sprite = array_find($base['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite');
+    expect($base['revision'])->toBe($before['revision'])
+        ->and($base['layerIds'])->toBe($session->readWorld('test-map')['layerIds'])
+        ->and($base['assetPaths'])->toContain('Graphics/Props/Replaceable.png')
+        ->and($sprite['value']['id'])->toBe('world-object:test-map:fixture-prop')
+        ->and($sprite['value']['layer'])->toBe(950)
+        ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => 1.0])
+        ->and($hasFloor($base))->toBeFalse()
+        ->and($hasFloor($absent))->toBeFalse()
+        ->and(array_filter($absent['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite'))->toBe([])
+        ->and($session->readMap('test-map'))->toBe($before)
+        ->and(file_get_contents($file))->toBe($source);
+    unlink($root . '/assets/Graphics/Props/Replaceable.png');
+    $missing = drawAuthoringCanvas($session, 'test-map');
+    expect($missing['assetPaths'])->toContain('Graphics/Props/Replaceable.png')
+        ->and($missing['diagnostics'])->toHaveCount(1)->and($hasFloor($missing))->toBeTrue();
+    writeTilesetTestPng($root . '/assets/Graphics/Props/Replaceable.png', 24, 32);
+    $session->saveMap('test-map');
+    $reopened = EditorSession::open($root);
+    expect(drawAuthoringCanvas($reopened, 'test-map')['diagnostics'])->toBe([])
+        ->and($reopened->readMap('test-map')['worldObjects'][0]['sprites2d']['asset'])->toBe('Graphics/Props/Replaceable.png')
+        ->and($reopened->readMap('test-map')['worldObjects'][0]['anchor'])->toBe($before['worldObjects'][0]['anchor']);
+});
+
+it('keeps unavailable sheet dependencies visible to the transient canvas asset watch', function () {
+    [$root, , $session] = createWorldObjectProject();
+    $path = 'Graphics/Tilesets/Home_A2.png';
+    unlink($root . '/assets/' . $path);
+    $missing = drawAuthoringCanvas($session, 'test-map');
+    expect($missing['assetPaths'])->toContain($path)
+        ->and(array_filter($missing['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->toBe([]);
+    writeTilesetTestPng($root . '/assets/' . $path, 32, 24);
+    $restored = drawAuthoringCanvas($session, 'test-map');
+    expect($restored['assetPaths'])->toContain($path)
+        ->and(array_filter($restored['operations'], static fn(array $op): bool => ($op['layerId'] ?? '') === 'tiles:floor'))->not->toBe([]);
+});
+
+it('routes real RPC ownership and cinematic object selectors without a global object database', function () {
+    [$root, , $session] = createWorldObjectProject();
+    $host = new SessionHost(fopen('php://memory', 'r+'), fopen('php://memory', 'r+'), fopen('php://memory', 'r+'));
+    $request = static fn(string $method, array $params): array => $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params]));
+    $hello = $request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]);
+    expect(array_column($hello['result']['project']['databases'], 'key'))->not->toContain('world_objects');
+    $view = $request('worldObjects.read', ['map' => 'test-map', 'id' => 'fixture-prop'])['result'];
+    expect($request('worldObjects.place', ['map' => 'test-map', 'id' => 'fixture-prop', 'revision' => $view['revision'], 'x' => 2, 'y' => 1])['result']['changed'])->toBeTrue();
+    $opened = $request('canvas.exchange', ['map' => 'test-map', 'events' => []])['result'];
+    $drawn = $request('canvas.exchange', ['map' => 'test-map', 'events' => [canvasWindowReady()], 'sessionId' => $opened['sessionId']])['result'];
+    expect(findCanvasMessages($drawn, 'frame')[0]['payload']['reset'])->toBeTrue()
+        ->and($request('canvas.exchange', ['map' => 'test-map', 'events' => [], 'seconds' => 'wrong'])['error']['kind'])->toBe('request')
+        ->and($request('canvas.close', ['map' => 'test-map'])['result'])->toBe(['closed' => true]);
+    expect(array_column($session->listReferences('test-map', 'map_world_objects'), 'value'))->toBe(['fixture-prop']);
+    $camera = CutsceneSchemas::cinematicCommandVariants(true)['camera'](['operation' => 'focus', 'target' => ['kind' => 'world_object', 'id' => 'fixture-prop']]);
+    expect(array_find($camera, static fn($field): bool => $field->key === 'target.id')->reference)->toBe('map_world_objects');
+});
+
+it('refuses referenced and opaque cinematic deletions before source history or revision mutation', function () {
+    [$root, $file] = createWorldObjectProject();
+    $folder = $root . '/assets/Cutscenes/Cinematics/fixture-cutscene';
+    mkdir($folder, 0777, true);
+    $dataFile = $folder . '/fixture-cutscene.data.php';
+    $scriptFile = $folder . '/fixture-cutscene.script.php';
+    file_put_contents($dataFile, "'fixture-cutscene', 'name'=>'Fixture', 'startMap'=>'test-map', 'cast'=>[
+        ['kind'=>'staged_actor', 'id'=>'staged', 'subject'=>['kind'=>'world_object','id'=>'fixture-prop'],
+        'suppress'=>[['kind'=>'world_object','id'=>'fixture-prop']]],
+    ]];");
+    file_put_contents($scriptFile, "'camera', 'operation'=>'focus', 'target'=>['kind'=>'world_object','id'=>'fixture-prop']]];");
+    $session = EditorSession::open($root);
+    $before = $session->readMap('test-map');
+    $source = file_get_contents($file);
+    try {
+        $session->deleteWorldObject('test-map', $before['revision'], 'fixture-prop');
+        $this->fail('Referenced object deletion was not refused.');
+    } catch (SessionRefusal $error) {
+        expect($error->getMessage())->toContain('fixture-cutscene.cast.0.subject', 'fixture-cutscene.cast.0.suppress.0', 'fixture-cutscene.commands.0.target');
+    }
+    expect($session->readMap('test-map'))->toBe($before)
+        ->and(file_get_contents($file))->toBe($source)
+        ->and($session->undo()['maps'])->toBe([]);
+    file_put_contents($dataFile, "'fixture-cutscene', 'name'=>'Fixture', 'startMap'=>'test-map', 'cast'=>[]];");
+    // A helper's result is proved by the plain data it loaded as; a closure the game would run later is not.
+    file_put_contents($scriptFile, " ['type'=>'wait','seconds'=>0.5];
+        return [\$wait(), ['type'=>'wait','seconds'=>0.5,'until'=>static fn(): bool => true]];");
+    $opaque = EditorSession::open($root);
+    expect(fn() => $opaque->deleteWorldObject('test-map', $opaque->readMap('test-map')['revision'], 'fixture-prop'))
+        ->toThrow(SessionRefusal::class, 'fixture-cutscene');
+    file_put_contents($scriptFile, " ['type'=>'wait','seconds'=>\$seconds];
+        return [\$wait(0.5), \$wait(1.0)];");
+    $helper = EditorSession::open($root);
+    $helper->deleteWorldObject('test-map', $helper->readMap('test-map')['revision'], 'fixture-prop');
+    expect($helper->readMap('test-map')['worldObjects'])->toBe([]);
+    file_put_contents($scriptFile, "'wait','seconds'=>0.5]];");
+    $safe = EditorSession::open($root);
+    $safe->deleteWorldObject('test-map', $safe->readMap('test-map')['revision'], 'fixture-prop');
+    expect($safe->readMap('test-map')['worldObjects'])->toBe([]);
+    $safe->undo();
+    expect(array_column($safe->readMap('test-map')['worldObjects'], 'id'))->toBe(['fixture-prop']);
+    $safe->redo();
+    $safe->saveMap('test-map');
+    expect(EditorSession::open($root)->readMap('test-map')['worldObjects'])->toBe([]);
+});
+
+it('proves enum case values, included scripts and helper results by the plain data they load as before deleting an object', function () {
+    [$root, $file] = createWorldObjectProject();
+    $script = $root . '/assets/Events/scribe.php';
+    $authored = (string) file_get_contents($file);
+    $write = static function (string $heading) use ($file, $authored): void {
+        file_put_contents($file, str_replace("'events' => [],", "'events' => [], 'npcs' => [['name' => 'Scribe', 'sprite' => '@', 'x' => 0, 'y' => 0,
+            'script' => require dirname(__DIR__, 2) . '/Events/scribe.php', 'heading' => {$heading}]],", $authored));
+    };
+    $refusal = static function (string $root): string {
+        $session = EditorSession::open($root);
+        try {
+            $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop');
+        } catch (SessionRefusal $error) {
+            return $error->getMessage();
+        }
+        return '';
+    };
+
+    // An included script and an enum case's value load as plain data, and nothing in them names the object.
+    file_put_contents($script, " 'text', 'name' => 'Scribe', 'text' => 'Ink dries slowly here.']];");
+    $write('\Ichiloto\Engine\Core\Enumerations\MovementHeading::NORTH->value');
+    expect($refusal($root))->toBe('');
+
+    // What the included script loads as is read for references like the map itself.
+    file_put_contents($script, " 'camera', 'operation' => 'focus', 'target' => ['kind' => 'world_object', 'id' => 'fixture-prop']]];");
+    expect($refusal($root))->toContain('test-map NPC Scribe.script.0.target');
+
+    // An enum case itself is an object, not plain data, so its source cannot vouch for it.
+    file_put_contents($script, 'toContain('npcs.0.heading: opaque authored source');
+});
+
+it('reorders explicit variants durably with undo and refuses invalid preview selection', function () {
+    [$root, , $session] = createWorldObjectProject();
+    foreach (['first', 'second'] as $id) {
+        $view = $session->readWorldObject('test-map', 'fixture-prop');
+        $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? '') === 'variantList');
+        $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false);
+        applyWorldObjectRow($session, 'variant0Id', $id);
+        applyWorldObjectRow($session, 'variant0Conditions', 'switch:existing-condition');
+    }
+    $session->moveWorldObjectVariant('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 'first', -1);
+    expect($session->readWorldObject('test-map', 'fixture-prop')['worldObject']['variants'])->toBe(['first', 'second']);
+    $session->undo();
+    expect($session->readWorldObject('test-map', 'fixture-prop')['worldObject']['variants'])->toBe(['second', 'first']);
+    $session->redo();
+    $session->saveMap('test-map');
+    expect(EditorSession::open($root)->readWorldObject('test-map', 'fixture-prop')['worldObject']['variants'])->toBe(['first', 'second'])
+        ->and(fn() => drawAuthoringCanvas($session, 'test-map', ['fixture-prop'=>true]))->toThrow(SessionRefusal::class, 'known variant ids')
+        ->and(fn() => drawAuthoringCanvas($session, 'test-map', ['fixture-prop'=>'unknown']))->toThrow(SessionRefusal::class, 'known world-object variant');
+});
+
+it('preserves known layer references through renames and refuses dangling removal atomically', function () {
+    [$root, , $session] = createWorldObjectProject();
+    applyWorldObjectRow($session, 'covers.layer', 'terrain');
+    applyWorldObjectRow($session, 'covers.tileLayers', 'floor');
+    $layer = array_find($session->readMap('test-map')['layers'], static fn($layer): bool => $layer['name'] === 'terrain')['id'];
+    $session->renameLayer('test-map', $session->readMap('test-map')['revision'], $layer, 'ground-renamed', true);
+    $session->renameTileLayer('test-map', $session->readMap('test-map')['revision'], 'floor', 'floor-renamed');
+    $before = $session->readMap('test-map');
+    expect($before['worldObjects'][0]['covers'])->toBe(['layer'=>'ground-renamed','cells'=>[[1,1]],'tileLayers'=>['floor-renamed']])
+        ->and(fn() => $session->removeTileLayer('test-map', $before['revision'], 'floor-renamed'))->toThrow(SessionRefusal::class)
+        ->and(fn() => $session->removeLayer('test-map', $before['revision'], $layer, true))->toThrow(SessionRefusal::class)
+        ->and($session->readMap('test-map'))->toBe($before);
+    $session->undo();
+    expect($session->readMap('test-map')['worldObjects'][0]['covers']['tileLayers'])->toBe(['floor']);
+    $session->redo();
+    $session->saveMap('test-map');
+    expect(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['covers'])->toBe($before['worldObjects'][0]['covers']);
+});
+
+it('moves only owned map coordinates in the existing insertion inventory and refuses clipped anchors', function () {
+    [$root] = createWorldObjectProject();
+    $map = ProjectWorkspace::fromProject($root)->maps[0];
+    expect(fn() => $map->resize(1,1))->toThrow(Ichiloto\Editor\MapSourceRefusal::class)
+        ->and($map->getWidth())->toBe(4);
+    $data = $map->getMapDataField([]);
+    $data['worldObjects'][0]['covers'] = ['layer'=>'terrain','cells'=>[[1,1],[3,1]],'tileLayers'=>['floor']];
+    $inventory = new Ichiloto\Editor\Maps\MapLineInventory(new Ichiloto\Editor\Maps\LineInsertion('test-map','x',1,2), []);
+    $inventory->addMapData('fixture.data.php','test-map',$data);
+    $shifts = $inventory->getShifts()['fixture.data.php'];
+    expect(array_column($shifts,'path'))->toContain(['worldObjects',0,'anchor','x'], ['worldObjects',0,'covers','cells',0,0], ['worldObjects',0,'covers','cells',1,0])
+        ->and(array_column($shifts,'value'))->toBe([3,3,5]);
+});
+
+it('keeps graphical subject controls out of Terminal schemas and uses typed camera route references', function () {
+    $camera = CutsceneSchemas::cinematicCommandVariants(false)['camera'](['operation'=>'focus','target'=>['kind'=>'npc']]);
+    expect(array_find($camera, static fn($field): bool => $field->key === 'target.kind')->options)->not->toContain('world_object');
+    $points = CutsceneSchemas::cameraPointList(true)->fieldsFor(['kind'=>'world_object']);
+    expect(array_find($points, static fn($field): bool => $field->key === 'id')->reference)->toBe('map_world_objects');
+    $effect = CutsceneSchemas::cinematicCommandVariants(true)['field_animation'](['target'=>['kind'=>'world_object']]);
+    expect(array_find($effect, static fn($field): bool => $field->key === 'target.id')->reference)->toBe('map_world_objects');
+});
+
+it('rechecks reference proof on redo and preserves the redo entry when a new source appears', function () {
+    [$root, , $session] = createWorldObjectProject();
+    $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop');
+    $session->undo();
+    $before = $session->readMap('test-map');
+    $folder = $root . '/assets/Cutscenes/Cinematics/external-fixture';
+    mkdir($folder,0777,true);
+    $file = $folder . '/external-fixture.script.php';
+    file_put_contents($file, "'camera','operation'=>'focus','target'=>['kind'=>'world_object','id'=>'fixture-prop']]];");
+    expect(fn() => $session->redo())->toThrow(SessionRefusal::class, 'external-fixture.script.php')
+        ->and($session->readMap('test-map'))->toBe($before);
+    unlink($file);
+    $session->redo();
+    expect($session->readMap('test-map')['worldObjects'])->toBe([]);
+});
+
+function createTerminalChestProject(): array
+{
+    $root = mapGraphicsProject();
+    $file = $root . '/assets/Maps/test-map/test-map.data.php';
+    // A two-cell chest over the buildings layer's xx: yellow while closed, white once its event is done.
+    file_put_contents($file, str_replace("'events' => [],", "'events' => [], 'worldObjects' => [
+        ['id' => 'fixture-prop', 'anchor' => ['x' => 1, 'y' => 1], 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => null,
+            'terminal' => ['color' => 'yellow'],
+            'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []],
+            'variants' => [
+                ['id' => 'opened', 'conditions' => [['type' => 'map_event', 'name' => 'test-map', 'marker' => 'E']], 'sprites2d' => null, 'terminal' => ['color' => 'white']],
+                ['id' => 'plain', 'conditions' => [['type' => 'switch', 'name' => 'plain']], 'sprites2d' => null, 'terminal' => null],
+                ['id' => 'inherits', 'conditions' => [['type' => 'switch', 'name' => 'inherits']], 'sprites2d' => null],
+            ]],
+    ],", (string) file_get_contents($file)));
+    return [$root, $file, EditorSession::open($root)];
+}
+
+function readWorldObjectRow(EditorSession $session, string $field): ?array
+{
+    return array_find($session->readWorldObject('test-map', 'fixture-prop')['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+it('preserves every terminal presentation form, inheritance and explicit null through an unrelated edit', function () {
+    [$root, , $session] = createTerminalChestProject();
+    expect(readWorldObjectRow($session, 'terminal.mode')['value'])->toBe('its covered glyphs recoloured')
+        ->and(readWorldObjectRow($session, 'terminal.color')['value'])->toBe('yellow')
+        ->and(readWorldObjectRow($session, 'variant1Terminalmode')['value'])->toBe("map's own glyph")
+        ->and(readWorldObjectRow($session, 'variant2Terminalmode')['value'])->toBe("the object's");
+    applyWorldObjectRow($session, 'pivot.x', '0.25');
+    $session->saveMap('test-map');
+    $object = EditorSession::open($root)->readMap('test-map')['worldObjects'][0];
+    expect($object['terminal'])->toBe(['color' => 'yellow'])
+        ->and($object['variants'][0]['terminal'])->toBe(['color' => 'white'])
+        ->and($object['variants'][1])->toHaveKey('terminal')
+        ->and($object['variants'][1]['terminal'])->toBeNull()
+        ->and($object['variants'][2])->not->toHaveKey('terminal');
+});
+
+it('edits each terminal presentation form through its explicit choice and refuses an invalid symbol', function () {
+    [$root, , $session] = createTerminalChestProject();
+    // An anchor symbol starts from the glyph the anchor shows.
+    applyWorldObjectRow($session, 'terminal.mode', 'a symbol at the anchor');
+    expect(readWorldObjectRow($session, 'terminal.symbol')['value'])->toBe('x');
+    applyWorldObjectRow($session, 'terminal.symbol', 'm');
+    applyWorldObjectRow($session, 'variant0Terminalcolor', 'bright-white');
+    applyWorldObjectRow($session, 'variant1Terminalmode', "the object's");
+    applyWorldObjectRow($session, 'variant2Terminalmode', "map's own glyph");
+    expect(fn() => applyWorldObjectRow($session, 'terminal.symbol', 'mm'))->toThrow(SessionRefusal::class);
+    $session->saveMap('test-map');
+    $object = EditorSession::open($root)->readMap('test-map')['worldObjects'][0];
+    expect($object['terminal'])->toBe('m')
+        ->and($object['variants'][0]['terminal'])->toBe(['color' => 'bright-white'])
+        ->and($object['variants'][1])->not->toHaveKey('terminal')
+        ->and($object['variants'][2]['terminal'])->toBeNull();
+    // Back to the map's glyph: the object's key goes.
+    applyWorldObjectRow($session, 'terminal.mode', "map's own glyph");
+    expect($session->readMap('test-map')['worldObjects'][0])->not->toHaveKey('terminal');
+});
+
+it('checks an unfinished variant against its object\'s real coverage', function () {
+    [, , $session] = createTerminalChestProject();
+    $view = $session->readWorldObject('test-map', 'fixture-prop');
+    $heading = array_find($view['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === 'variantList');
+    $session->changeWorldObjectItem('test-map', $view['revision'], 'fixture-prop', $heading['key'], false);
+    // A footprint colour on a draft variant is admitted because its object covers glyphs.
+    applyWorldObjectRow($session, 'variant3Terminalmode', 'its covered glyphs recoloured');
+    expect($session->readMap('test-map')['worldObjects'][0]['variants'][3]['terminal'])->toBe(['color' => 'white']);
+});
+
+it('moves a world object with its covered glyphs as one undo step, leaving no glyph behind', function () {
+    [$root, $file] = createWorldObjectProject();
+    file_put_contents($file, str_replace("'sprites2d' => null],", "'sprites2d' => null,
+            'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []]],", (string) file_get_contents($file)));
+    $session = EditorSession::open($root);
+    $buildings = static fn(): string => implode("\n", array_map(static fn(int $y): string => implode('', array_map(
+        static fn(int $x): string => getSessionMap($session)->getLayerSymbol(array_find(getSessionMap($session)->getLayers(),
+            static fn(array $layer): bool => $layer['name'] === 'buildings')['id'], $x, $y), range(0, 3))), range(0, 1)));
+    $object = static fn(): array => array_find($session->readMap('test-map')['worldObjects'], static fn(array $entry): bool => $entry['id'] === 'fixture-prop');
+
+    $moved = $session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 1, 0);
+
+    expect($moved['changed'])->toBeTrue()
+        ->and($buildings())->toBe(" /  \n  xx")
+        ->and($object()['anchor'])->toBe(['x' => 2, 'y' => 1])
+        ->and($object()['covers']['cells'])->toBe([[2, 1], [3, 1]]);
+
+    $session->undo();
+    expect($buildings())->toBe(" /  \n xx ")
+        ->and($object()['anchor'])->toBe(['x' => 1, 'y' => 1])
+        ->and($object()['covers']['cells'])->toBe([[1, 1], [2, 1]]);
+
+    $revision = $session->readMap('test-map')['revision'];
+    expect(fn() => $session->moveWorldObject('test-map', $revision, 'fixture-prop', 0, -1))
+        ->toThrow(SessionRefusal::class, '1, 0 on the buildings layer holds /; clear it before moving fixture-prop there.')
+        ->and(fn() => $session->moveWorldObject('test-map', $revision, 'fixture-prop', 2, 0))
+        ->toThrow(SessionRefusal::class, 'outside test-map')
+        ->and(fn() => $session->moveWorldObject('test-map', $revision, 'no-such-prop', 1, 0))
+        ->toThrow(SessionRefusal::class, 'test-map has no world object no-such-prop.')
+        ->and($buildings())->toBe(" /  \n xx ");
+});
+
+it('moves each covered glyph as the piece it plays, and one the object presents without tiles', function (string $decor, string $moved) {
+    [$root, $file] = createWorldObjectProject();
+    writeTestTileset($root, pieces: [
+        'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['5']]],
+        'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['decor' => ['6']]],
+    ]);
+    writeTileLayer($root . '/assets/Maps/test-map', '02.decor.tiles.php', $decor);
+    file_put_contents($file, str_replace("'sprites2d' => null],", "'sprites2d' => null,
+            'covers' => ['layer' => 'buildings', 'cells' => [[1, 1], [2, 1]], 'tileLayers' => []]],", (string) file_get_contents($file)));
+    $session = EditorSession::open($root);
+
+    expect($session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'fixture-prop', 1, 0)['changed'])->toBeTrue();
+    $session->saveMap('test-map');
+
+    expect(Ichiloto\Engine\Field\MapGridSource::readFile($root . '/assets/Maps/test-map/graphics/02.decor.tiles.php'))->toBe($moved);
+})->with([
+    // Its tiles show each x is a window: the windows move with them.
+    'pieces' => ["0 0 0 0\n0 5 5 0", "0 0 0 0\n0 0 5 5"],
+    // No tile tells which piece: the object presents them, and no tiles follow.
+    'object art' => ["0 0 0 0\n0 0 0 0", "0 0 0 0\n0 0 0 0"],
+]);
+
+it('authors an inline object\'s placement conditions beside its variants, keeping the rest of the source', function () {
+    [$root, $file, $session] = createWorldObjectProject();
+    applyWorldObjectRow($session, 'conditions', 'switch:existing-condition');
+    applyWorldObjectRow($session, 'anchor.x', '2');
+    $object = $session->readMap('test-map')['worldObjects'][0];
+    expect($object['conditions'])->toBe([['type' => 'switch', 'name' => 'existing-condition']])
+        ->and($object['anchor'])->toBe(['x' => 2, 'y' => 1]);
+    $session->saveMap('test-map');
+    expect(file_get_contents($file))->toContain('/* map-owned identity */', "'preserved' => abs(7)")
+        ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['conditions'])
+        ->toBe([['type' => 'switch', 'name' => 'existing-condition']]);
+});
+
+it('refuses a placement condition the Engine cannot evaluate before changing the map', function () {
+    [, $file, $session] = createWorldObjectProject();
+    $before = $session->readMap('test-map')['worldObjects'];
+    $source = file_get_contents($file);
+    expect(fn() => applyWorldObjectRow($session, 'conditions', 'no-such-predicate:anything'))->toThrow(SessionRefusal::class);
+    expect($session->readMap('test-map')['worldObjects'])->toBe($before)
+        ->and(file_get_contents($file))->toBe($source);
+});
+
+it('judges a world object where a line removal moves it, so one beyond the map\'s new width moves back with its line', function () {
+    [$root, $file, $session] = createWorldObjectProject();
+    file_put_contents($file, str_replace("'anchor' => ['x' => 1, 'y' => 1]", "'anchor' => ['x' => 3, 'y' => 1]", (string) file_get_contents($file)));
+    // No saves to land: the removal needs no reachable landing.
+    @unlink($root . '/assets/Data/save-compatibility.php');
+    $session = EditorSession::open($root);
+    $revision = $session->readMap('test-map')['revision'];
+
+    $question = $session->removeMapLines('test-map', $revision, 'x', 0, 1);
+    $removed = $session->removeMapLines('test-map', $revision, 'x', 0, 1, null, 'write', $question['confirm']);
+
+    expect($removed['status'])->toBe('removed')
+        ->and($session->readMap('test-map')['width'])->toBe(3)
+        ->and($session->readMap('test-map')['worldObjects'][0]['anchor'])->toBe(['x' => 2, 'y' => 1])
+        ->and((require $file)['worldObjects'][0]['anchor'])->toBe(['x' => 2, 'y' => 1]);
+});
diff --git a/tests/Unit/Session/WorldObjectFootprintTest.php b/tests/Unit/Session/WorldObjectFootprintTest.php
new file mode 100644
index 00000000..d8e1602b
--- /dev/null
+++ b/tests/Unit/Session/WorldObjectFootprintTest.php
@@ -0,0 +1,95 @@
+getResolvedCollisionMap()[$y][$x];
+}
+
+function createFootprintObject(EditorSession $session, string $id, int $x, int $y, string $resource): void
+{
+    $session->createWorldObject('test-map', $session->readMap('test-map')['revision'], $id, $x, $y, $resource);
+}
+
+it('stamps a made object\'s footprint and moves it with the object as one undo step', function () {
+    $session = EditorSession::open(createTreeBrushProject());
+    createFootprintObject($session, 'oak-1', 3, 4, 'oak');
+    expect(readFootprintCell($session, 3, 4))->toBe(CollisionType::SOLID->value);
+
+    $session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'oak-1', 1, 0);
+    expect(readFootprintCell($session, 3, 4))->toBe(CollisionType::NONE->value)
+        ->and(readFootprintCell($session, 4, 4))->toBe(CollisionType::SOLID->value);
+
+    $session->undo();
+    expect(readFootprintCell($session, 3, 4))->toBe(CollisionType::SOLID->value)
+        ->and(readFootprintCell($session, 4, 4))->toBe(CollisionType::NONE->value);
+    $session->redo();
+    expect(readFootprintCell($session, 3, 4))->toBe(CollisionType::NONE->value)
+        ->and(readFootprintCell($session, 4, 4))->toBe(CollisionType::SOLID->value);
+});
+
+it('gives the floor back when an object is deleted, unless another object still stamps it', function () {
+    $session = EditorSession::open(createTreeBrushProject());
+    createFootprintObject($session, 'pair-1', 2, 4, 'pair');
+    expect(readFootprintCell($session, 2, 4))->toBe(CollisionType::SOLID->value)
+        ->and(readFootprintCell($session, 4, 4))->toBe(CollisionType::SOLID->value);
+
+    $session->deleteWorldObject('test-map', $session->readMap('test-map')['revision'], 'pair-1');
+    expect(readFootprintCell($session, 2, 4))->toBe(CollisionType::NONE->value)
+        ->and(readFootprintCell($session, 4, 4))->toBe(CollisionType::NONE->value);
+});
+
+it('refuses a footprint on anything but plain floor, changing nothing', function (int $dx, int $dy, string $held) {
+    $session = EditorSession::open(createTreeBrushProject(rows: ['############', '#..........#', '#.....#....#', '#.....#....#',
+        '#....g#....#', '#.....#....#', '#..........#', '############']));
+    createFootprintObject($session, 'oak-1', 4, 3, 'oak');
+    $revision = $session->readMap('test-map')['revision'];
+
+    expect(fn() => $session->moveWorldObject('test-map', $revision, 'oak-1', $dx, $dy))
+        ->toThrow(SessionRefusal::class, "which is {$held}, not plain floor")
+        ->and(readFootprintCell($session, 4, 3))->toBe(CollisionType::SOLID->value)
+        ->and(array_column($session->readMap('test-map')['worldObjects'], 'anchor', 'id')['oak-1'])->toBe(['x' => 4, 'y' => 3]);
+})->with([
+    'a wall' => [2, 0, 'SOLID'],
+    'encounter grass' => [1, 1, 'ENCOUNTER'],
+]);
+
+it('leaves collision alone for objects without a footprint and maps that resolve collision from their glyphs', function (bool $declared, string $resource) {
+    $session = EditorSession::open(createTreeBrushProject($declared));
+    $before = getPhysicalOccupancyMap($session)->getResolvedCollisionMap();
+    createFootprintObject($session, 'object-1', 3, 4, $resource);
+    $session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'object-1', 1, 0);
+
+    expect(getPhysicalOccupancyMap($session)->getResolvedCollisionMap())->toBe($before);
+})->with([
+    'a trunkless counter' => [true, 'counter'],
+    'an undeclared map' => [false, 'oak'],
+]);
+
+it('writes the moved footprint to the map file when saved', function () {
+    $root = createTreeBrushProject();
+    $session = EditorSession::open($root);
+    createFootprintObject($session, 'oak-1', 3, 4, 'oak');
+    $session->moveWorldObject('test-map', $session->readMap('test-map')['revision'], 'oak-1', 0, 1);
+    $session->saveMap('test-map');
+
+    $reopened = EditorSession::open($root);
+    expect(readFootprintCell($reopened, 3, 4))->toBe(CollisionType::NONE->value)
+        ->and(readFootprintCell($reopened, 3, 5))->toBe(CollisionType::SOLID->value);
+});
diff --git a/tests/Unit/Session/WorldObjectResourceTest.php b/tests/Unit/Session/WorldObjectResourceTest.php
new file mode 100644
index 00000000..bd3aaf9b
--- /dev/null
+++ b/tests/Unit/Session/WorldObjectResourceTest.php
@@ -0,0 +1,105 @@
+ [\n"
+        . "  'broadleaf' => ['name' => 'Broadleaf', 'pivot' => ['x' => 0.5, 'y' => 0.9], 'sprites2d' => ['asset' => 'Graphics/Props/Broadleaf.png']],\n"
+        . "  'conifer' => ['name' => 'Conifer', 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Props/Conifer.png']],\n"
+        . "]];\n");
+    $file = $root . '/assets/Maps/test-map/test-map.data.php';
+    $source = str_replace("'events' => [],", "'events' => [], 'worldObjects' => [
+        ['id' => 'grove-001', 'resource' => 'broadleaf', 'anchor' => ['x' => 1, 'y' => 1]],
+    ], 'preserved' => abs(7),", (string) file_get_contents($file));
+    file_put_contents($file, $source);
+
+    return [$root, $file, EditorSession::open($root)];
+}
+
+/** @return array|null The placement's row for a field. */
+function findPlacementRow(EditorSession $session, string $field): ?array
+{
+    return array_find($session->readWorldObject('test-map', 'grove-001')['rows'], static fn($row): bool => ($row['key']['field'] ?? null) === $field);
+}
+
+it('previews a placed resource with the resource\'s own art, and watches that art', function () {
+    [, , $session] = createResourcePlacementProject();
+    $preview = drawAuthoringCanvas($session, 'test-map');
+    $sprite = array_find($preview['operations'], static fn($op): bool => ($op['kind'] ?? '') === 'sprite');
+
+    expect($preview['diagnostics'])->toBe([])
+        ->and($sprite['value']['id'])->toBe('world-object:test-map:grove-001')
+        ->and($sprite['value']['pivot'])->toBe(['x' => .5, 'y' => .9])
+        ->and($preview['assetPaths'])->toContain('Graphics/Props/Broadleaf.png');
+});
+
+it('offers a placement the resource to choose, never the art and pivot the resource owns', function () {
+    [$root, , $session] = createResourcePlacementProject();
+    $catalog = new ReferenceCatalog(ProjectWorkspace::fromProject($root));
+
+    expect(findPlacementRow($session, 'resource'))->toMatchArray(['reference' => 'field_resources'])
+        ->and(findPlacementRow($session, 'pivot.x'))->toBeNull()
+        ->and(findPlacementRow($session, 'sprites2d.asset'))->toBeNull()
+        ->and($catalog->valuesFor('field_resources'))->toBe(['broadleaf', 'conifer'])
+        ->and($catalog->labelsFor('field_resources'))->toBe(['broadleaf' => 'Broadleaf (broadleaf)', 'conifer' => 'Conifer (conifer)']);
+});
+
+it('changes a placement\'s resource as one undoable step that keeps the rest of the source as authored', function () {
+    [$root, $file, $session] = createResourcePlacementProject();
+    $row = findPlacementRow($session, 'resource');
+    $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], 'conifer');
+
+    expect($session->readMap('test-map')['worldObjects'][0])->toBe(['id' => 'grove-001', 'resource' => 'conifer', 'anchor' => ['x' => 1, 'y' => 1]]);
+    $session->saveMap('test-map');
+    expect((string) file_get_contents($file))->toContain("'resource' => 'conifer'")->toContain("'preserved' => abs(7)");
+    $session->undo();
+    expect($session->readMap('test-map')['worldObjects'][0]['resource'])->toBe('broadleaf')
+        ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['resource'])->toBe('conifer');
+});
+
+it('refuses a resource the catalogue does not name, before writing anything', function () {
+    [, $file, $session] = createResourcePlacementProject();
+    $before = (string) file_get_contents($file);
+    $map = $session->readMap('test-map');
+    $row = findPlacementRow($session, 'resource');
+
+    expect(fn() => $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], 'birch'))
+        ->toThrow(SessionRefusal::class)
+        ->and($session->readMap('test-map'))->toBe($map)
+        ->and((string) file_get_contents($file))->toBe($before);
+});
+
+it('shows a resource placement only while its authored conditions hold, and always when it has none', function () {
+    [$root, $file, $session] = createResourcePlacementProject();
+    expect(findPlacementRow($session, 'conditions'))->toMatchArray(['kind' => 'conditions']);
+    $row = findPlacementRow($session, 'conditions');
+    $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], 'switch:grove-grown');
+
+    expect($session->readMap('test-map')['worldObjects'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'grove-grown']]);
+    $session->saveMap('test-map');
+    expect((string) file_get_contents($file))->toContain("'preserved' => abs(7)")
+        ->and(EditorSession::open($root)->readMap('test-map')['worldObjects'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'grove-grown']]);
+
+    // Clearing the list removes the key, which the Engine reads as always visible; it never writes an empty or null list.
+    $row = findPlacementRow($session, 'conditions');
+    $session->applyWorldObject('test-map', $session->readWorldObject('test-map', 'grove-001')['revision'], 'grove-001', $row['key'], '');
+    expect($session->readMap('test-map')['worldObjects'][0])->not->toHaveKey('conditions');
+    $session->undo();
+    expect($session->readMap('test-map')['worldObjects'][0]['conditions'])->toBe([['type' => 'switch', 'name' => 'grove-grown']]);
+});
diff --git a/tests/Unit/SessionHostTest.php b/tests/Unit/SessionHostTest.php
new file mode 100644
index 00000000..956fe8a5
--- /dev/null
+++ b/tests/Unit/SessionHostTest.php
@@ -0,0 +1,74 @@
+ ['player' => ['destinationMap' => 'harbour',
+            'spawnPoint' => ['x' => 4, 'y' => 4], 'spawnSprite' => ['^']]],
+    ], true) . ';');
+    $before = sourceHashTree($root);
+    $input = fopen('php://memory', 'r');
+    $output = fopen('php://memory', 'w+');
+    $diagnostics = fopen('php://memory', 'w+');
+    $host = new SessionHost($input, $output, $diagnostics);
+    $request = static fn(string $method, array $params = []): array =>
+        $host->handle(json_encode(['id' => 1, 'method' => $method, 'params' => $params], JSON_THROW_ON_ERROR));
+    $exchange = 'cutscenes.' . $source . 'Scene';
+    $start = $source === 'cinematic' ? 'cutscenes.cinematicPreview' : 'cutscenes.effectField';
+    $detach = $source === 'cinematic' ? 'cutscenes.cinematicSceneDetach' : 'cutscenes.effectFieldClose';
+
+    try {
+        expect($request('hello', ['protocol' => SessionHost::PROTOCOL, 'project' => $root]))->toHaveKey('result');
+        $category = $source === 'cinematic' ? 'cutscenes/cinematic' : 'cutscenes/effect';
+        $identity = $source === 'cinematic' ? 'Harbour Lanterns' : 'ember-spark';
+        $index = array_search($identity, $request('database.records', ['category' => $category])['result']['records'], true);
+        expect($index)->toBeInt();
+        $startParams = ['index' => $index, 'frame' => 0, 'width' => 40, 'height' => 12];
+        expect($request($start, $startParams))->toHaveKey('result');
+        $discovery = $request($exchange)['result'];
+        $sessionId = $discovery['sessionId'];
+        expect($discovery['messages'])->toBe([])
+            ->and($sessionId)->toBeString()->not->toBe('');
+
+        $ready = $request($exchange, ['sessionId' => $sessionId, 'events' => [previewWindowReady()]]);
+        expect($ready)->toHaveKey('result')
+            ->and($ready['result']['grid'])->toBe(['columns' => 40, 'rows' => 12, 'cellWidth' => 10, 'cellHeight' => 20]);
+        $messages = $ready['result']['messages'];
+        $generation = max(previewFrameGenerations($messages));
+        expect(array_column($messages, 'type'))->toContain('frame');
+        $ack = json_encode(['protocol' => 2, 'type' => 'frame_ack',
+            'generation' => $generation, 'frame' => 1, 'presented' => true], JSON_THROW_ON_ERROR);
+        expect($request($exchange, ['sessionId' => $sessionId, 'events' => [$ack]])['result']['messages'])->toBe([]);
+
+        // Malformed requests must be refused before touching the active relay.
+        foreach ([null, 'not a list', [0 => previewWindowReady(), 2 => $ack], [previewWindowReady(), 7], [false]] as $events) {
+            expect($request($exchange, ['sessionId' => $sessionId, 'events' => $events])['error'])->toBe([
+                'kind' => 'request', 'message' => '"events" must be a list of strings.',
+            ]);
+        }
+        expect($request($exchange, ['sessionId' => $sessionId, 'events' => []])['result']['messages'])->toBe([]);
+        expect($request($detach))->toHaveKey('result');
+        if ($source !== 'cinematic') {
+            expect($request($start, $startParams))->toHaveKey('result');
+        }
+        $nextSessionId = $request($exchange)['result']['sessionId'];
+        expect($nextSessionId)->not->toBe($sessionId);
+        $again = $request($exchange, ['sessionId' => $nextSessionId, 'events' => [previewWindowReady()]]);
+        expect($again)->toHaveKey('result');
+        $frames = array_values(array_filter($again['result']['messages'], static fn(array $message): bool => $message['type'] === 'frame'));
+        expect($frames)->not->toBe([])->and($frames[0]['payload']['reset'] ?? false)->toBeTrue();
+        rewind($diagnostics);
+        expect(stream_get_contents($diagnostics))->toBe('')
+            ->and(sourceHashTree($root))->toBe($before);
+    } finally {
+        // EOF uses the production host teardown, including preview disposal.
+        $host->run();
+        fclose($input);
+        fclose($output);
+        fclose($diagnostics);
+    }
+})->with(['cinematic', 'effectField']);
diff --git a/tests/Unit/SettingsPaneLayoutTest.php b/tests/Unit/SettingsPaneLayoutTest.php
index 3db528e5..1d3fbe6c 100644
--- a/tests/Unit/SettingsPaneLayoutTest.php
+++ b/tests/Unit/SettingsPaneLayoutTest.php
@@ -110,3 +110,12 @@ function paneRow(string $label, string $value, bool $editable = true, bool $sele
         ->toBe(['  None. Nothing changes when', '  this completes.'])
         ->and(SettingsPaneLayout::wrapProse('  a to add a write.', 30))->toBe(['  a to add a write.']);
 });
+
+it('keeps text deeper than the pane is wide, shortening its indent instead of losing it', function () {
+    $lines = \Ichiloto\Editor\UI\SettingsPaneLayout::wrapProse(str_repeat(' ', 24) . '// preserved nested source', 16);
+
+    expect(array_filter($lines, static fn(string $line): bool => mb_strwidth($line) > 16))->toBe([])
+        // A word longer than the room left is split, never dropped.
+        ->and(preg_replace('/\s+/', '', implode('', $lines)))->toBe('//preservednestedsource')
+        ->and($lines[0])->toStartWith(str_repeat(' ', 8) . '//');
+});
diff --git a/tests/Unit/SharedInventoryTransactionTest.php b/tests/Unit/SharedInventoryTransactionTest.php
deleted file mode 100644
index f819928d..00000000
--- a/tests/Unit/SharedInventoryTransactionTest.php
+++ /dev/null
@@ -1,308 +0,0 @@
-
- */
-function inventoryIds(string $root): array
-{
-    $ids = [];
-
-    foreach (['items', 'weapons', 'armors'] as $category) {
-        $ids[$category] = array_map(
-            static fn($record): string => strval($record->get('id')),
-            ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category))->getRecords(),
-        );
-    }
-
-    return $ids;
-}
-
-/**
- * Opens all three inventory categories at once, as the editor holds them.
- *
- * @return array
- */
-function inventoryCategories(string $root): array
-{
-    $open = [];
-
-    foreach (['items', 'weapons', 'armors'] as $category) {
-        $open[$category] = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category));
-    }
-
-    return $open;
-}
-
-it('preserves both structural edits in either save order', function (array $pair, bool $reversed) {
-    $project = inventoryTransactionProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root] = $project;
-    $before = inventoryIds($root);
-    $open = inventoryCategories($root);
-    $removed = [];
-
-    foreach ($pair as $category) {
-        $removed[$category] = strval($open[$category]->removeRecord(0)?->get('id'));
-    }
-
-    $order = $reversed ? array_reverse($pair) : $pair;
-
-    foreach ($order as $category) {
-        $open[$category]->save();
-    }
-
-    $after = inventoryIds($root);
-
-    foreach (['items', 'weapons', 'armors'] as $category) {
-        $expected = $before[$category];
-
-        if (isset($removed[$category])) {
-            $expected = array_values(array_diff($expected, [$removed[$category]]));
-        }
-
-        // Exactly what was asked for, and nothing else, in every category.
-        expect($after[$category])->toBe(
-            $expected,
-            sprintf('%s is wrong after removing %s.', $category, implode(' and ', $pair)),
-        );
-    }
-})->with([
-    [['items', 'weapons']],
-    [['items', 'armors']],
-    [['weapons', 'armors']],
-])->with([false, true])->group('engine');
-
-it('preserves a removal from all three categories at once', function () {
-    $project = inventoryTransactionProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = inventoryIds($root);
-    $source = (string) file_get_contents($path);
-    $open = inventoryCategories($root);
-    $removed = [];
-
-    foreach ($open as $category => $database) {
-        $removed[$category] = strval($database->removeRecord(0)?->get('id'));
-    }
-
-    foreach ($open as $database) {
-        $database->save();
-    }
-
-    $after = inventoryIds($root);
-
-    foreach ($after as $category => $ids) {
-        expect($ids)->toBe(array_values(array_diff($before[$category], [$removed[$category]])));
-    }
-
-    // Three entries gone, and everything else spelled as the author spelled it.
-    expect(PhpSourceDocument::parse((string) file_get_contents($path))->entryCount())
-        ->toBe(PhpSourceDocument::parse($source)->entryCount() - 3)
-        ->and((string) file_get_contents($path))->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;')
-        ->and((string) file_get_contents($path))->toContain('parameterChanges: new \Ichiloto\Engine\Entities\ParameterChanges(');
-})->group('engine');
-
-it('composes a field edit in one category with a structural edit in another', function () {
-    $project = inventoryTransactionProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = inventoryIds($root);
-    $open = inventoryCategories($root);
-
-    $open['items']->setField(0, 'price', '4321');
-    $removedWeapon = strval($open['weapons']->removeRecord(0)?->get('id'));
-    $addedArmor = null;
-    $index = $open['armors']->addRecord();
-    $addedArmor = strval($open['armors']->getRecordByIndex($index)?->get('id'));
-
-    foreach (['weapons', 'armors', 'items'] as $category) {
-        $open[$category]->save();
-    }
-
-    $after = inventoryIds($root);
-    $reloadedItems = ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items'));
-
-    expect($reloadedItems->getRecordByIndex(0)?->get('price'))->toBe(4321)
-        ->and($after['items'])->toBe($before['items'])
-        ->and($after['weapons'])->toBe(array_values(array_diff($before['weapons'], [$removedWeapon])))
-        ->and($after['armors'])->toBe([...$before['armors'], $addedArmor])
-        ->and((string) file_get_contents($path))->toContain('price: 4321,');
-})->group('engine');
-
-it('writes one file once through Save All, with every category folded in', function () {
-    $project = inventoryTransactionProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = inventoryIds($root);
-
-    $editor = createEditorForTesting($root);
-    setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root));
-    setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]);
-
-    /** @var ProjectWorkspace $workspace */
-    $workspace = getEditorProperty($editor, 'workspace');
-    $removedItem = strval($workspace->getRecordDatabase('items')?->removeRecord(0)?->get('id'));
-    $removedWeapon = strval($workspace->getRecordDatabase('weapons')?->removeRecord(0)?->get('id'));
-    $workspace->getRecordDatabase('armors')?->setField(0, 'price', '999');
-
-    callEditorMethod($editor, 'saveAllAssets');
-
-    $after = inventoryIds($root);
-
-    expect($after['items'])->toBe(array_values(array_diff($before['items'], [$removedItem])))
-        ->and($after['weapons'])->toBe(array_values(array_diff($before['weapons'], [$removedWeapon])))
-        ->and($after['armors'])->toBe($before['armors'])
-        ->and((string) file_get_contents($path))->toContain('price: 999,')
-        ->and($workspace->hasUnsavedChanges())->toBeFalse();
-})->group('engine');
-
-it('leaves every byte and every category alone when the write is refused', function () {
-    $project = inventoryTransactionProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = (string) file_get_contents($path);
-    $open = inventoryCategories($root);
-    $open['items']->removeRecord(0);
-    $open['weapons']->removeRecord(0);
-
-    chmod($root . '/assets/Data', 0o555);
-    set_error_handler(static fn(): bool => true, E_WARNING);
-
-    try {
-        expect(static fn() => SharedFileTransaction::commit(array_values($open)))
-            ->toThrow(RuntimeException::class);
-
-        expect((string) file_get_contents($path))->toBe($before)
-            ->and($open['items']->isDirty())->toBeTrue()
-            ->and($open['weapons']->isDirty())->toBeTrue();
-    } finally {
-        restore_error_handler();
-        chmod($root . '/assets/Data', 0o755);
-    }
-})->group('engine');
-
-it('undoes and redoes exactly across an intermediate sibling save', function () {
-    $project = inventoryTransactionProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = inventoryIds($root);
-
-    $editor = createEditorForTesting($root);
-    setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root));
-    setEditorProperty($editor, 'lastTerminalSize', ['width' => 140, 'height' => 40]);
-    callEditorMethod($editor, 'openDatabaseAtCategory', 'items');
-
-    $fields = array_column(callEditorMethod($editor, 'getDatabaseSettingsFields'), null, 'field');
-    callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields['price'], '777');
-    callEditorMethod($editor, 'saveAllAssets');
-
-    /** @var ProjectWorkspace $workspace */
-    $workspace = getEditorProperty($editor, 'workspace');
-    $removedWeapon = strval($workspace->getRecordDatabase('weapons')?->removeRecord(0)?->get('id'));
-    callEditorMethod($editor, 'saveAllAssets');
-
-    callEditorMethod($editor, 'performUndo');
-    callEditorMethod($editor, 'saveAllAssets');
-
-    $undone = inventoryIds($root);
-
-    // The price edit is undone; the sibling's removal, saved in between,
-    // stays removed.
-    expect(ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items'))->getRecordByIndex(0)?->get('price'))
-        ->not->toBe(777)
-        ->and($undone['weapons'])->toBe(array_values(array_diff($before['weapons'], [$removedWeapon])));
-
-    callEditorMethod($editor, 'performRedo');
-    callEditorMethod($editor, 'saveAllAssets');
-
-    expect(ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey('items'))->getRecordByIndex(0)?->get('price'))
-        ->toBe(777)
-        ->and(inventoryIds($root)['weapons'])->toBe($undone['weapons']);
-})->group('engine');
-
-it('writes nothing when a grouped save has nothing to do', function () {
-    $project = inventoryTransactionProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = sourceHashTree($root);
-    $modifiedAt = filemtime($path);
-    $open = inventoryCategories($root);
-
-    expect(SharedFileTransaction::commit(array_values($open)))->toBeFalse()
-        ->and(sourceHashTree($root))->toBe($before)
-        ->and(filemtime($path))->toBe($modifiedAt);
-})->group('engine');
diff --git a/tests/Unit/SkillRecordFilesTest.php b/tests/Unit/SkillRecordFilesTest.php
new file mode 100644
index 00000000..8c33d66a
--- /dev/null
+++ b/tests/Unit/SkillRecordFilesTest.php
@@ -0,0 +1,139 @@
+stats->attack * 3')]),
+        new MagicSkill('Ember', 'Burns.', '', 5, 0),
+    );
+
+    return $root;
+}
+
+it('lists every skill record in file order, numbered, with its kind and effects as data', function () {
+    $skills = loadRecordDatabase(skillRecordsProject(), 'skills');
+
+    expect($skills->getEntryLabels())->toBe(['Attack', 'Lunge', 'Ember'])
+        ->and(array_map(static fn($record): string => $record->recordId, $skills->getRecords()))->toBe(['0001-attack', '0002-lunge', '0003-ember'])
+        ->and($skills->getRecordByIndex(1)?->get('kind'))->toBe('special')
+        ->and($skills->getRecordByIndex(1)?->get('effects'))->toBe([['type' => 'hp_damage', 'formula' => '$user->stats->attack * 3', 'variance' => 0.2]]);
+});
+
+it('creates and copies a skill under the next number, last in the list as reopening shows it', function () {
+    $root = skillRecordsProject();
+    $skills = loadRecordDatabase($root, 'skills');
+    $authoring = new RecordAuthoring();
+
+    $created = $authoring->createRecord($skills);
+    $copied = $authoring->duplicateRecord($skills, 0);
+    $skills->save();
+
+    expect([$created->index, $copied->index])->toBe([3, 4])
+        ->and(array_map(basename(...), glob($root . '/assets/Data/Skills/*.php') ?: []))
+        ->toBe(['0001-attack.php', '0002-lunge.php', '0003-ember.php', '0004-new-skill.php', '0005-attack-2.php'])
+        ->and(array_keys(SkillCatalog::load($root . '/assets')->getSkills()))->toBe(['Attack', 'Lunge', 'Ember', 'New Skill', 'Attack-2'])
+        ->and(loadRecordDatabase($root, 'skills')->getEntryLabels())->toBe(['Attack', 'Lunge', 'Ember', 'New Skill', 'Attack-2']);
+});
+
+it('authors effects by type, keeping only what each type reads', function () {
+    $root = skillRecordsProject();
+    $skills = loadRecordDatabase($root, 'skills');
+    $field = static fn(string $name): string => ProjectRecordDatabase::subFieldId('effect', 0, $name);
+
+    $skills->setField(1, $field('element'), 'Fire');
+    $skills->setField(1, $field('type'), 'add_state');
+    $skills->setField(1, $field('stateId'), 'poison');
+    $skills->save();
+
+    $lunge = SkillCatalog::load($root . '/assets')->findSkill('Lunge');
+
+    expect($skills->getRecordByIndex(1)?->get('effects'))->toBe([['type' => 'add_state', 'stateId' => 'poison']])
+        ->and($lunge?->effects[0]?->stateId)->toBe('poison')
+        ->and(SkillCatalog::load($root . '/assets')->getProblems())->toBe([]);
+});
+
+it('authors a reveal effect the Engine reads back, with its facts chosen from the Engine\'s own', function () {
+    $root = skillRecordsProject();
+    $skills = loadRecordDatabase($root, 'skills');
+    $field = static fn(string $name): string => ProjectRecordDatabase::subFieldId('effect', 0, $name);
+
+    $skills->setField(1, $field('type'), 'reveal');
+    $skills->setField(1, $field('facts'), 'hp, stats');
+    $skills->setField(1, $field('chancePercent'), '75');
+    $skills->save();
+
+    $effect = SkillCatalog::load($root . '/assets')->findSkill('Lunge')?->effects[0];
+
+    expect($skills->getRecordByIndex(1)?->get('effects'))->toBe([['type' => 'reveal', 'facts' => ['hp', 'stats'], 'chancePercent' => 75]])
+        ->and($effect)->toBeInstanceOf(RevealSkillEffect::class)
+        ->and($effect?->facts)->toBe([RevealFact::HP, RevealFact::STATS])
+        ->and((new ReferenceCatalog(ProjectWorkspace::fromProject($root)))->valuesFor('reveal_facts'))
+        ->toBe(['hp', 'mp', 'stats', 'affinities']);
+});
+
+it('authors a state\'s confusion chance and leaves it out when cleared', function () {
+    $states = loadRecordDatabase(makeTemporaryProject(), 'states');
+
+    $states->setField(0, 'confusionChancePercent', '100');
+    expect($states->getRecordByIndex(0)?->get('confusionChancePercent'))->toBe(100);
+    $states->setField(0, 'confusionChancePercent', '');
+    expect(array_key_exists('confusionChancePercent', $states->getRecordByIndex(0)?->toArray() ?? []))->toBeFalse();
+});
+
+it('seeds a new skill with the action-name battle message and previews what each message shows, as a battle formats it', function () {
+    $root = skillRecordsProject();
+    $skills = loadRecordDatabase($root, 'skills');
+    $created = (new RecordAuthoring())->createRecord($skills);
+    $skills->setField(0, 'invocation.message', '%1 swings at the foe!');
+    $skills->setField(1, 'invocation.message', '');
+    $skills->save();
+    $workspace = Ichiloto\Editor\ProjectWorkspace::fromProject($root);
+    $lines = static fn(int $index): string => implode("\n", array_merge(...array_map(
+        static fn(array $pane): array => array_map('strval', (array) ($pane['lines'] ?? $pane[1] ?? [])),
+        array_values(Ichiloto\Editor\Database\RecordPanes::describe($workspace, 'skills', $index)),
+    )));
+
+    expect(loadRecordDatabase($root, 'skills')->getRecordByIndex($created->index)?->get('invocation')['message'])->toBe('%2')
+        ->and(array_find(Ichiloto\Editor\Database\RecordSchemaCatalog::forKey('skills')->fields,
+            static fn($field): bool => $field->key === 'invocation.message')->label)->toBe('Battle Message')
+        ->and($lines(0))->toContain('Shows: Actor swings at the foe!')
+        ->and($lines(1))->toContain('Shows: nothing, the skill is used silently')
+        ->and($lines(2))->toContain('Shows: Ember')
+        ->and($lines(2))->toContain('%1 = who acts, %2 = this skill, blank = no message');
+});
+
+it('authors a treatment chance on a cure and the Engine reads it back', function () {
+    $root = skillRecordsProject();
+    $skills = loadRecordDatabase($root, 'skills');
+    $field = static fn(string $name): string => ProjectRecordDatabase::subFieldId('effect', 0, $name);
+
+    $skills->setField(1, $field('type'), 'remove_state');
+    $skills->setField(1, $field('stateIds'), 'poison');
+    $skills->setField(1, $field('chancePercent'), '75');
+    $skills->save();
+
+    expect($skills->getRecordByIndex(1)?->get('effects'))->toBe([['type' => 'remove_state', 'stateIds' => ['poison'], 'chancePercent' => 75]])
+        ->and(SkillCatalog::load($root . '/assets')->findSkill('Lunge')?->effects[0]?->chancePercent)->toBe(75)
+        ->and(SkillCatalog::load($root . '/assets')->getProblems())->toBe([]);
+});
diff --git a/tests/Unit/SkitAndEventEditorTest.php b/tests/Unit/SkitAndEventEditorTest.php
index db26da80..c84faff6 100644
--- a/tests/Unit/SkitAndEventEditorTest.php
+++ b/tests/Unit/SkitAndEventEditorTest.php
@@ -4,6 +4,9 @@
 
 use Ichiloto\Editor\Database\ConditionCodec;
 use Ichiloto\Editor\Database\RecordSchemaCatalog;
+use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandDefinition;
+use Ichiloto\Engine\Events\Interpreter\Commands\ScriptCommandRegistry;
+use Ichiloto\Engine\Events\Interpreter\EventExecutionScope;
 
 it('round-trips the engine condition grammar', function (): void {
     $encoded = 'quest:breakfast-duty:active; !switch:door_open:false; item:S-Potion:3; variable:gold:>=:100';
@@ -70,7 +73,7 @@
     $database->save();
 
     $payload = require $root . '/assets/Data/Skits/breakfast-banter.php';
-    expect($payload['beats'][2])->toBe(['speaker' => 'Drazek', 'text' => 'I have seen her with a wooden spoon.']);
+    expect($payload['beats'][2])->toEqual(['speaker' => 'Drazek', 'text' => 'I have seen her with a wooden spoon.']);
 
     $removed = $database->removeSubItem(0, 2);
     expect($removed['speaker'])->toBe('Drazek');
@@ -79,6 +82,87 @@
     removeDirectoryRecursively($root);
 });
 
+it('selects stable skit actors and keeps non-actor speaker text mutually exclusive', function (): void {
+    $root = makeTemporaryProject();
+    $editor = deletionEditor($root);
+    openDatabaseCategory($editor, 'skits');
+    $workspace = getEditorProperty($editor, 'workspace');
+    $database = $workspace->getRecordDatabase('skits');
+    $fields = array_column($database->getSettingsFields(0), null, 'field');
+    $actor = $workspace->actorDatabase->getActors()[0];
+    expect($fields['beat0Actor']['reference'])->toBe('actor_ids')
+        ->and(isset($fields['beat0Actor']['control']))->toBeFalse()
+        ->and(isset($fields['beat0Speaker']['reference']))->toBeFalse();
+    callEditorMethod($editor, 'applyDatabaseFieldValueRecorded', $fields['beat0Actor'], $actor->getDefinitionId());
+    $database->save();
+    $path = $root . '/assets/Data/Skits/breakfast-banter.php';
+    $beat = (require $path)['beats'][0];
+    expect($beat['actor'])->toBe($actor->getDefinitionId())->and($beat)->not->toHaveKey('speaker');
+    callEditorMethod($editor, 'performUndo');
+    expect($database->getRecords()[0]->toArray()['beats'][0]['speaker'])->toBe('Liora');
+    callEditorMethod($editor, 'performRedo');
+    expect($database->getRecords()[0]->toArray()['beats'][0]['actor'])->toBe($actor->getDefinitionId());
+    $database->setField(0, 'beat0Speaker', 'Innkeeper');
+    $database->save();
+    $beat = (require $path)['beats'][0];
+    expect($beat['speaker'])->toBe('Innkeeper')->and($beat)->not->toHaveKey('actor');
+});
+
+it('validates skit actor identity with the shared runtime resolver and warns on legacy speaker ids', function (): void {
+    $root = makeTemporaryProject();
+    $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root);
+    $actor = $workspace->actorDatabase->getActors()[0];
+    $database = $workspace->getRecordDatabase('skits');
+    $record = $database->getRecords()[0];
+    $record->setSubList('beats', [
+        ['actor' => $actor->getDefinitionId(), 'text' => 'Canonical'],
+        ['speaker' => $actor->getDefinitionId(), 'text' => 'Legacy'],
+        ['actor' => 'not-an-actor', 'text' => 'Invalid'],
+        ['actor' => $actor->getDefinitionId(), 'speaker' => 'Innkeeper', 'text' => 'Conflict'],
+        ['speaker' => 'Innkeeper', 'text' => 'Plain text'],
+    ]);
+    $validator = new \Ichiloto\Editor\Validation\ProjectValidator();
+    $method = new ReflectionMethod($validator, 'checkSkitReferences');
+    $issues = $method->invoke($validator, $workspace, ['maps' => ['happyville/town-center'], 'quests' => ['breakfast-duty']]);
+    $speakers = array_values(array_filter($issues, static fn($issue): bool => str_contains($issue->where, ' beat ')));
+    expect($speakers)->toHaveCount(3)
+        ->and($speakers[0]->message)->toContain('Deprecated')
+        ->and($speakers[1]->message)->toContain('stable actor id')
+        ->and($speakers[2]->message)->toContain('not both');
+    expect($speakers[1]->hint)->toContain('existing actor')->not->toContain('not both');
+});
+
+it('uses unsaved and renamed actors consistently in the skit picker and validator', function (): void {
+    $root = makeTemporaryProject();
+    $workspace = \Ichiloto\Editor\ProjectWorkspace::fromProject($root);
+    $index = $workspace->actorDatabase->addActor('Liora');
+    $workspace->actorDatabase->setField($index, 'name', 'Liora Vey');
+    $catalog = new \Ichiloto\Editor\Database\ReferenceCatalog($workspace);
+    expect($catalog->valuesFor('actor_ids'))->toContain('Liora')
+        ->and($catalog->labelsFor('actor_ids')['Liora'])->toBe('Liora Vey (Liora)')
+        ->and(is_file($root . '/assets/Data/Actors/Liora.php'))->toBeFalse();
+    $workspace->getRecordDatabase('skits')->getRecords()[0]->setSubList('beats', [['actor' => 'Liora', 'text' => 'Hello.']]);
+    $validator = new \Ichiloto\Editor\Validation\ProjectValidator();
+    $issues = new ReflectionMethod($validator, 'checkSkitReferences')->invoke($validator, $workspace,
+        ['maps' => ['happyville/town-center'], 'quests' => ['breakfast-duty']]);
+    expect($issues)->toBeEmpty();
+});
+
+it('opens the Actor picker when adding a beat or creating a skit instead of inventing a speaker', function (): void {
+    $root = makeTemporaryProject();
+    $editor = deletionEditor($root);
+    openDatabaseCategory($editor, 'skits');
+    callEditorMethod($editor, 'addDatabaseRecordSubItem');
+    $picker = getEditorProperty($editor, 'referencePicker');
+    expect($picker->isOpen())->toBeTrue();
+    $database = getEditorProperty($editor, 'workspace')->getRecordDatabase('skits');
+    expect($database->getRecords()[0]->toArray()['beats'][2])->toBe(['actor' => '', 'text' => 'Say something.']);
+    $picker->close();
+    callEditorMethod($editor, 'createDatabaseRecord');
+    expect($picker->isOpen())->toBeTrue()
+        ->and($database->getRecords()[1]->toArray()['beats'][0])->toBe(['actor' => '', 'text' => 'Say something.']);
+});
+
 it('creates a new skit as its own file', function (): void {
     $root = makeTemporaryProject();
     $database = loadRecordDatabase($root, 'skits');
@@ -122,7 +206,7 @@
     expect($byField)->not->toHaveKey('command0Amount');
 });
 
-it('offers every interpreter command type as a cycleable option', function (): void {
+it('offers every built-in and registered command type as a cycleable option', function (): void {
     $root = makeTemporaryProject();
     $database = loadRecordDatabase($root, 'common_events');
 
@@ -135,7 +219,11 @@
     }
 
     expect($typeRow)->not->toBeNull();
-    expect($typeRow['options'])->toBe(RecordSchemaCatalog::EVENT_COMMAND_TYPES);
+    // The interpreter's own commands, then every service the Engine registers for field scripts, as it registers them.
+    $registered = array_keys(array_filter(ScriptCommandRegistry::getCatalog()->definitions,
+        static fn(ScriptCommandDefinition $definition): bool => $definition->runsIn(EventExecutionScope::FIELD)));
+    expect($typeRow['options'])->toBe([...RecordSchemaCatalog::EVENT_COMMAND_TYPES, ...$registered])
+        ->and($registered)->toContain('shop', 'inn');
 
     removeDirectoryRecursively($root);
 });
diff --git a/tests/Unit/SourceCheckoutTest.php b/tests/Unit/SourceCheckoutTest.php
new file mode 100644
index 00000000..04632695
--- /dev/null
+++ b/tests/Unit/SourceCheckoutTest.php
@@ -0,0 +1,23 @@
+toBe(realpath($root));
+        $copy = disposableLastLegend();
+        expect(is_link($copy . '/assets/Audio'))->toBeTrue()
+            ->and(file_get_contents($copy . '/assets/Audio/reference.wav'))->toBe('fixture audio bytes');
+    } finally {
+        chdir($directory);
+        putenv($previous === false ? 'ICHILOTO_GAME_SRC' : 'ICHILOTO_GAME_SRC=' . $previous);
+    }
+});
diff --git a/tests/Unit/SourceFreshnessTest.php b/tests/Unit/SourceFreshnessTest.php
new file mode 100644
index 00000000..8f728b55
--- /dev/null
+++ b/tests/Unit/SourceFreshnessTest.php
@@ -0,0 +1,439 @@
+ 'guide', 'name' => 'Guide', 'x' => 1, 'y' => 1,
+            'script' => [['type' => 'transfer', 'map' => 'test-map', 'x' => 2, 'y' => 1]]]];
+        $source = str_replace("'futureValue' => 7", "'futureValue' => abs(7)", var_export($data, true));
+        file_put_contents($path, " 'camera', 'operation' => 'pan', 'target' => ['kind' => 'position', 'x' => 2, 'y' => 1], 'seconds' => 0.5],
+        ['type' => 'wait', 'seconds' => abs(1)],
+    ];\n");
+    return [$root, $path, 'harbour'];
+}
+
+function readSourceFreshnessPlacement(EditorSession $session, string $kind, string $map): array
+{
+    $view = $kind === 'map' ? $session->readNpc($map, 0, ['script'])
+        : $session->readDatabaseRecord('cutscenes/cinematic', 0, ['commands']);
+    $row = array_find($view['rows'], static fn(array $row): bool => isset($row['mapPlacement']))
+        ?? throw new RuntimeException('The synthetic placement is missing.');
+    $context = $kind === 'map' ? ['kind' => 'npc', 'map' => $map, 'revision' => $view['revision'], 'index' => 0]
+        : ['kind' => 'database', 'category' => 'cutscenes/cinematic', 'index' => 0];
+    return [$row, $context];
+}
+
+/** A sparse route shares its owner with untouched legacy Inn references. */
+function createSourceFreshnessRouteProject(string $kind): array
+{
+    [$root, $path, $map] = createSourceFreshnessProject($kind);
+    $commands = [
+        ['type' => 'move_route', 'subject' => 'player', 'waypoints' => [['x' => 2], ['y' => 1]], 'secondsPerStep' => 0.3],
+        ['type' => 'inn', 'cost' => 5, 'confirmDialogue' => ['text' => 'Synthetic rest?'],
+            'presentation' => ['treatment' => 'leader', 'leaders' => ['retired-actor' => 'retired-rest']]],
+        ['type' => 'wait', 'seconds' => 1],
+    ];
+    $data = $kind === 'map' ? require $path : $commands;
+    if ($kind === 'map') { $data['npcs'][0]['script'] = $commands; }
+    else {
+        $metadataPath = str_replace('.script.php', '.data.php', $path);
+        $metadata = require $metadataPath;
+        $metadata['skip'] = ['policy' => 'forbidden'];
+        file_put_contents($metadataPath, " 7", "'seconds' => 1"], ["'futureValue' => abs(7)", "'seconds' => abs(1)"], var_export($data, true));
+    file_put_contents($path, "setMapField('description', $name);
+    } else {
+        $owner->apply([...$owner->payload(), 'name' => $name]);
+    }
+}
+
+function rewriteSourceFreshnessFile(string $path, string $mode): void
+{
+    $source = (string) file_get_contents($path);
+    $changed = match ($mode) {
+        'literal' => str_replace("'x' => 2", "'x' => 3", $source),
+        'expression' => str_replace("'x' => 2", "'x' => (1 + 1)", $source),
+        'comment' => $source . "\n// Written by another author.\n",
+    };
+    if ($changed === $source) {
+        throw new RuntimeException('The synthetic rewrite did not change the source.');
+    }
+    file_put_contents($path, $changed);
+}
+
+it('refuses external source changes before first placement review and after review without mutating cached data', function (string $kind, string $mode, bool $reviewed) {
+    [$root, $path, $map] = createSourceFreshnessProject($kind);
+    $session = EditorSession::open($root);
+    $revision = $session->readMap($map)['revision'];
+    if ($reviewed) {
+        [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map);
+        expect($row['mapPlacement']['issue'])->toBeNull();
+    }
+    rewriteSourceFreshnessFile($path, $mode);
+    $before = sourceHashTree($root);
+    if (! $reviewed) {
+        [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map);
+        expect($row['mapPlacement']['issue'])->toContain('changed after opening');
+    }
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 2, null, $revision))
+        ->toThrow(SessionRefusal::class);
+    [$current] = readSourceFreshnessPlacement($session, $kind, $map);
+    expect($current['mapPlacement']['points'][0]['point'])->toBe([2, 1])
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($before);
+})->with(['map', 'cinematic'])->with(['literal', 'comment', 'expression'])->with([false, true]);
+
+it('refuses stale ordinary field descriptors before first review and after review', function (string $kind, string $mode, bool $reviewed) {
+    [$root, $path, $map] = createSourceFreshnessProject($kind);
+    $session = EditorSession::open($root);
+    $session->readMap($map);
+    $read = static fn() => $kind === 'map' ? $session->readInspector($map)
+        : $session->readDatabaseRecord('cutscenes/cinematic', 0);
+    if ($reviewed) {
+        expect(array_any($read()['rows'], static fn(array $row): bool => isset($row['mapPlacement'])))->toBeFalse();
+    }
+    rewriteSourceFreshnessFile($path, $mode);
+    $before = sourceHashTree($root);
+    expect($read)->toThrow(SessionRefusal::class, 'changed after opening');
+    if ($kind === 'map') {
+        expect(fn() => $session->readNpc($map, 0))->toThrow(SessionRefusal::class, 'changed after opening');
+    }
+    expect($session->undo()['label'])->toBeNull()->and(sourceHashTree($root))->toBe($before);
+})->with(['map', 'cinematic'])->with(['literal', 'comment', 'expression'])->with([false, true]);
+
+it('keeps ordinary field review available for dirty edits and missing source recovery', function (string $kind) {
+    [$root, $path, $map] = createSourceFreshnessProject($kind);
+    $session = EditorSession::open($root);
+    $session->readMap($map);
+    $read = static fn() => $kind === 'map' ? $session->readInspector($map)
+        : $session->readDatabaseRecord('cutscenes/cinematic', 0);
+    $view = $read();
+    $field = $kind === 'map' ? 'description' : 'name';
+    $row = array_find($view['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === $field)
+        ?? throw new RuntimeException('The synthetic ordinary field is missing.');
+    if ($kind === 'map') {
+        $session->applyInspector($map, $view['revision'], $row['key'], 'Pending internal edit');
+    } else {
+        $session->applyDatabaseRecord('cutscenes/cinematic', 0, $row['key'], 'Pending internal edit');
+    }
+    expect(array_find($read()['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === $field)['value'])
+        ->toBe('Pending internal edit');
+    unlink($path);
+    expect($read()['rows'])->not->toBeEmpty();
+    if ($kind === 'map') { $session->saveMap($map); }
+    else { $session->saveDatabase('cutscenes/cinematic'); }
+    $reopened = loadSourceFreshnessOwner($root, $kind);
+    expect($reopened instanceof ProjectMap ? $reopened->getEditableData()['description'] : $reopened->payload()['name'])
+        ->toBe('Pending internal edit')->and(file_get_contents($path))->toContain('Authored', 'abs(');
+    $session->undo();
+    expect($read()['rows'])->not->toBeEmpty();
+})->with(['map', 'cinematic']);
+
+it('refuses stale direct authoring and saves while keeping dirty internal edits intact', function (string $kind, string $mode, bool $dirty) {
+    [$root, $path] = createSourceFreshnessProject($kind);
+    $owner = loadSourceFreshnessOwner($root, $kind);
+    if ($dirty) {
+        editSourceFreshnessOwner($owner, 'Pending internal edit');
+    }
+    $owner->assertSourcesUnchanged();
+    $state = $owner instanceof ProjectMap ? $owner->getEditableData() : $owner->captureEditState();
+    $version = $owner->stateVersion();
+    rewriteSourceFreshnessFile($path, $mode);
+    $before = sourceHashTree($root);
+    $refusal = $kind === 'map' ? MapSourceRefusal::class : FileSetTransactionFailure::class;
+    expect(fn() => $owner->assertSourcesUnchanged())->toThrow($refusal, 'changed after opening')
+        ->and(fn() => editSourceFreshnessOwner($owner, 'Must not apply'))->toThrow($refusal, 'changed after opening')
+        ->and(fn() => $owner->save())->toThrow($refusal, 'changed after opening');
+    expect($owner instanceof ProjectMap ? $owner->getEditableData() : $owner->captureEditState())->toBe($state)
+        ->and($owner->stateVersion())->toBe($version)
+        ->and($owner->isDirty())->toBe($dirty)
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and(glob(dirname($path) . '/*.tmp-*'))->toBe([]);
+})->with(['map', 'cinematic'])->with(['literal', 'comment', 'expression'])->with([false, true]);
+
+it('refuses a stale displayed map independently of cinematic placement source', function (string $member) {
+    [$root, $path, $map] = createSourceFreshnessProject('cinematic');
+    $session = EditorSession::open($root);
+    [$row, $context] = readSourceFreshnessPlacement($session, 'cinematic', $map);
+    $revision = $session->readMap($map)['revision'];
+    $mapPath = $root . '/assets/Maps/harbour/harbour.' . $member . '.php';
+    file_put_contents($mapPath, file_get_contents($mapPath) . "\n// External preview-map edit.\n");
+    $before = sourceHashTree($root);
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 2, null, $revision))
+        ->toThrow(SessionRefusal::class, 'changed after opening');
+    expect($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($before)
+        ->and(file_get_contents($path))->toContain("'x' => 2");
+})->with(['data', 'map', 'event']);
+
+it('keeps the owner baseline correct across dirty placement undo save redo and reopen', function (string $kind) {
+    [$root, $path, $map] = createSourceFreshnessProject($kind);
+    $original = file_get_contents($path);
+    $session = EditorSession::open($root);
+    [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map);
+    $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 2, null, $session->readMap($map)['revision']);
+    $save = static fn() => $kind === 'map' ? $session->saveMap($map) : $session->saveDatabase('cutscenes/cinematic');
+    $save();
+    expect(file_get_contents($path))->toContain("'x' => 4", 'abs(');
+    $session->undo();
+    $save();
+    expect(file_get_contents($path))->toBe($original);
+    $session->redo();
+    $save();
+    [$reopened] = readSourceFreshnessPlacement(EditorSession::open($root), $kind, $map);
+    expect($reopened['mapPlacement']['points'][0]['point'])->toBe([4, 2])
+        ->and($reopened['mapPlacement']['issue'])->toBeNull()
+        ->and(file_get_contents($path))->toContain('Authored', 'abs(');
+})->with(['map', 'cinematic']);
+
+it('keeps nullable waypoint placement compatible with unchanged Inn semantics and owner baselines', function (string $kind) {
+    [$root, $path, $map] = createSourceFreshnessRouteProject($kind);
+    $original = file_get_contents($path);
+    $session = EditorSession::open($root);
+    [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map);
+    expect($row['mapPlacement']['kind'])->toBe('waypoints')
+        ->and($row['mapPlacement']['points'][0]['point'])->toBe([2, null])
+        ->and(array_keys($row['mapPlacement']['points'][0]['fields']))->toBe(['x'])
+        ->and($row['mapPlacement']['points'][1]['point'])->toBe([2, 1])
+        ->and(array_keys($row['mapPlacement']['points'][1]['fields']))->toBe(['y']);
+    $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 3, [1, 1], $session->readMap($map)['revision']);
+    $save = static fn() => $kind === 'map' ? $session->saveMap($map) : $session->saveDatabase('cutscenes/cinematic');
+    $save();
+    $saved = require $path;
+    $commands = $kind === 'map' ? $saved['npcs'][0]['script'] : $saved;
+    expect($commands[0]['waypoints'])->toBe([['x' => 4], ['y' => 1]])
+        ->and($commands[0]['secondsPerStep'])->toBe(0.3)
+        ->and($commands[1]['presentation'])->toBe(['treatment' => 'leader', 'leaders' => ['retired-actor' => 'retired-rest']])
+        ->and(file_get_contents($path))->toContain('Authored mixed route and Inn source', 'abs(1)');
+    $session->undo();
+    $save();
+    expect(file_get_contents($path))->toBe($original);
+    $session->redo();
+    $save();
+    [$reopened] = readSourceFreshnessPlacement(EditorSession::open($root), $kind, $map);
+    expect($reopened['mapPlacement']['points'][0]['point'])->toBe([4, null])
+        ->and($reopened['mapPlacement']['points'][1]['point'])->toBe([4, 1])
+        ->and($reopened['mapPlacement']['issue'])->toBeNull();
+})->with(['map', 'cinematic']);
+
+it('refuses stale sparse waypoint placement beside untouched Inn references', function (string $kind, bool $reviewed) {
+    [$root, $path, $map] = createSourceFreshnessRouteProject($kind);
+    $session = EditorSession::open($root);
+    $revision = $session->readMap($map)['revision'];
+    if ($reviewed) { [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); }
+    rewriteSourceFreshnessFile($path, 'comment');
+    $before = sourceHashTree($root);
+    if (!$reviewed) { [$row, $context] = readSourceFreshnessPlacement($session, $kind, $map); }
+    expect(fn() => $session->applyMapPlacement($context, $row['key'], $row['mapPlacement'], $map, 0, 4, 3, [1, 1], $revision))
+        ->toThrow(SessionRefusal::class);
+    [$current] = readSourceFreshnessPlacement($session, $kind, $map);
+    expect($current['mapPlacement']['issue'])->toContain('changed after opening')
+        ->and($current['mapPlacement']['points'][0]['point'])->toBe([2, null])
+        ->and($session->undo()['label'])->toBeNull()
+        ->and(sourceHashTree($root))->toBe($before);
+})->with(['map', 'cinematic'])->with([false, true]);
+
+it('recovers missing map data or the complete map folder without losing internal edits', function (bool $wholeFolder, bool $dirty) {
+    [$root, $path] = createSourceFreshnessProject('map');
+    $owner = loadSourceFreshnessOwner($root, 'map');
+    $original = (string) file_get_contents($path);
+    if ($wholeFolder) {
+        removeDirectoryRecursively(dirname($path));
+    } else {
+        unlink($path);
+    }
+    $owner->assertSourcesUnchanged();
+    if ($dirty) {
+        editSourceFreshnessOwner($owner, 'Recovered internal edit');
+    }
+    $owner->save();
+    $reopened = loadSourceFreshnessOwner($root, 'map');
+    expect($reopened->getDescription())->toBe($dirty ? 'Recovered internal edit' : 'Loaded map')
+        ->and($owner->isDirty())->toBeFalse()
+        ->and(file_get_contents($path))->toContain('Authored map source.', 'abs(7)');
+    if (! $dirty) {
+        expect(file_get_contents($path))->toBe($original);
+    }
+})->with([false, true])->with([false, true]);
+
+it('recovers missing cinematic members exactly and adopts their new saved baseline', function (string $member, bool $dirty) {
+    [$root, $path] = createSourceFreshnessProject('cinematic');
+    $owner = loadSourceFreshnessOwner($root, 'cinematic');
+    $original = sourceHashTree($owner->folder);
+    foreach ($owner->paths() as $owned) {
+        if ($member === 'both' || str_ends_with($owned, '.' . $member . '.php')) {
+            unlink($owned);
+        }
+    }
+    $owner->assertSourcesUnchanged();
+    if ($dirty) {
+        editSourceFreshnessOwner($owner, 'Recovered cinematic');
+    }
+    expect($owner->save())->toBeTrue();
+    $owner->assertSourcesUnchanged();
+    $reopened = loadSourceFreshnessOwner($root, 'cinematic');
+    expect($owner->isDirty())->toBeFalse()
+        ->and($reopened->name())->toBe($dirty ? 'Recovered cinematic' : $owner->name())
+        ->and(file_get_contents($path))->toContain('Authored cinematic source.', 'abs(1)');
+    if (! $dirty) {
+        expect(sourceHashTree($owner->folder))->toBe($original);
+    }
+    editSourceFreshnessOwner($owner, 'Another internal edit');
+    $owner->save();
+    expect(loadSourceFreshnessOwner($root, 'cinematic')->name())->toBe('Another internal edit');
+})->with(['data', 'script', 'both'])->with([false, true]);
+
+it('refuses directory replacements rather than treating them as recoverable missing files', function (string $kind) {
+    [$root, $path] = createSourceFreshnessProject($kind);
+    $owner = loadSourceFreshnessOwner($root, $kind);
+    unlink($path);
+    mkdir($path);
+    $before = sourceHashTree($root);
+    expect(fn() => $owner->save())->toThrow(RuntimeException::class, 'changed after opening')
+        ->and(is_dir($path))->toBeTrue()
+        ->and(sourceHashTree($root))->toBe($before);
+})->with(['map', 'cinematic']);
+
+it('refuses new cinematic target collisions rather than overwriting another author', function () {
+    $root = cutsceneProject();
+    $owner = CutsceneAsset::create(CutsceneType::CINEMATIC, 'new-scene', $root . '/assets/Cutscenes/Cinematics',
+        ['id' => 'new-scene', 'name' => 'New Scene', 'commands' => []], $root);
+    mkdir($owner->folder);
+    file_put_contents($owner->partnerPath(), " $owner->assertSourcesUnchanged())->toThrow(FileSetTransactionFailure::class, 'changed after opening')
+        ->and(fn() => $owner->save())->toThrow(FileSetTransactionFailure::class, 'changed after opening')
+        ->and($owner->isNew())->toBeTrue()
+        ->and($owner->isDirty())->toBeTrue()
+        ->and(is_file($owner->dataPath()))->toBeFalse()
+        ->and(sourceHashTree($root))->toBe($before);
+});
+
+it('refuses stale deletion and recovers the saved deletion through its existing undo state', function () {
+    [$root, $path] = createSourceFreshnessProject('cinematic');
+    $owner = loadSourceFreshnessOwner($root, 'cinematic');
+    $state = $owner->captureEditState();
+    $original = sourceHashTree($owner->folder);
+    $source = (string) file_get_contents($path);
+    $owner->markDeleted(true);
+    rewriteSourceFreshnessFile($path, 'comment');
+    $before = sourceHashTree($root);
+    expect(fn() => $owner->save())->toThrow(FileSetTransactionFailure::class, 'changed after opening')
+        ->and(sourceHashTree($root))->toBe($before);
+    file_put_contents($path, $source);
+    $owner->save();
+    expect(array_filter($owner->paths(), is_file(...)))->toBe([]);
+    $owner->restoreEditState($state);
+    $owner->save();
+    expect(sourceHashTree($owner->folder))->toBe($original)
+        ->and(loadSourceFreshnessOwner($root, 'cinematic')->isEditable())->toBeTrue();
+});
+
+it('guards unchanged members if another author writes during the save backup callback', function (string $kind) {
+    [$root, $path] = createSourceFreshnessProject($kind);
+    $owner = loadSourceFreshnessOwner($root, $kind);
+    editSourceFreshnessOwner($owner, 'Pending internal edit');
+    $target = $kind === 'map' ? $owner->eventPath : $owner->partnerPath();
+    $source = (string) file_get_contents($target);
+    $before = sourceHashTree($root);
+    expect(fn() => $owner->save(static function () use ($target, $source): void {
+        file_put_contents($target, $source . "\n// External edit during backup.\n");
+    }))->toThrow(FileSetTransactionFailure::class, 'changed after opening');
+    $before[substr($target, strlen($root) + 1)] = hash_file('sha256', $target);
+    expect(sourceHashTree($root))->toBe($before)
+        ->and($owner->isDirty())->toBeTrue()
+        ->and(glob(dirname($path) . '/*.tmp-*'))->toBe([]);
+})->with(['map', 'cinematic']);
+
+it('reviews expected sources without staging and preserves their access metadata', function () {
+    [$root, $path] = createSourceFreshnessProject('map');
+    $transaction = new FileSetTransaction($root . '/not-created');
+    $source = (string) file_get_contents($path);
+    $transaction->expectSource($path, $source);
+    touch($path, time() - 3600, time() - 7200);
+    clearstatcache(true, $path);
+    $before = stat($path);
+    $transaction->assertSourcesUnchanged();
+    clearstatcache(true, $path);
+    $after = stat($path);
+    expect(is_dir($root . '/not-created'))->toBeFalse()
+        ->and($after['atime'])->toBe($before['atime'])
+        ->and($after['mtime'])->toBe($before['mtime'])
+        ->and($after['mode'])->toBe($before['mode']);
+    rewriteSourceFreshnessFile($path, 'comment');
+    expect(fn() => $transaction->assertSourcesUnchanged())->toThrow(FileSetTransactionFailure::class, 'changed after opening');
+});
+
+it('refuses stale cinematic field edits before changing record state or history', function () {
+    [$root, $path] = createSourceFreshnessProject('cinematic');
+    $session = EditorSession::open($root);
+    $view = $session->readDatabaseRecord('cutscenes/cinematic', 0);
+    $name = array_find($view['rows'], static fn(array $row): bool => ($row['key']['field'] ?? '') === 'name');
+    $library = Ichiloto\Editor\Cutscenes\CutsceneLibrary::fromProject($root);
+    $asset = $library->find(CutsceneType::CINEMATIC, 'harbour-lanterns');
+    $version = $asset->stateVersion();
+    $state = $asset->captureEditState();
+    rewriteSourceFreshnessFile($path, 'comment');
+    expect(fn() => $session->applyDatabaseRecord('cutscenes/cinematic', 0, $name['key'], 'Must not apply'))
+        ->toThrow(SessionRefusal::class, 'changed after opening');
+    expect(fn() => $library->changeAsset(CutsceneType::CINEMATIC, 0, 'Must not apply',
+        static fn() => throw new LogicException('The stale authoring callback must not run.')))
+        ->toThrow(FileSetTransactionFailure::class, 'changed after opening');
+    expect($asset->captureEditState())->toBe($state)
+        ->and($asset->stateVersion())->toBe($version)
+        ->and($session->undo()['label'])->toBeNull();
+});
+
+it('refuses external creation of a pending map layer during save', function () {
+    $root = makeTemporaryProject('source-freshness-layers-');
+    $directory = $root . '/assets/Maps/fresh-map';
+    ProjectMap::createBlank($directory, 'fresh-map', 'Fresh Map', 12, 5);
+    $owner = ProjectMap::fromDirectory($root . '/assets/Maps', $directory);
+    $layer = $owner->createLayer('canopy', true);
+    $target = array_find($owner->getLayers(), static fn(array $entry): bool => $entry['id'] === $layer)['path'];
+    $before = sourceHashTree($root);
+    expect(fn() => $owner->save(static function () use ($target): void {
+        file_put_contents($target, "toThrow(FileSetTransactionFailure::class, 'changed after opening');
+    $before[substr($target, strlen($root) + 1)] = hash_file('sha256', $target);
+    ksort($before);
+    expect(sourceHashTree($root))->toBe($before)
+        ->and(file_get_contents($target))->toContain('Another author')
+        ->and($owner->isDirty())->toBeTrue();
+});
diff --git a/tests/Unit/StagedCharacterSheetPreviewTest.php b/tests/Unit/StagedCharacterSheetPreviewTest.php
new file mode 100644
index 00000000..a5ce3a35
--- /dev/null
+++ b/tests/Unit/StagedCharacterSheetPreviewTest.php
@@ -0,0 +1,84 @@
+ 'Graphics/Characters/People.png', 'index' => 5, 'layer' => 120];
+    $actor = ['id' => 'walker', 'sprite' => ['@'], 'x' => 1, 'y' => 2, 'sprites2d' => $sprites];
+    $wrapped = $owner === 'envelope';
+    $stage = $wrapped ? ['type' => 'stage_actor', 'actor' => $actor] : ['type' => 'stage_actor', ...$actor];
+    $command = ['type' => 'sequence', 'commands' => [$stage]];
+    $metadata = $root . '/assets/Cutscenes/Cinematics/harbour-lanterns/harbour-lanterns.data.php';
+    $data = require $metadata;
+    $data['cast'] = [['kind' => 'staged_actor', ...$actor]];
+    $data['skip'] = ['policy' => 'forbidden'];
+    file_put_contents($metadata, "readDatabaseRecord('cutscenes/cinematic', 0, $frame);
+
+    return array_find($view['rows'], static fn(array $row): bool => ($row['key']['field'] ?? null) === $field)
+        ?? throw new RuntimeException('Missing staged sheet preview row ' . $field);
+}
+
+it('shares standing preview crops with staged cast nested commands and actor envelopes', function (string $owner) {
+    [$root, $session, $frame, $stem, $sprites] = createStagedSheetPreviewProject($owner);
+    $row = readStagedSheetPreviewRow($session, $frame, $stem . 'sheet');
+    expect($row)->toMatchArray(['kind' => 'reference', 'reference' => 'png_assets',
+        'value' => $sprites['sheet'], 'media' => ['kind' => 'image', 'root' => 'assets']])
+        ->and($row['imagePreview'])->toBe(CharacterSheetPreview::describe($sprites, $root . '/assets'))
+        ->and($row['imagePreview']['frames'][0]['sourceRect'])->toBe(['x' => 16, 'y' => 16, 'width' => 4, 'height' => 4]);
+    $index = readStagedSheetPreviewRow($session, $frame, $stem . 'index');
+    $session->applyDatabaseRecord('cutscenes/cinematic', 0, $index['key'], '7');
+    expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['x'])->toBe(40);
+    $session->undo();
+    expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview'])->toBe($row['imagePreview']);
+    $session->redo();
+    $session->saveDatabase('cutscenes/cinematic');
+    $reopened = EditorSession::open($root);
+    expect(readStagedSheetPreviewRow($reopened, $frame, $stem . 'index')['value'])->toBe('7')
+        ->and(readStagedSheetPreviewRow($reopened, $frame, $stem . 'sheet')['imagePreview'])
+        ->toBe(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']);
+})->with(['cast', 'inline', 'envelope']);
+
+it('refreshes staged preview dimensions from the correct current workspace and reports file issues', function (string $owner) {
+    [$root, $session, $frame, $stem] = createStagedSheetPreviewProject($owner);
+    [$otherRoot, $other, $otherFrame, $otherStem] = createStagedSheetPreviewProject($owner, 96, 64);
+    $png = $root . '/assets/Graphics/Characters/People.png';
+    expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(4)
+        ->and(readStagedSheetPreviewRow($other, $otherFrame, $otherStem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(8);
+    writeTilesetTestPng($png, 144, 96);
+    expect(readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(12);
+    file_put_contents($png, 'malformed replacement');
+    $files = sourceHashTree($root);
+    $preview = readStagedSheetPreviewRow($session, $frame, $stem . 'sheet')['imagePreview'];
+    expect($preview['frames'])->toBe([])->and($preview['issue'])->not->toBeEmpty()
+        ->and($session->listUnsavedChanges())->toBe([])->and(sourceHashTree($root))->toBe($files)
+        ->and(readStagedSheetPreviewRow($other, $otherFrame, $otherStem . 'sheet')['imagePreview']['frames'][0]['sourceRect']['width'])->toBe(8);
+})->with(['cast', 'envelope']);
+
+it('reports an absent explicit staged asset root instead of inventing one from an owner path', function () {
+    $entry = ['type' => 'stage_actor', 'actor' => ['sprites2d' => ['sheet' => 'People.png']]];
+    $fields = [['sourceKey' => 'actor.sprites2d.sheet', 'value' => 'People.png'],
+        ['sourceKey' => 'actor.sprites2d.asset', 'value' => '']];
+    $described = StagedActorFields::describeFields($entry, $fields, null);
+    expect($described[0]['imagePreview']['frames'])->toBe([])
+        ->and($described[0]['imagePreview']['issue'])->toContain('no project asset root')
+        ->and($described[1])->not->toHaveKey('imagePreview');
+});
diff --git a/tests/Unit/StoryGuidanceValidatorTest.php b/tests/Unit/StoryGuidanceValidatorTest.php
new file mode 100644
index 00000000..9fa94da7
--- /dev/null
+++ b/tests/Unit/StoryGuidanceValidatorTest.php
@@ -0,0 +1,218 @@
+ [['type' => 'quest', 'name' => 'errand', 'status' => 'active']],
+            'lines' => [['name' => 'Keeper', 'text' => 'Open it, then.']], 'sets' => [['type' => 'event', 'name' => 'gate_opened']]],
+        ['lines' => [['name' => 'Keeper', 'text' => 'An errand for you.']], 'sets' => [['type' => 'quest', 'name' => 'errand']]],
+    ];
+    $map = [
+        'name' => 'Test Map',
+        'region' => '',
+        'triggers' => [],
+        'npcs' => [['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 1, 'y' => 1, 'dialogue' => $keeperPages]],
+        'events' => [
+            'D' => [
+                'class' => 'Ichiloto\\Engine\\Events\\Triggers\\TransferPlayerTrigger',
+                'conditions' => [['type' => 'event', 'name' => 'gate_opened']],
+                'whenBlocked' => 'The gate is shut.',
+                'data' => ['destinationMap' => 'test-map', 'spawnPoint' => ['x' => 0, 'y' => 0]],
+            ],
+            ...$events,
+        ],
+        ...$mapData,
+    ];
+    file_put_contents($root . '/assets/Maps/test-map/test-map.data.php', ' $commands) {
+        file_put_contents($root . '/assets/Events/' . $id . '.php', ' The guidance gaps validation reports. */
+function guidanceGaps(string $root): array
+{
+    return array_values(array_filter(new ProjectValidator()->validate(ProjectWorkspace::fromProject($root)),
+        static fn(Issue $issue): bool => str_contains($issue->message, 'wayfinder')));
+}
+
+/** The opening quest, led to its giver by a start waymark unless a case replaces it. */
+function errandQuest(array $objectives, array $extra = []): array
+{
+    return ['id' => 'errand', 'name' => 'Errand', 'description' => '',
+        'startWaymarks' => [['map' => 'test-map', 'npc' => 'keeper']], 'objectives' => [
+        ['type' => 'reach_map', 'target' => 'test-map', 'waymarks' => [['map' => 'test-map']]],
+        ...$objectives,
+    ], ...$extra];
+}
+
+function gateObjective(array $waymark = ['map' => 'test-map', 'npc' => 'keeper']): array
+{
+    return ['type' => 'flag', 'target' => 'gate_opened', 'description' => 'Open the gate', 'waymarks' => [$waymark]];
+}
+
+it('warns at a blocked door whose story flag no objective leads to', function () {
+    $gaps = guidanceGaps(writeGuidanceProject([errandQuest([])]));
+
+    expect($gaps)->toHaveCount(1)
+        ->and($gaps[0]->severity)->toBe(Severity::WARNING)
+        ->and($gaps[0]->where)->toBe('test-map event D')
+        ->and($gaps[0]->map)->toBe('test-map')
+        ->and($gaps[0]->message)->toContain('gate_opened');
+});
+
+it('finds no gap once an objective tracks the flag with a waymark', function () {
+    expect(guidanceGaps(writeGuidanceProject([errandQuest([gateObjective()])])))->toBe([]);
+});
+
+it('counts a waymark on the page that sets the flag, through its event script', function () {
+    $pages = [
+        ['conditions' => [['type' => 'quest', 'name' => 'errand', 'status' => 'active']], 'scriptId' => 'open-gate'],
+        ['lines' => [['name' => 'Keeper', 'text' => 'An errand for you.']], 'sets' => [['type' => 'quest', 'name' => 'errand']]],
+    ];
+    $talk = ['type' => 'talk_to', 'target' => 'Keeper', 'waymarks' => [['map' => 'test-map', 'npc' => 'keeper']]];
+    $root = writeGuidanceProject([errandQuest([$talk])], $pages,
+        ['open-gate' => [['type' => 'branch', 'conditions' => [['type' => 'event', 'name' => 'other']],
+            'then' => [], 'else' => [['type' => 'record_event', 'name' => 'gate_opened']]]]]);
+
+    expect(guidanceGaps($root))->toBe([]);
+});
+
+it('does not count guidance that can only appear once the flag is set', function (array $quest) {
+    expect(array_map(static fn(Issue $issue): string => $issue->where, guidanceGaps(writeGuidanceProject([$quest]))))->toContain('test-map event D');
+})->with([
+    'a waymark waiting for the flag' => [errandQuest([gateObjective(['map' => 'test-map', 'npc' => 'keeper',
+        'conditions' => [['type' => 'event', 'name' => 'gate_opened']]])])],
+    'a quest needing the flag to start' => [errandQuest([gateObjective()], ['prerequisites' => [['type' => 'event', 'name' => 'gate_opened']]])],
+    'an optional quest' => [errandQuest([gateObjective()], ['optional' => true])],
+]);
+
+it('does not count a quest that starts only where the flag is set', function () {
+    $pages = [['lines' => [['name' => 'Keeper', 'text' => 'Go.']],
+        'sets' => [['type' => 'quest', 'name' => 'errand'], ['type' => 'event', 'name' => 'gate_opened']]]];
+
+    $gaps = guidanceGaps(writeGuidanceProject([errandQuest([gateObjective()], ['startWaymarks' => []])], $pages));
+
+    expect(array_map(static fn(Issue $issue): string => $issue->where, $gaps))->toContain('test-map event D')
+        // Its start waymark leads there through the keeper, who hands over the errand and opens the gate at once.
+        ->and(guidanceGaps(writeGuidanceProject([errandQuest([gateObjective()])], $pages)))->toBe([]);
+});
+
+/** A notice board (event N) that hands over the next quest. */
+function nextQuestBoard(): array
+{
+    return ['N' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger',
+        'sets' => [['type' => 'quest', 'name' => 'next']], 'data' => ['mode' => 'action', 'reusable' => true]]];
+}
+
+function nextQuest(array $extra = []): array
+{
+    return ['id' => 'next', 'name' => 'Next Step', 'description' => '',
+        'prerequisites' => [['type' => 'event', 'name' => 'gate_opened']],
+        'startWaymarks' => [['map' => 'test-map', 'event' => 'N']],
+        'objectives' => [['type' => 'reach_map', 'target' => 'test-map']], ...$extra];
+}
+
+it('warns at a main quest whose prerequisite no objective leads to', function () {
+    $gaps = guidanceGaps(writeGuidanceProject([errandQuest([]), nextQuest()], events: nextQuestBoard()));
+
+    expect(array_map(static fn(Issue $issue): string => $issue->where, $gaps))->toBe(['quest next', 'test-map event D'])
+        ->and(guidanceGaps(writeGuidanceProject([errandQuest([gateObjective()]), nextQuest()], events: nextQuestBoard())))->toBe([]);
+});
+
+/* Quest starts: what starts each main quest is led to before it starts. */
+
+it('warns at a main quest whose start nothing leads to, on the map where it starts', function () {
+    $gaps = guidanceGaps(writeGuidanceProject([errandQuest([gateObjective()], ['startWaymarks' => []])]));
+
+    expect($gaps)->toHaveCount(1)
+        ->and($gaps[0]->severity)->toBe(Severity::WARNING)
+        ->and($gaps[0]->where)->toBe('quest errand')
+        ->and($gaps[0]->map)->toBe('test-map')
+        ->and($gaps[0]->message)->toContain('starts at test-map NPC keeper page 2', 'no start waymark')
+        ->and($gaps[0]->hint)->toContain('start waymark');
+});
+
+it('counts an objective of the quest before it leading to where the next quest starts', function () {
+    $toBoard = ['type' => 'flag', 'target' => 'board_read', 'waymarks' => [['map' => 'test-map', 'event' => 'N']]];
+    $project = static fn(array $errandObjectives): string => writeGuidanceProject(
+        [errandQuest([gateObjective(), ...$errandObjectives]), nextQuest(['startWaymarks' => []])], events: nextQuestBoard());
+
+    expect(array_map(static fn(Issue $issue): string => $issue->where, guidanceGaps($project([]))))->toBe(['quest next'])
+        ->and(guidanceGaps($project([$toBoard])))->toBe([]);
+});
+
+it('warns at a main quest nothing in the project starts', function () {
+    $gaps = guidanceGaps(writeGuidanceProject([errandQuest([gateObjective()]), nextQuest()]));
+
+    expect(array_map(static fn(Issue $issue): string => $issue->where, $gaps))->toBe(['quest next'])
+        ->and($gaps[0]->message)->toContain('Nothing in the project starts the quest "Next Step"')
+        ->and($gaps[0]->map)->toBeNull();
+});
+
+/* Battles: a troop's event page opens the gate in its battle. */
+
+function gateTroop(): array
+{
+    return ['name' => 'Gate Warden', 'enemies' => [['enemy' => 'Regular Bat', 'position' => [15, 7]]], 'events' => [
+        ['name' => 'Yields', 'span' => 'battle', 'conditions' => ['enemy' => 0, 'enemyHpPercent' => 25],
+            'commands' => [['type' => 'common_event', 'id' => 'warden-yields']]],
+    ]];
+}
+
+/** A keeper who starts the errand but opens nothing: only the battle opens the gate. */
+function errandOnlyPages(): array
+{
+    return [['lines' => [['name' => 'Keeper', 'text' => 'An errand for you.']], 'sets' => [['type' => 'quest', 'name' => 'errand']]]];
+}
+
+it('counts a waymark on the event that starts the battle whose troop page sets the flag', function () {
+    $duel = ['B' => ['class' => 'Ichiloto\\Engine\\Events\\Triggers\\ScriptEventTrigger',
+        'data' => ['mode' => 'action', 'reusable' => true, 'scriptId' => 'duel']]];
+    $scripts = ['duel' => [['type' => 'start_battle', 'troop' => 'Gate Warden', 'resultVariable' => 'duel']],
+        'warden-yields' => [['type' => 'record_event', 'name' => 'gate_opened']]];
+    $toDuel = ['type' => 'flag', 'target' => 'duel_fought', 'waymarks' => [['map' => 'test-map', 'event' => 'B']]];
+    $project = static fn(array $objectives): string => writeGuidanceProject([errandQuest($objectives)], errandOnlyPages(),
+        $scripts, [gateTroop()], $duel);
+
+    expect(guidanceGaps($project([$toDuel])))->toBe([])
+        ->and(array_map(static fn(Issue $issue): string => $issue->message, guidanceGaps($project([]))))
+        ->toHaveCount(1)->each->toContain('no objective or start waymark of a main quest leads');
+});
+
+it('counts a waymark leading to the map whose encounters bring that battle', function () {
+    $scripts = ['warden-yields' => [['type' => 'set_switch', 'name' => 'gate_opened', 'value' => true]]];
+    $encounters = ['encounters' => ['troops' => ['Gate Warden' => 4], 'rate' => 12]];
+    // The errand's first objective leads to the map; without encounters nothing there opens the gate.
+    expect(guidanceGaps(writeGuidanceProject([errandQuest([])], errandOnlyPages(), $scripts, [gateTroop()], mapData: $encounters)))->toBe([])
+        ->and(guidanceGaps(writeGuidanceProject([errandQuest([])], errandOnlyPages(), $scripts, [gateTroop()])))->toHaveCount(1);
+});
+
+it('warns at a gate nothing in the project sets, battles included', function () {
+    $gaps = guidanceGaps(writeGuidanceProject([errandQuest([])], errandOnlyPages(), troops: [gateTroop()]));
+
+    expect($gaps)->toHaveCount(1)
+        ->and($gaps[0]->where)->toBe('test-map event D')
+        ->and($gaps[0]->message)->toContain('nothing in the project sets it');
+});
diff --git a/tests/Unit/StructuralSourcePreservationTest.php b/tests/Unit/StructuralSourcePreservationTest.php
index b34730cb..eb9fa0db 100644
--- a/tests/Unit/StructuralSourcePreservationTest.php
+++ b/tests/Unit/StructuralSourcePreservationTest.php
@@ -2,44 +2,18 @@
 
 declare(strict_types=1);
 
-use Ichiloto\Editor\Database\PhpSourceDocument;
 use Ichiloto\Editor\Database\ProjectRecordDatabase;
 use Ichiloto\Editor\Database\RecordSchemaCatalog;
 
 /**
- * Adding and removing a record without rewriting the file around it.
+ * Adding and removing an inventory record without rewriting anything else.
  *
- * Editing a value was already surgical, but changing the *shape* of the list
- * fell back to the writer that rebuilds the whole returned expression. On a
- * file of authored constructor calls that means every entry reformatted,
- * arguments reordered, defaults nobody wrote written out, and any expression
- * the exporter cannot spell silently replaced -- to add one record.
- *
- * The file used here is the real game's `items.php`: fully qualified class
- * names, imports, nested constructors, enum cases, emoji icons, and items,
- * weapons and armors interleaved in one list.
+ * Each item, weapon and armor is a record file of its own, numbered in the
+ * order menus list them. Changing the shape of a category writes or deletes
+ * that one file: every other record, in every inventory category, keeps its
+ * own bytes.
  */
 
-/**
- * Copies the real authored inventory into a throwaway project.
- *
- * @return array{0: string, 1: string}|null The project root and items path.
- */
-function structuralProject(): ?array
-{
-    $game = gameSourceRoot();
-
-    if ($game === null) {
-        return null;
-    }
-
-    $root = makeTemporaryProject('ichiloto-structural-');
-    $path = $root . '/assets/Data/items.php';
-    copy($game . '/assets/Data/items.php', $path);
-
-    return [$root, $path];
-}
-
 /**
  * Opens one inventory category.
  */
@@ -48,182 +22,71 @@ function structuralDatabase(string $root, string $category): ProjectRecordDataba
     return ProjectRecordDatabase::fromProject($root, RecordSchemaCatalog::forKey($category));
 }
 
-/**
- * Returns the lines of the original that a change rewrote.
- *
- * @return string[] The rewritten lines.
- */
-function rewrittenLines(string $before, string $after): array
-{
-    $beforeLines = explode("\n", $before);
-    $afterLines = explode("\n", $after);
-    $rewritten = [];
-
-    foreach ($beforeLines as $index => $line) {
-        if (($afterLines[$index] ?? null) !== $line) {
-            $rewritten[] = $line;
-        }
-    }
-
-    return $rewritten;
-}
-
-it('adds a record without rewriting the entries already in the file', function () {
-    $project = structuralProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = (string) file_get_contents($path);
-    $entriesBefore = PhpSourceDocument::parse($before)->entryCount();
-
-    $weapons = structuralDatabase($root, 'weapons');
-    $index = $weapons->addRecord();
-    $label = $weapons->getEntryLabels()[$index] ?? '';
-    $weapons->save();
-
-    $after = (string) file_get_contents($path);
-
-    // The list grew by one, and every byte the file had up to its closing
-    // bracket is still there, in order: the new entry was inserted, not
-    // written by rebuilding the list around it.
-    $upToClose = rtrim(substr($before, 0, (int) strrpos($before, '];')));
-
-    expect(PhpSourceDocument::parse($after)->entryCount())->toBe($entriesBefore + 1)
-        ->and(str_starts_with($after, $upToClose))->toBeTrue()
-        ->and(rewrittenLines($before, $after))->toHaveCount(2)
-        // Everything the surgical writer exists to protect.
-        ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;')
-        ->and($after)->toContain("id: 'item.s-potion',")
-        ->and($after)->toContain('equipmentType: \Ichiloto\Engine\Entities\Enumerations\WeaponType::SWORD,')
-        ->and($after)->toContain('parameterChanges: new \Ichiloto\Engine\Entities\ParameterChanges(');
-
-    $reloaded = structuralDatabase($root, 'weapons');
-    expect($reloaded->getEntryLabels())->toContain($label);
-})->group('engine');
-
-it('removes a record without rewriting its neighbours', function () {
-    $project = structuralProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = (string) file_get_contents($path);
-    $entriesBefore = PhpSourceDocument::parse($before)->entryCount();
+it('adds a record as a file of its own, numbered last, leaving every other file alone', function () {
+    $root = makeTemporaryProject('ichiloto-structural-');
+    $before = sourceHashTree($root);
 
     $items = structuralDatabase($root, 'items');
-    $labels = $items->getEntryLabels();
-    $removed = $items->removeRecord(1);
+    $index = $items->addRecord();
+    $label = $items->getEntryLabels()[$index] ?? '';
     $items->save();
 
-    $after = (string) file_get_contents($path);
-
-    expect($removed?->get('name'))->toBe($labels[1])
-        ->and(PhpSourceDocument::parse($after)->entryCount())->toBe($entriesBefore - 1)
-        // The entries either side of it kept their own bytes.
-        ->and($after)->toContain("id: 'item.s-potion',")
-        ->and($after)->toContain('use Ichiloto\Engine\Entities\Effects\HPRecoveryEffect;');
+    $after = sourceHashTree($root);
 
-    $reloaded = structuralDatabase($root, 'items');
-    expect($reloaded->getEntryLabels())->not->toContain($labels[1])
-        ->and($reloaded->getEntryLabels())->toContain($labels[0]);
-})->group('engine');
-
-it('leaves the other categories of a shared file alone', function () {
-    $project = structuralProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
+    expect(array_keys(array_diff_key($after, $before)))->toBe(['assets/Data/Items/0003-item-new-item.php'])
+        ->and(array_diff_assoc(array_intersect_key($after, $before), $before))->toBe([])
+        ->and(structuralDatabase($root, 'items')->getEntryLabels())->toBe(['S-Potion', 'Antidote', $label]);
+});
 
-    [$root, $path] = $project;
-    $weaponsBefore = structuralDatabase($root, 'weapons')->getEntryLabels();
-    $armorsBefore = structuralDatabase($root, 'armors')->getEntryLabels();
+it('removes a record by removing its file, leaving its neighbours alone', function () {
+    $root = makeTemporaryProject('ichiloto-structural-');
+    $before = sourceHashTree($root);
 
-    // Items, weapons and armors are interleaved in one list, so removing an
-    // item must not disturb the weapons and armors around it.
     $items = structuralDatabase($root, 'items');
-    $items->removeRecord(0);
+    $removed = $items->removeRecord(0);
     $items->save();
 
-    expect(structuralDatabase($root, 'weapons')->getEntryLabels())->toBe($weaponsBefore)
-        ->and(structuralDatabase($root, 'armors')->getEntryLabels())->toBe($armorsBefore);
-})->group('engine');
+    $after = sourceHashTree($root);
 
-it('puts a deleted record back exactly, bytes and all', function () {
-    $project = structuralProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
+    expect($removed?->get('name'))->toBe('S-Potion')
+        ->and(array_keys(array_diff_key($before, $after)))->toBe(['assets/Data/Items/0001-s-potion.php'])
+        ->and(array_diff_assoc($after, $before))->toBe([])
+        ->and(structuralDatabase($root, 'items')->getEntryLabels())->toBe(['Antidote'])
+        // The other inventory categories are untouched.
+        ->and(structuralDatabase($root, 'weapons')->getEntryLabels())->toBe(['Wooden Sword']);
+});
 
-    [$root, $path] = $project;
-    $before = (string) file_get_contents($path);
+it('puts a deleted record back exactly, bytes and all', function () {
+    $root = makeTemporaryProject('ichiloto-structural-');
+    $before = sourceHashTree($root);
 
     $items = structuralDatabase($root, 'items');
     $removed = $items->removeRecord(1);
 
-    // Undone before the save ever happens: the file was never changed, and
-    // the record goes back where it came from.
+    // Undone before the save ever happens: nothing on disk changes, and the
+    // record goes back where it came from.
     $items->insertRecord(1, $removed);
     $items->save();
 
-    expect((string) file_get_contents($path))->toBe($before);
-})->group('engine');
+    expect(sourceHashTree($root))->toBe($before);
+});
 
 it('survives add, save, delete, save, and reload in one sitting', function () {
-    $project = structuralProject();
-
-    if ($project === null) {
-        $this->markTestSkipped('The game project is not reachable from this checkout.');
-    }
-
-    [$root, $path] = $project;
-    $before = (string) file_get_contents($path);
-    $entriesBefore = PhpSourceDocument::parse($before)->entryCount();
+    $root = makeTemporaryProject('ichiloto-structural-');
+    $before = sourceHashTree($root);
 
     $armors = structuralDatabase($root, 'armors');
     $index = $armors->addRecord();
     $label = $armors->getEntryLabels()[$index] ?? '';
     $armors->save();
 
-    expect(PhpSourceDocument::parse((string) file_get_contents($path))->entryCount())->toBe($entriesBefore + 1);
-
     $reloaded = structuralDatabase($root, 'armors');
-    $at = array_search($label, $reloaded->getEntryLabels(), true);
-    $reloaded->removeRecord(intval($at));
-    $reloaded->save();
+    expect($reloaded->getEntryLabels())->toContain($label);
 
-    $after = (string) file_get_contents($path);
+    $reloaded->removeRecord(intval(array_search($label, $reloaded->getEntryLabels(), true)));
+    $reloaded->save();
 
-    // Back to the count it started at, with the original entries still
-    // spelled the way the author spelled them.
-    expect(PhpSourceDocument::parse($after)->entryCount())->toBe($entriesBefore)
-        ->and($after)->toContain("id: 'item.s-potion',")
+    // Back to exactly the project it started as.
+    expect(sourceHashTree($root))->toBe($before)
         ->and(structuralDatabase($root, 'armors')->getEntryLabels())->not->toContain($label);
-})->group('engine');
-
-it('refuses a structural edit it cannot make in the source, rather than regenerating', function () {
-    // The fixture authors its entries positionally, so an argument cannot be
-    // patched by name -- but an entry can still be appended and removed by
-    // span, which is what makes the refusal specific rather than blanket.
-    $root = makeTemporaryProject('ichiloto-structural-');
-    $path = $root . '/assets/Data/items.php';
-    $before = (string) file_get_contents($path);
-
-    expect($before)->toContain("new Item('S-Potion'");
-
-    $items = structuralDatabase($root, 'items');
-    $items->addRecord();
-    $items->save();
-
-    $after = (string) file_get_contents($path);
-
-    expect(PhpSourceDocument::parse($after)->entryCount())->toBe(PhpSourceDocument::parse($before)->entryCount() + 1)
-        // The positional entries kept their own spelling.
-        ->and($after)->toContain("new Item('S-Potion'");
 });
diff --git a/tests/Unit/TerminalAnimationConversionTest.php b/tests/Unit/TerminalAnimationConversionTest.php
new file mode 100644
index 00000000..6bf531b1
--- /dev/null
+++ b/tests/Unit/TerminalAnimationConversionTest.php
@@ -0,0 +1,200 @@
+ %d, 'cells' => [['x' => %d, 'y' => 0, 'symbol' => '*']]],", $frame, $frame % 5);
+    }
+    // Every role the Engine supports: a long list of battle consumers to wrap.
+    $roles = implode(', ', array_map(static fn(string $role): string => var_export($role, true), ActionAnimationResolver::getSupportedRoles()));
+
+    return " 1,\n        'name' => 'Old Spark',\n"
+        . "        'position' => 'center',\n        'maxFrames' => 30,\n        'roles' => [{$roles}],\n        'frames' => [\n"
+        . implode("\n", $frames) . "\n        ],\n"
+        . "        'cues' => [['frame' => 1, 'soundEffect' => 'spark', 'flashColor' => 'white', 'flashDurationFrames' => 2]],\n    ],\n];\n";
+}
+
+/** The terminal editor at a size, on the Animations database with the legacy record selected. */
+function openLegacyAnimationEditor(int $width = 160, int $height = 48): array
+{
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/Data/animations.php', longLegacyAnimations());
+    $editor = createEditorForTesting($root);
+    setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root));
+    setEditorProperty($editor, 'lastTerminalSize', ['width' => $width, 'height' => $height]);
+    callEditorMethod($editor, 'openDatabaseAtCategory', 'animations');
+
+    return [$editor, $root];
+}
+
+function terminalConversion(Editor $editor): AnimationConversionEditor
+{
+    return getEditorProperty($editor, 'animationConversion');
+}
+
+/** @return list The settings pane as the window draws it: fitted to its width and height. */
+function visibleConversionPane(Editor $editor): array
+{
+    $metrics = callEditorMethod($editor, 'recordPaneMetrics');
+
+    return callEditorMethod($editor, 'fitLines', callEditorMethod($editor, 'getDatabaseSettingsLines'), $metrics['width'], $metrics['rows']);
+}
+
+/** The line the cursor is on, as drawn; it must be on screen. */
+function visibleCursorLine(Editor $editor): string
+{
+    return array_values(array_filter(visibleConversionPane($editor), static fn(string $line): bool => str_starts_with($line, '> ')))[0]
+        ?? throw new RuntimeException("The cursor is off screen:\n" . implode("\n", visibleConversionPane($editor)));
+}
+
+/** The visible pane as one line of prose, cursor markers dropped, whatever its wrapping. */
+function readVisibleConversionPane(Editor $editor): string
+{
+    return preg_replace('/\s+/', ' ', implode(' ', array_map(static fn(string $line): string => substr($line, 2), visibleConversionPane($editor))));
+}
+
+/** Battle phase cadence, target binding, ticks 1 and rest 0, typed through the pane. */
+function chooseBattleTiming(Editor $editor): void
+{
+    pressKeys($editor, 'T', "\033[C", "\033[B", "\033[B", "\033[B");
+    typeText($editor, '1');
+    pressKeys($editor, "\033[B");
+    typeText($editor, '0');
+    pressKeys($editor, "\033[B", "\033[B");
+}
+
+it('keeps the selected control on screen and every fact reachable at real terminal sizes', function (int $width, int $height) {
+    [$editor] = openLegacyAnimationEditor($width, $height);
+    pressKeys($editor, 'T');
+    $conversion = terminalConversion($editor);
+    $seen = [];
+
+    expect(visibleCursorLine($editor))->toContain('Played by');
+
+    // Walk every step, controls then the facts after them, reading what the pane shows at each.
+    $lines = count($conversion->getLines(callEditorMethod($editor, 'recordPaneMetrics')['width'], getEditorProperty($editor, 'projectRoot')));
+    foreach (range(1, $lines) as $step) {
+        visibleCursorLine($editor);
+        $seen[] = readVisibleConversionPane($editor);
+        pressKeys($editor, "\033[B");
+    }
+    $read = implode(' ', $seen);
+
+    expect($read)->toContain('Preview the files', 'role ' . array_last(ActionAnimationResolver::getSupportedRoles()), 'until their command names the timeline');
+})->with([[80, 24], [120, 40], [160, 48]]);
+
+it('reviews every proposed line before writing exactly that plan, as one undo step', function (int $width, int $height) {
+    [$editor, $root] = openLegacyAnimationEditor($width, $height);
+    $timeline = $root . '/assets/Animations/old-spark/old-spark.timeline.php';
+    chooseBattleTiming($editor);
+    $conversion = terminalConversion($editor);
+
+    expect($conversion->getSelectedControl())->toBe('preview')
+        ->and(visibleCursorLine($editor))->toContain('[ Preview')
+        ->and(readVisibleConversionPane($editor))->toContain('[ Preview the files ]');
+
+    pressKeys($editor, "\r");
+
+    expect($conversion->getMode())->toBe(AnimationConversionEditor::MODE_REVIEW)
+        ->and(file_exists($timeline))->toBeFalse();
+
+    // Read the whole review, a line at a time, as the pane shows it.
+    $seen = [];
+    $lines = count($conversion->getLines(callEditorMethod($editor, 'recordPaneMetrics')['width'], $root));
+    foreach (range(1, $lines) as $step) {
+        $seen[] = substr(visibleCursorLine($editor), 2);
+        pressKeys($editor, "\033[B");
+    }
+    $read = preg_replace('/\s+/', '', implode('', $seen));
+
+    // Deeply indented source stays readable however narrow the pane.
+    expect($read)->toContain('//preservednestedsource');
+
+    // Every proposed file was on screen in full, in order: nothing is written unread.
+    foreach ($conversion->getPlan()->getProposedSources() as $source) {
+        expect(str_contains($read, preg_replace('/\s+/', '', $source)))->toBeTrue();
+    }
+
+    pressKeys($editor, "\r");
+
+    expect($conversion->isOpen())->toBeFalse()
+        ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain("'targetEffect' => 'old-spark'", '// A spark in the old cell frames.')
+        ->and(new EffectTimelineLibrary($root . '/assets')->load('old-spark', forBattle: true))->not->toBeNull();
+
+    callEditorMethod($editor, 'performUndo');
+
+    expect(file_get_contents($root . '/assets/Data/animations.php'))->toBe(longLegacyAnimations())
+        ->and(file_exists($timeline))->toBeFalse();
+})->with([[80, 24], [120, 40], [160, 48]]);
+
+it('goes back from review to the choices on Escape, and forgets the plan when a choice changes', function () {
+    [$editor] = openLegacyAnimationEditor(80, 24);
+    chooseBattleTiming($editor);
+    pressKeys($editor, "\r", "\033");
+    $conversion = terminalConversion($editor);
+
+    expect($conversion->getMode())->toBe(AnimationConversionEditor::MODE_CHOICES)
+        ->and(visibleCursorLine($editor))->toContain('[ Review')
+        ->and($conversion->getSelectedControl())->toBe('review')
+        ->and(readVisibleConversionPane($editor))->toContain('[ Review and write 2 files ]');
+
+    // Up to the ticks, and change them.
+    pressKeys($editor, "\033[A", "\033[A", "\033[A", "\033[A");
+    typeText($editor, '2');
+
+    expect($conversion->getPlan())->toBeNull()
+        ->and($conversion->getControls())->not->toContain('review');
+});
+
+it('refuses to write a review the files have moved on from, writing nothing and saying why', function () {
+    [$editor, $root] = openLegacyAnimationEditor(80, 24);
+    chooseBattleTiming($editor);
+    pressKeys($editor, "\r");
+
+    file_put_contents($root . '/assets/Data/animations.php', str_replace('// A spark in the old cell frames.', '// Edited elsewhere.', longLegacyAnimations()));
+    pressKeys($editor, "\r");
+
+    expect(terminalConversion($editor)->getMode())->toBe(AnimationConversionEditor::MODE_CHOICES)
+        ->and(terminalConversion($editor)->getError())->toContain('changed outside the animation conversion')
+        ->and(file_exists($root . '/assets/Animations/old-spark'))->toBeFalse()
+        ->and(file_get_contents($root . '/assets/Data/animations.php'))->toContain('// Edited elsewhere.');
+});
+
+it('asks for who plays the timeline rather than assuming it', function () {
+    [$editor] = openLegacyAnimationEditor(160, 48);
+    // Down to Preview with no consumer chosen.
+    pressKeys($editor, 'T', "\033[B", "\033[B", "\033[B", "\033[B", "\033[B");
+
+    expect(visibleCursorLine($editor))->toContain('Preview the files');
+
+    pressKeys($editor, "\r");
+
+    expect(terminalConversion($editor)->getError())->toContain('Choose who plays the timeline')
+        ->and(terminalConversion($editor)->getMode())->toBe(AnimationConversionEditor::MODE_CHOICES);
+});
+
+it('says why a record cannot be converted', function () {
+    [$editor, $root] = openLegacyAnimationEditor(80, 24);
+    file_put_contents($root . '/assets/Data/animations.php', " 1, 'name' => 'Already Timed', 'targetEffect' => 'burst']];\n");
+    setEditorProperty($editor, 'workspace', ProjectWorkspace::fromProject($root));
+    pressKeys($editor, 'T');
+
+    expect(terminalConversion($editor)->isOpen())->toBeFalse()
+        ->and(getEditorProperty($editor, 'statusMessage'))->toContain('has no legacy frames or cues to convert');
+});
diff --git a/tests/Unit/TerrainKindCanvasTest.php b/tests/Unit/TerrainKindCanvasTest.php
new file mode 100644
index 00000000..edfa873c
--- /dev/null
+++ b/tests/Unit/TerrainKindCanvasTest.php
@@ -0,0 +1,93 @@
+ 'home',", "'tileset' => 'home', 'terrainKinds' => true,", (string) file_get_contents($data)));
+    }
+
+    return layeredCanvasEditor($root);
+}
+
+/** Chooses a kind with the keys: K, down to its entry and Enter. */
+function chooseKindByKeys(Editor $editor, string $id): void
+{
+    callEditorMethod($editor, 'dispatchInput', 'K');
+    $current = getEditorProperty($editor, 'selectedEventOptionIndex');
+    $target = array_search($id, array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'value'), true);
+    for ($i = $current; $i < $target; $i++) {
+        callEditorMethod($editor, 'dispatchInput', "\033[B");
+    }
+    callEditorMethod($editor, 'dispatchInput', "\n");
+}
+
+it('lists the kinds of the layer being edited with K, opening on the kind under the cursor', function () {
+    [$editor] = createKindCanvasEditor();
+    setEditorProperty($editor, 'cursorX', 1);
+
+    callEditorMethod($editor, 'dispatchInput', 'K');
+
+    // The fixture's terrain cells are bare glyphs over meadow tiles: the cursor's cell shows meadow.
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue()
+        ->and(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Terrain kind')
+        ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([
+            ['label' => '.  Meadow', 'value' => 'meadow', 'description' => 'walk · meadow encounters'],
+            ['label' => '=  Path', 'value' => 'path', 'description' => 'walk · no encounters'],
+            ['label' => '♣  Grove', 'value' => 'grove', 'description' => 'walk · grove encounters · on Meadow'],
+            ['label' => '^  Peaks', 'value' => 'peaks', 'description' => 'solid · on Meadow · awaiting art'],
+        ])
+        ->and(getEditorProperty($editor, 'selectedEventOptionIndex'))->toBe(0);
+});
+
+it('shows K:Kind in Normal mode on a map painted in kinds only', function (bool $converted, bool $shown) {
+    [$editor] = createKindCanvasEditor($converted);
+
+    expect(str_contains(renderEditorPlainFrame($editor, 200, 45), 'L:Layer P:Piece K:Kind'))->toBe($shown);
+})->with(['painted in kinds' => [true, true], 'not yet' => [false, false]]);
+
+it('says how to convert a map that is not painted in kinds, and opens nothing', function () {
+    [$editor] = createKindCanvasEditor(converted: false);
+
+    callEditorMethod($editor, 'dispatchInput', 'K');
+
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse()
+        ->and(getEditorProperty($editor, 'statusMessage'))->toContain('map:convert-kinds test-map');
+});
+
+it('paints a kind\'s glyph and tiles at the cursor and fills to the anchor, each one undo step', function () {
+    [$editor, $map] = createKindCanvasEditor();
+    $before = $map->captureLayerSnapshot();
+    $tilesBefore = $map->getTileLayerSources();
+
+    chooseKindByKeys($editor, 'grove');
+    expect(callEditorMethod($editor, 'getPiecePlacementHelp', 60))->toBe('KIND Grove  Enter:Paint  Drag:Fill  Esc:Done');
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    setEditorProperty($editor, 'cursorX', 2);
+    callEditorMethod($editor, 'dispatchInput', "\n");
+
+    expect($map->getLayerSymbol('map:1', 0, 0))->toBe('♣')
+        ->and($map->getLayerSymbol('map:1', 2, 0))->toBe('♣')
+        ->and($map->getLayerColor('map:1', 1, 0))->toBe('green')
+        ->and($map->readTileEntries(['canopy'], 0, 0, 4, 1)['canopy'][0])->toBe(['2912', '2912', '2912', '0'])
+        ->and(getEditorProperty($editor, 'statusMessage'))->toContain('Painted 3 cells of Grove');
+
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->captureLayerSnapshot())->toBe($before)
+        ->and($map->getTileLayerSources())->toBe($tilesBefore);
+});
diff --git a/tests/Unit/TilesFollowGlyphsTest.php b/tests/Unit/TilesFollowGlyphsTest.php
new file mode 100644
index 00000000..1cff42d4
--- /dev/null
+++ b/tests/Unit/TilesFollowGlyphsTest.php
@@ -0,0 +1,205 @@
+ ['name' => 'Chest', 'layer' => 'buildings', 'glyphs' => ['m'], 'tiles' => ['furniture' => ['60']]],
+        'chair-facing-south' => ['name' => 'Chair facing south', 'layer' => 'buildings', 'glyphs' => ['-'], 'tiles' => ['furniture' => ['50']]],
+        'chair-facing-north' => ['name' => 'Chair facing north', 'layer' => 'buildings', 'glyphs' => ['-'], 'tiles' => ['furniture' => ['51']]],
+        'table' => ['name' => 'Dining table', 'layer' => 'buildings', 'glyphs' => ['##'], 'tiles' => ['furniture' => ['40 41']]],
+        // The sofa's back rises into the row above its seat.
+        'sofa' => ['name' => 'Sofa', 'layer' => 'buildings', 'glyphs' => ['  ', '##'], 'tiles' => ['furniture' => ['70 71', '72 73']]],
+    ]);
+    [$editor, $map] = layeredCanvasEditor($root);
+    callEditorMethod($editor, 'selectCanvasLayer', 'map:4');
+
+    return [$editor, $map];
+}
+
+/** @return list> The furniture layer's entries, all `0` while the map has none. */
+function readFurnitureRows(ProjectMap $map): array
+{
+    $source = $map->getTileLayerSources()[$map->directory . '/graphics/03.furniture.tiles.php'] ?? null;
+
+    return $source === null ? [['0', '0', '0', '0'], ['0', '0', '0', '0']]
+        : TileLayerSource::readLayer($source, '03.furniture.tiles.php')->getEntries();
+}
+
+function typeCanvasGlyph(Editor $editor, string $glyph, int $x, int $y): void
+{
+    setEditorProperty($editor, 'cursorX', $x);
+    setEditorProperty($editor, 'cursorY', $y);
+    callEditorMethod($editor, 'adoptPaintSymbol', $glyph);
+}
+
+function choosePieceFor(Editor $editor, string $label): void
+{
+    $entries = getEditorProperty($editor, 'eventOptionDialogEntries');
+    setEditorProperty($editor, 'selectedEventOptionIndex', array_search($label, array_column($entries, 'label'), true));
+    callEditorMethod($editor, 'applySelectedEventOption');
+}
+
+it('stamps a typed glyph\'s tiles and clears them when it is erased, one undo step each, leaving other tiles alone', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+    $others = [$map->directory . '/graphics/01.floor.tiles.php', $map->directory . '/graphics/02.decor.tiles.php'];
+    $before = array_intersect_key($map->getTileLayerSources(), array_flip($others));
+
+    typeCanvasGlyph($editor, 'm', 0, 0);
+    expect($map->getLayerSymbol('map:4', 0, 0))->toBe('m')
+        ->and(readFurnitureRows($map))->toBe([['60', '0', '0', '0'], ['0', '0', '0', '0']]);
+
+    typeCanvasGlyph($editor, ' ', 0, 0);
+    expect(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']])
+        ->and(array_intersect_key($map->getTileLayerSources(), array_flip($others)))->toBe($before);
+
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->getLayerSymbol('map:4', 0, 0))->toBe('m')
+        ->and(readFurnitureRows($map)[0][0])->toBe('60');
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->getLayerSymbol('map:4', 0, 0))->toBe(' ')
+        ->and(readFurnitureRows($map)[0][0])->toBe('0');
+});
+
+it('asks which piece a shared glyph is, and the brush remembers the answer until a glyph is typed again', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+
+    typeCanvasGlyph($editor, '-', 2, 0);
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeTrue()
+        ->and(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'label'))
+            ->toBe(['Chair facing south', 'Chair facing north', 'No tiles'])
+        ->and($map->getLayerSymbol('map:4', 2, 0))->toBe(' ');
+
+    choosePieceFor($editor, 'Chair facing south');
+    expect($map->getLayerSymbol('map:4', 2, 0))->toBe('-')
+        ->and(readFurnitureRows($map)[0])->toBe(['0', '0', '50', '0']);
+
+    // Painting the brush again draws the same chair without asking.
+    setEditorProperty($editor, 'cursorX', 3);
+    callEditorMethod($editor, 'applySelectedPaintSymbol');
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse()
+        ->and(readFurnitureRows($map)[0])->toBe(['0', '0', '50', '50']);
+
+    // A chair moved by erasing and typing takes its tiles along.
+    typeCanvasGlyph($editor, ' ', 2, 0);
+    typeCanvasGlyph($editor, '-', 0, 1);
+    choosePieceFor($editor, 'Chair facing north');
+    expect(readFurnitureRows($map))->toBe([['0', '0', '0', '50'], ['51', '0', '0', '0']]);
+});
+
+it('leaves the map as it was when the choice is dismissed', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+    $sources = $map->getTileLayerSources();
+
+    typeCanvasGlyph($editor, '-', 2, 0);
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse()
+        ->and($map->getLayerSymbol('map:4', 2, 0))->toBe(' ')
+        ->and($map->getTileLayerSources())->toBe($sources)
+        ->and(getEditorProperty($editor, 'statusMessage'))->toBe('Nothing was painted; the map is as it was.');
+});
+
+it('gives a glyph the part of a piece its neighbours prove, without asking', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+
+    typeCanvasGlyph($editor, '#', 2, 0);
+    choosePieceFor($editor, 'Dining table (left)');
+    typeCanvasGlyph($editor, '#', 3, 0);
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse()
+        ->and(readFurnitureRows($map)[0])->toBe(['0', '0', '40', '41']);
+});
+
+it('carries the tiles of a piece\'s blank cells with the glyph nearest them', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+
+    typeCanvasGlyph($editor, '#', 0, 1);
+    choosePieceFor($editor, 'Sofa (left)');
+    expect(readFurnitureRows($map))->toBe([['70', '0', '0', '0'], ['72', '0', '0', '0']]);
+
+    typeCanvasGlyph($editor, ' ', 0, 1);
+    expect(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']]);
+});
+
+it('picks up the piece a glyph draws with the eyedropper', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+    typeCanvasGlyph($editor, '-', 2, 0);
+    choosePieceFor($editor, 'Chair facing north');
+
+    callEditorMethod($editor, 'pickSymbolUnderCursor');
+    expect(getEditorProperty($editor, 'statusMessage'))->toBe('Picked up - (Chair facing north) from (2, 0).');
+
+    setEditorProperty($editor, 'cursorX', 3);
+    setEditorProperty($editor, 'cursorY', 1);
+    callEditorMethod($editor, 'applySelectedPaintSymbol');
+    expect(readFurnitureRows($map)[1])->toBe(['0', '0', '0', '51']);
+});
+
+it('keeps a recoloured glyph\'s tiles', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+    typeCanvasGlyph($editor, 'm', 0, 0);
+    $sources = $map->getTileLayerSources();
+
+    callEditorMethod($editor, 'applyCanvasWrites', $map, [['x' => 0, 'y' => 0, 'symbol' => 'm', 'color' => 'red']], 'Recolour');
+    expect($map->getTileLayerSources())->toBe($sources);
+});
+
+it('records a mouse stroke\'s tiles with its glyphs as one undo step', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+    setEditorProperty($editor, 'selectedPaintSymbol', 'm');
+    $sources = $map->getTileLayerSources();
+
+    callEditorMethod($editor, 'paintCanvasStroke', $map, 'm', 0, 1, MouseButton::LEFT_BUTTON);
+    callEditorMethod($editor, 'paintCanvasStroke', $map, 'm', 2, 1, MouseButton::LEFT_BUTTON);
+    callEditorMethod($editor, 'finalizeActiveStroke');
+    expect(readFurnitureRows($map)[1])->toBe(['60', '60', '60', '0']);
+
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->getTileLayerSources())->toBe($sources)
+        ->and($map->getLayerSymbol('map:4', 1, 1))->toBe('x');
+});
+
+it('draws the tiles for the glyphs already on a layer with T, asking about a shared glyph, in one undo step', function () {
+    [$editor, $map] = createFollowingTilesEditor();
+    // The buildings layer already holds ` /  ` over ` xx `, authored before
+    // any piece drew tiles for them.
+    writeTestTileset(dirname($map->directory, 3), pieces: [
+        'crate' => ['name' => 'Crate', 'layer' => 'buildings', 'glyphs' => ['x'], 'tiles' => ['furniture' => ['60']]],
+        'banner-red' => ['name' => 'Red banner', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['70']]],
+        'banner-blue' => ['name' => 'Blue banner', 'layer' => 'buildings', 'glyphs' => ['/'], 'tiles' => ['furniture' => ['71']]],
+    ]);
+    $glyphs = [$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1), $map->getLayerSymbol('map:4', 2, 1)];
+    setEditorProperty($editor, 'focusedPane', 'canvas');
+
+    callEditorMethod($editor, 'dispatchInput', 'T');
+    expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Piece for /')
+        ->and(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']]);
+
+    choosePieceFor($editor, 'Blue banner');
+    expect(readFurnitureRows($map))->toBe([['0', '71', '0', '0'], ['0', '60', '60', '0']])
+        ->and([$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1), $map->getLayerSymbol('map:4', 2, 1)])->toBe($glyphs)
+        ->and($map->isDirty())->toBeTrue();
+
+    // Drawing again changes nothing.
+    callEditorMethod($editor, 'dispatchInput', 'T');
+    choosePieceFor($editor, 'Blue banner');
+    expect(getEditorProperty($editor, 'toasts')->current()?->message)->toBe('Every glyph on the Buildings layer already has its tiles.');
+
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect(readFurnitureRows($map))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '0']]);
+});
diff --git a/tests/Unit/TilesMoveWithGlyphsTest.php b/tests/Unit/TilesMoveWithGlyphsTest.php
new file mode 100644
index 00000000..af900db6
--- /dev/null
+++ b/tests/Unit/TilesMoveWithGlyphsTest.php
@@ -0,0 +1,107 @@
+ 'home',", "'tileset' => 'home', {$settings}", (string) file_get_contents($data)));
+    }
+    [$editor, $map] = layeredCanvasEditor($root);
+
+    return [$editor, $map];
+}
+
+/** @return array> */
+function buildMovingTilePieces(): array
+{
+    return [
+        'bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['='], 'tiles' => ['decor' => ['5']]],
+        'rug' => ['name' => 'Rug', 'layer' => 'terrain', 'glyphs' => ['~'], 'tiles' => ['floor' => ['2816']]],
+    ];
+}
+
+/** @return list> */
+function readMovingTileRows(ProjectMap $map, string $file): array
+{
+    return TileLayerSource::readLayer($map->getTileLayerSources()[$map->directory . '/graphics/' . $file], $file)->getEntries();
+}
+
+function selectCanvasBlock(Editor $editor, string $layer, int $x, int $y, int $width, int $height): void
+{
+    callEditorMethod($editor, 'selectCanvasLayer', $layer);
+    setEditorProperty($editor, 'canvasSelection', ['x' => $x, 'y' => $y, 'width' => $width, 'height' => $height]);
+}
+
+it('finds the tile layers that move with a gameplay layer from its tileset pieces', function () {
+    [, $map] = createMovingTilesEditor();
+
+    expect($map->getTileLayersMovingWith('map:4'))->toBe(['decor'])
+        ->and($map->getTileLayersMovingWith('map:1'))->toBe(['floor'])
+        ->and($map->getTileLayersMovingWith('event'))->toBe([]);
+});
+
+it('lets the map data name the gameplay layer a tile layer moves with', function () {
+    [, $map] = createMovingTilesEditor("'tileLayers' => ['floor' => ['movesWith' => 'buildings']],");
+
+    expect($map->getTileLayersMovingWith('map:4'))->toBe(['floor', 'decor'])
+        ->and($map->getTileLayersMovingWith('map:1'))->toBe([]);
+});
+
+it('cuts and pastes a block with its tiles, one undo step each, leaving other tile layers alone', function () {
+    [$editor, $map] = createMovingTilesEditor();
+    $floor = readMovingTileRows($map, '01.floor.tiles.php');
+    $original = $map->getTileLayerSources();
+    $glyphs = [$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1)];
+    expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '5', '0', '0'], ['0', '0', '0', '5']]);
+
+    selectCanvasBlock($editor, 'map:4', 1, 0, 1, 2);
+    callEditorMethod($editor, 'cutCanvasSelection');
+    expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '5']])
+        ->and($map->getLayerSymbol('map:4', 1, 0))->toBe(' ');
+
+    setEditorProperty($editor, 'cursorX', 2);
+    setEditorProperty($editor, 'cursorY', 0);
+    callEditorMethod($editor, 'pasteCanvasClipboard');
+    // The block replaces the tiles under it as it replaces the glyphs.
+    expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '0', '5', '0'], ['0', '0', '0', '5']])
+        ->and([$map->getLayerSymbol('map:4', 2, 0), $map->getLayerSymbol('map:4', 2, 1)])->toBe($glyphs)
+        ->and(readMovingTileRows($map, '01.floor.tiles.php'))->toBe($floor);
+
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect(readMovingTileRows($map, '02.decor.tiles.php'))->toBe([['0', '0', '0', '0'], ['0', '0', '0', '5']]);
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->getTileLayerSources())->toBe($original)
+        ->and([$map->getLayerSymbol('map:4', 1, 0), $map->getLayerSymbol('map:4', 1, 1)])->toBe($glyphs);
+});
+
+it('copies the tiles of the layers the map data names, and none from the event layer', function () {
+    [$editor, $map] = createMovingTilesEditor("'tileLayers' => ['floor' => ['movesWith' => 'buildings']],");
+
+    selectCanvasBlock($editor, 'map:4', 0, 0, 2, 1);
+    callEditorMethod($editor, 'copyCanvasSelection');
+    expect(getEditorProperty($editor, 'clipboard')->projectTiles(0, 1, 4, 2))->toBe([
+        'floor' => [['x' => 0, 'y' => 1, 'entry' => '2816'], ['x' => 1, 'y' => 1, 'entry' => '2816']],
+        'decor' => [['x' => 0, 'y' => 1, 'entry' => '0'], ['x' => 1, 'y' => 1, 'entry' => '5']],
+    ]);
+
+    selectCanvasBlock($editor, 'event', 0, 0, 2, 1);
+    callEditorMethod($editor, 'copyCanvasSelection');
+    expect(getEditorProperty($editor, 'clipboard')->projectTiles(0, 1, 4, 2))->toBe([]);
+});
diff --git a/tests/Unit/TilesetPieceKeepsTest.php b/tests/Unit/TilesetPieceKeepsTest.php
new file mode 100644
index 00000000..10461278
--- /dev/null
+++ b/tests/Unit/TilesetPieceKeepsTest.php
@@ -0,0 +1,271 @@
+ $id, 'layer' => 'buildings'] + $data, 'Synthetic keeps');
+}
+
+function planKeepsTestChanges(array $pieces, array $changes, array $glyphs, array $tiles, array $choices = [], array $assigned = []): array
+{
+    return GlyphTilePlanner::fromPieces($pieces, 'buildings')->plan($changes,
+        static fn(int $x, int $y): ?string => $glyphs[$y][$x] ?? null,
+        static fn(string $layer, int $x, int $y): string => $tiles[$layer][$y][$x] ?? '0',
+        $choices, assigned: $assigned);
+}
+
+/** Reports only stale piece tiles, apart from unrelated synthetic fixture coverage. */
+function getKeepsTestStaleIssues(string $root): array
+{
+    return array_values(array_map(static fn($issue): string => $issue->message,
+        array_filter(MapGraphicsValidator::validateCoverage(loadLayeredMap($root)),
+            static fn($issue): bool => str_contains($issue->message, 'no longer there'))));
+}
+
+it('gives kept cells to the same glyph roles as blank attachments, even for zero tiles', function () {
+    $piece = createKeepsTestPiece('mounted', ['glyphs' => ['a ', ' b'],
+        'tiles' => ['decor' => ['5 0', '0 6']], 'keeps' => ['walls', 'floor']]);
+    $roles = PieceRole::readPiece($piece);
+    $attached = [['dx' => 0, 'dy' => 0], ['dx' => 1, 'dy' => 0], ['dx' => 0, 'dy' => 1]];
+
+    expect($roles['a'][0]->keeps)->toBe(['walls' => $attached, 'floor' => $attached])
+        ->and($roles['b'][0]->keeps)->toBe(['walls' => [['dx' => 0, 'dy' => 0]], 'floor' => [['dx' => 0, 'dy' => 0]]])
+        ->and($roles['a'][0]->tiles)->toBe(['decor' => [['dx' => 0, 'dy' => 0, 'entry' => '5']]])
+        ->and(PieceRole::readPiece($piece, ['walls', 'decor'])['a'][0]->keeps)->toBe(['floor' => $attached])
+        ->and(PieceRole::readPiece($piece, ['walls', 'decor'])['a'][0]->tiles)->toBe([]);
+
+    $middle = createKeepsTestPiece('middle', ['glyphs' => [' w '], 'keeps' => ['walls']]);
+    expect(PieceRole::readPiece($middle)['w'][0]->keeps['walls'])->toBe([
+        ['dx' => 0, 'dy' => 0], ['dx' => -1, 'dy' => 0], ['dx' => 1, 'dy' => 0],
+    ]);
+});
+
+it('deduplicates equivalent roles but not distinct kept layers or footprints', function () {
+    $data = ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']]];
+    $planner = GlyphTilePlanner::fromPieces([
+        createKeepsTestPiece('plain', $data),
+        createKeepsTestPiece('mounted', $data + ['keeps' => ['walls']]),
+        createKeepsTestPiece('both', $data + ['keeps' => ['walls', 'floor']]),
+        createKeepsTestPiece('both-reordered', $data + ['keeps' => ['floor', 'walls']]),
+        createKeepsTestPiece('wide', array_replace($data, ['glyphs' => ['w '], 'tiles' => ['decor' => ['5 0']], 'keeps' => ['walls']])),
+    ], 'buildings');
+
+    expect(array_map(static fn(PieceRole $role): string => $role->key, $planner->getRoles('w')))
+        ->toBe(['plain:0:0', 'mounted:0:0', 'both:0:0', 'wide:0:0'])
+        ->and($planner->findRole('w', 'both-reordered:0:0'))->toBe($planner->findRole('w', 'both:0:0'))
+        ->and($planner->findRole('w', 'mounted:0:0'))->not->toBe($planner->findRole('w', 'plain:0:0'));
+});
+
+it('preserves the actual underlay at the arriving role and its attached blank', function (array $keeps, array $expectedWalls) {
+    $pieces = [
+        createKeepsTestPiece('wall', ['glyphs' => ['# '], 'tiles' => ['walls' => ['10 11']]]),
+        createKeepsTestPiece('window', ['glyphs' => ['w '], 'tiles' => ['decor' => ['5 6']], 'keeps' => $keeps]),
+    ];
+    $plan = planKeepsTestChanges($pieces, [['x' => 1, 'y' => 0, 'old' => '#', 'new' => 'w']],
+        [[' ', '#', ' ', ' ']], ['walls' => [['0', '10', '11', '0']]]);
+
+    expect($plan['unresolved'])->toBe([])
+        ->and($plan['tiles']['walls'] ?? [])->toBe($expectedWalls)
+        ->and($plan['tiles']['decor'])->toBe([
+            ['x' => 1, 'y' => 0, 'entry' => '5'], ['x' => 2, 'y' => 0, 'entry' => '6'],
+        ]);
+})->with([
+    'kept layer' => [['walls'], []],
+    'existing non-keeping behaviour' => [[], [['x' => 1, 'y' => 0, 'entry' => '0'], ['x' => 2, 'y' => 0, 'entry' => '0']]],
+]);
+
+it('protects an arriving kept cell from a different leaving role but not cells outside it', function () {
+    $pieces = [
+        createKeepsTestPiece('wall', ['glyphs' => ['# '], 'tiles' => ['walls' => ['10 11']]]),
+        createKeepsTestPiece('window', ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']]),
+    ];
+    $plan = planKeepsTestChanges($pieces, [
+        ['x' => 1, 'y' => 0, 'old' => '#', 'new' => ' '], ['x' => 2, 'y' => 0, 'old' => ' ', 'new' => 'w'],
+    ], [[' ', '#', ' ', ' ']], ['walls' => [['0', '10', '11', '0']]]);
+
+    expect($plan['tiles'])->toBe([
+        'walls' => [['x' => 1, 'y' => 0, 'entry' => '0']],
+        'decor' => [['x' => 2, 'y' => 0, 'entry' => '5']],
+    ]);
+});
+
+it('erases and moves only a keeping role\'s own tiles, never independent underlay', function () {
+    $pieces = [
+        createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]),
+        createKeepsTestPiece('window', ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']], 'keeps' => ['walls']]),
+    ];
+    $plan = planKeepsTestChanges($pieces, [
+        ['x' => 1, 'y' => 0, 'old' => 'w', 'new' => ' '], ['x' => 3, 'y' => 0, 'old' => '#', 'new' => 'w'],
+    ], [[' ', 'w', ' ', '#']], ['walls' => [['0', '13', '0', '10']], 'decor' => [['0', '5', '0', '0']]]);
+
+    expect($plan['tiles'])->toBe(['decor' => [['x' => 1, 'y' => 0, 'entry' => '0'], ['x' => 3, 'y' => 0, 'entry' => '5']]]);
+});
+
+it('requires a real role choice when identical artwork has different keeping behaviour', function () {
+    $wall = createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]);
+    $data = ['glyphs' => ['w'], 'tiles' => ['decor' => ['5']]];
+    $pieces = [$wall, createKeepsTestPiece('plain', $data), createKeepsTestPiece('mounted', $data + ['keeps' => ['walls']])];
+    $changes = [['x' => 0, 'y' => 0, 'old' => '#', 'new' => 'w']];
+    $glyphs = [['#']];
+    $tiles = ['walls' => [['10']]];
+
+    expect(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles)['unresolved']['w'])->toHaveCount(2)
+        ->and(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles, ['w' => 'mounted:0:0'])['tiles'])->not->toHaveKey('walls')
+        ->and(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles, ['w' => 'plain:0:0'])['tiles']['walls'])
+        ->toBe([['x' => 0, 'y' => 0, 'entry' => '0']])
+        ->and(planKeepsTestChanges($pieces, $changes, $glyphs, $tiles, ['w' => 'plain:0:0'], ['0,0' => 'mounted:0:0'])['tiles'])
+        ->not->toHaveKey('walls');
+});
+
+it('does not invent missing kept layers or block independently authored arriving tiles', function () {
+    $pieces = [
+        createKeepsTestPiece('window', ['glyphs' => ['w '], 'tiles' => ['decor' => ['5 6']], 'keeps' => ['walls']]),
+        createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['12']]]),
+    ];
+    $plan = planKeepsTestChanges($pieces, [['x' => 0, 'y' => 0, 'old' => ' ', 'new' => 'w']], [[' ', ' ']], []);
+    expect($plan['tiles'])->toBe(['decor' => [['x' => 0, 'y' => 0, 'entry' => '5'], ['x' => 1, 'y' => 0, 'entry' => '6']]]);
+
+    $plan = planKeepsTestChanges($pieces, [
+        ['x' => 0, 'y' => 0, 'old' => ' ', 'new' => 'w'], ['x' => 1, 'y' => 0, 'old' => ' ', 'new' => '#'],
+    ], [[' ', ' ']], ['walls' => [['10', '11']]]);
+    expect($plan['tiles']['walls'])->toBe([['x' => 1, 'y' => 0, 'entry' => '12']]);
+});
+
+it('stamps multi-cell keeps through PiecePlacer with byte-preserving undo and save round trips', function () {
+    $root = mapGraphicsProject();
+    $directory = $root . '/assets/Maps/test-map';
+    file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("####\n####", 'AUTHORED', "// Preserve this authored layer.\n"));
+    writeTileLayer($directory, '03.walls.tiles.php', "10 11 10 11\n11 10 11 10", "// Independent underlay.\n");
+    writeTileLayer($directory, '02.decor.tiles.php', "0 0 0 0\n0 0 0 0");
+    writeTestTileset($root, pieces: [
+        'wall-one' => ['name' => 'Wall one', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]],
+        'wall-two' => ['name' => 'Wall two', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['11']]],
+        'mounted' => ['name' => 'Mounted', 'layer' => 'buildings', 'glyphs' => ['w ', ' b'],
+            'tiles' => ['decor' => ['5 6', '0 7']], 'keeps' => ['walls']],
+    ]);
+    $map = loadLayeredMap($root);
+    $before = $map->captureLayerSnapshot();
+    $tilesBefore = $map->getTileLayerSources();
+    $disk = sourceHashTree($directory);
+    $piece = PiecePlacer::loadPieces($map)['mounted'];
+    $result = PiecePlacer::stampArea($map, $piece, ['x' => 1, 'y' => 0], ['x' => 1, 'y' => 0], null);
+    $after = $map->captureLayerSnapshot();
+    $glyphSourcesAfter = $map->getGridSources();
+    $tilesAfter = $map->getTileLayerSources();
+    $walls = $directory . '/graphics/03.walls.tiles.php';
+
+    expect($result['command'])->not->toBeNull()
+        ->and($tilesAfter[$walls])->toBe($tilesBefore[$walls])
+        ->and($map->getLayerSymbol('map:4', 2, 0))->toBe('#')
+        ->and($map->getLayerSymbol('map:4', 1, 1))->toBe('#')
+        ->and(sourceHashTree($directory))->toBe($disk);
+    $result['command']->undo();
+    expect($map->captureLayerSnapshot())->toBe($before)->and($map->getTileLayerSources())->toBe($tilesBefore);
+    $result['command']->execute();
+    expect($map->captureLayerSnapshot())->toBe($after)->and($map->getTileLayerSources())->toBe($tilesAfter);
+    $map->save();
+    expect(array_keys(array_diff_assoc(sourceHashTree($directory), $disk)))
+        ->toBe(['graphics/02.decor.tiles.php', 'layers/04.buildings.map.php'])
+        ->and(file_get_contents($walls))->toBe($tilesBefore[$walls]);
+
+    $map = loadLayeredMap($root);
+    expect($map->getGridSources())->toBe($glyphSourcesAfter)->and($map->getTileLayerSources())->toBe($tilesAfter)
+        ->and($map->isDirty())->toBeFalse();
+    $writes = [['x' => 1, 'y' => 0, 'symbol' => ' '], ['x' => 2, 'y' => 1, 'symbol' => ' ']];
+    $plan = CanvasEditor::plan($map, 'map:4', $writes);
+    expect($plan['unresolved'])->toBe([])->and($plan['tiles'])->not->toHaveKey('walls');
+    CanvasEditor::apply($map, 'map:4', $writes, 'Erase mounted glyphs', $plan['tiles']);
+    expect($map->getTileLayerSources()[$walls])->toBe($tilesBefore[$walls]);
+});
+
+it('explains arbitrary kept underlay only at matching role cells, not stale tiles elsewhere', function () {
+    $root = mapGraphicsProject();
+    $directory = $root . '/assets/Maps/test-map';
+    file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("w   \n    ", 'AUTHORED'));
+    writeTestTileset($root, pieces: [
+        'wall-one' => ['name' => 'Wall one', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]],
+        'wall-two' => ['name' => 'Wall two', 'layer' => 'buildings', 'glyphs' => ['@'], 'tiles' => ['walls' => ['11']]],
+        'window' => ['name' => 'Window', 'layer' => 'buildings', 'glyphs' => ['w '], 'tiles' => ['decor' => ['5 6']], 'keeps' => ['walls']],
+    ]);
+    writeTileLayer($directory, '02.decor.tiles.php', "5 6 0 0\n0 0 0 0");
+    writeTileLayer($directory, '03.walls.tiles.php', "10 11 10 0\n0 0 11 10");
+    expect(getKeepsTestStaleIssues($root))->toBe([
+        '3 tiles on the walls tile layer are drawn by a tileset piece whose glyph is no longer there: (2, 0), (2, 1), (3, 1).',
+    ]);
+
+    // The glyph alone must not excuse another role's underlay when its identifying art contradicts it.
+    writeTileLayer($directory, '02.decor.tiles.php', "9 0 0 0\n0 0 0 0");
+    expect(getKeepsTestStaleIssues($root))->toBe([
+        '5 tiles on the walls tile layer are drawn by a tileset piece whose glyph is no longer there: (0, 0), (1, 0), (2, 0) and 2 more.',
+    ]);
+});
+
+it('does not extend a surviving role\'s keeps into a missing sibling glyph\'s cells', function () {
+    $root = mapGraphicsProject();
+    $directory = $root . '/assets/Maps/test-map';
+    file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("a   \n    ", 'AUTHORED'));
+    writeTestTileset($root, pieces: [
+        'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]],
+        'mounted' => ['name' => 'Mounted', 'layer' => 'buildings', 'glyphs' => ['a b'],
+            'tiles' => ['decor' => ['5 0 6']], 'keeps' => ['walls']],
+    ]);
+    writeTileLayer($directory, '02.decor.tiles.php', "5 0 0 0\n0 0 0 0");
+    writeTileLayer($directory, '03.walls.tiles.php', "10 10 10 0\n0 0 0 0");
+    expect(getKeepsTestStaleIssues($root))->toBe([
+        '1 tile on the walls tile layer is drawn by a tileset piece whose glyph is no longer there: (2, 0).',
+    ]);
+});
+
+it('allows glyph-only keeping roles without claiming an entire layer', function () {
+    $root = mapGraphicsProject();
+    $directory = $root . '/assets/Maps/test-map';
+    file_put_contents($directory . '/layers/04.buildings.map.php', MapGridSource::buildSource("g   \n    ", 'AUTHORED'));
+    writeTestTileset($root, pieces: [
+        'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'glyphs' => ['#'], 'tiles' => ['walls' => ['10']]],
+        'mounted' => ['name' => 'Mounted', 'layer' => 'buildings', 'glyphs' => ['g '], 'keeps' => ['walls']],
+    ]);
+    writeTileLayer($directory, '03.walls.tiles.php', "10 10 10 0\n0 0 0 0");
+    expect(getKeepsTestStaleIssues($root))->toBe([
+        '1 tile on the walls tile layer is drawn by a tileset piece whose glyph is no longer there: (2, 0).',
+    ]);
+});
+
+it('keeps underlay for an assigned same-glyph repaint and clips attached cells off the map', function () {
+    $pieces = [
+        createKeepsTestPiece('wall', ['glyphs' => ['#'], 'tiles' => ['walls' => ['10']]]),
+        createKeepsTestPiece('mounted', ['glyphs' => ['# '], 'tiles' => ['decor' => ['5 6']], 'keeps' => ['walls']]),
+    ];
+    $plan = planKeepsTestChanges($pieces, [['x' => 0, 'y' => 0, 'old' => '#', 'new' => '#']],
+        [['#']], ['walls' => [['10']]], assigned: ['0,0' => 'mounted:0:0']);
+    expect($plan)->toBe(['tiles' => ['decor' => [['x' => 0, 'y' => 0, 'entry' => '5']]], 'unresolved' => [], 'glyphs' => []]);
+});
+
+it('leaves underlay intact when PiecePlacer stamps a piece containing only blank glyphs', function () {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: [
+        'blank' => ['name' => 'Blank', 'layer' => 'buildings', 'glyphs' => ['  '],
+            'tiles' => ['decor' => ['8 9']], 'keeps' => ['floor']],
+    ]);
+    $map = loadLayeredMap($root);
+    $before = $map->getTileLayerSources();
+    $glyphs = array_diff_key($map->getGridSources(), $before);
+    $result = PiecePlacer::stampArea($map, PiecePlacer::loadPieces($map)['blank'],
+        ['x' => 1, 'y' => 0], ['x' => 1, 'y' => 0], null);
+
+    expect(array_diff_key($map->getGridSources(), $map->getTileLayerSources()))->toBe($glyphs)
+        ->and($map->getTileLayerSources()[$map->directory . '/graphics/01.floor.tiles.php'])
+        ->toBe($before[$map->directory . '/graphics/01.floor.tiles.php'])
+        ->and($result['command'])->not->toBeNull();
+    $result['command']->undo();
+    expect($map->getTileLayerSources())->toBe($before);
+});
diff --git a/tests/Unit/TilesetPiecesTest.php b/tests/Unit/TilesetPiecesTest.php
new file mode 100644
index 00000000..8c1cfba5
--- /dev/null
+++ b/tests/Unit/TilesetPiecesTest.php
@@ -0,0 +1,833 @@
+> Synthetic pieces for tileset `home`. */
+function buildTestPieces(): array
+{
+    return [
+        'bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['= ', '=H'],
+            'tiles' => ['decor' => ['5 5', '6 0'], 'furniture' => ['7 0', '0 8']]],
+        'lamp' => ['name' => 'Lamp', 'layer' => 'fixtures', 'glyphs' => ['i']],
+        'rug' => ['name' => 'Rug', 'layer' => 'terrain', 'glyphs' => ['~~'], 'tiles' => ['floor' => ['2816 2816']]],
+        'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines',
+            'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'],
+            'tiles' => ['walls' => ['horizontal' => '10', 'vertical' => '11', 'corner' => '12']]],
+    ];
+}
+
+/**
+ * The editor on the graphics test map, its tileset listing the test pieces.
+ *
+ * @return array{Editor, ProjectMap, string}
+ */
+function createPieceCanvasEditor(bool $ragged = false): array
+{
+    $root = mapGraphicsProject($ragged);
+    writeTestTileset($root, pieces: buildTestPieces());
+
+    return layeredCanvasEditor($root);
+}
+
+function getCurrentToast(Editor $editor): ?Ichiloto\Editor\Status\Toast
+{
+    return getEditorProperty($editor, 'toasts')->current();
+}
+
+/** Selects the layer a test piece goes on, as L does. */
+function selectPieceLayer(Editor $editor, string $layerName): void
+{
+    $layer = array_find(callEditorMethod($editor, 'getSelectedMap')->getLayers(), static fn(array $layer): bool => $layer['name'] === $layerName);
+    callEditorMethod($editor, 'selectCanvasLayer', $layer['id']);
+}
+
+/** Chooses a piece with the keys: its layer, P, down to its entry and Enter. */
+function choosePieceByKeys(Editor $editor, string $id = 'bed'): void
+{
+    selectPieceLayer($editor, buildTestPieces()[$id]['layer']);
+    callEditorMethod($editor, 'dispatchInput', 'P');
+    $downs = array_search($id, array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'value'), true);
+    for ($i = 0; $i < $downs; $i++) {
+        callEditorMethod($editor, 'dispatchInput', "\033[B");
+    }
+    callEditorMethod($editor, 'dispatchInput', "\n");
+}
+
+it('lists the pieces for the layer being edited in the shared picker with P', function () {
+    [$editor] = createPieceCanvasEditor();
+    selectPieceLayer($editor, 'buildings');
+    callEditorMethod($editor, 'dispatchInput', 'P');
+
+    expect(getEditorProperty($editor, 'eventOptionDialogTitle'))->toBe('Buildings piece')
+        ->and(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([
+            ['label' => 'Bed', 'value' => 'bed', 'description' => '2 x 2 · tiles: decor, furniture'],
+            ['label' => 'Wall', 'value' => 'wall', 'description' => 'connected · tiles: walls'],
+        ]);
+
+    // Esc cancels without placing anything.
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(getEditorProperty($editor, 'eventOptionDialogEntries'))->toBe([])
+        ->and(getEditorProperty($editor, 'piecePlacement'))->toBeNull();
+
+    // A filter narrows the list; Enter chooses the match.
+    foreach (['P', '/', 'W', 'a', "\n", "\n"] as $key) {
+        callEditorMethod($editor, 'dispatchInput', $key);
+    }
+    expect(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('wall');
+
+    // Another layer offers only its own pieces.
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    selectPieceLayer($editor, 'terrain');
+    callEditorMethod($editor, 'dispatchInput', 'P');
+    expect(array_column(getEditorProperty($editor, 'eventOptionDialogEntries'), 'value'))->toBe(['rug']);
+});
+
+it('says which layers have pieces when the layer being edited has none', function () {
+    [$editor, $map] = createPieceCanvasEditor();
+    writeTestTileset(getEditorProperty($editor, 'projectRoot'), pieces: ['wall' => buildTestPieces()['wall']]);
+    selectPieceLayer($editor, 'terrain');
+    callEditorMethod($editor, 'dispatchInput', 'P');
+
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse()
+        ->and(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN)
+        ->and(getCurrentToast($editor)?->message)->toBe('Home has no pieces for the Terrain layer. It has pieces for Buildings; press L to switch layers.')
+        ->and($map->isDirty())->toBeFalse();
+});
+
+it('offers choosing a piece and drawing a layer\'s tiles in the command palette', function () {
+    [$editor] = createPieceCanvasEditor();
+    $labels = array_map(static fn($item): string => $item->label . ' ' . $item->hint, callEditorMethod($editor, 'buildPaletteItems'));
+
+    expect(array_values(array_filter($labels, static fn(string $label): bool => str_starts_with($label, 'Pieces:'))))
+        ->toBe(['Pieces: Choose a piece to place P', "Pieces: Draw tiles for this layer's glyphs T"]);
+});
+
+it('says why a map has no pieces and opens nothing', function (string $case, string $message) {
+    $root = $case === 'no tileset' ? layeredMapProject() : mapGraphicsProject();
+    if ($case === 'no tileset') {
+        writeTestTileset($root, pieces: buildTestPieces());
+    }
+    if ($case === 'broken tileset') {
+        file_put_contents($root . '/assets/Data/Tilesets/home.php', " 'Home'];\n");
+    }
+    if ($case === 'half-tile piece') {
+        writeTestTileset($root, pieces: ['bed' => ['name' => 'Bed', 'layer' => 'buildings', 'glyphs' => ['=='],
+            'tiles' => ['decor' => ['5L 5R']]]]);
+    }
+    [$editor, $map] = layeredCanvasEditor($root);
+    callEditorMethod($editor, 'dispatchInput', 'P');
+
+    expect(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse()
+        ->and(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN)
+        ->and(getCurrentToast($editor)?->message)->toContain($message)
+        ->and($map->isDirty())->toBeFalse();
+})->with([
+    'no tileset' => ['no tileset', 'This map has no kind yet, so it has no pieces. Set its Kind in the Inspector.'],
+    'no pieces' => ['no pieces', 'Tileset home has no pieces'],
+    'broken tileset' => ['broken tileset', 'Pieces are unavailable: Tileset home needs at least one sheet.'],
+    // A field cell holds one whole tile, so a piece naming half a tile is refused, not reinterpreted.
+    'half-tile piece' => ['half-tile piece', "'5L' names half of tile 5, but each field cell holds one whole tile; use whole tiles, such as '5'."],
+]);
+
+it('previews the piece\'s footprint at the cursor while placing it', function () {
+    [$editor, $map] = createPieceCanvasEditor();
+    choosePieceByKeys($editor);
+    callEditorMethod($editor, 'dispatchInput', "\033[C");
+
+    expect(callEditorMethod($editor, 'getPiecePreviewCells'))->toBe([0 => [1 => '=', 2 => null], 1 => [1 => '=', 2 => 'H']])
+        ->and($map->renderPreview(4, 2, stampPreview: callEditorMethod($editor, 'getPiecePreviewCells'))[1])
+        ->toContain("\033[7m=\033[0m", "\033[7mH\033[0m");
+
+    $frame = renderEditorPlainFrame($editor, 160, 45);
+    expect($frame)->toContain('.=..', '.=H.', 'PIECE Bed  Enter:Stamp  Drag:Fill  Esc:Done')
+        ->and($map->isDirty())->toBeFalse();
+
+    // Esc ends placement and the preview goes with it.
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(getEditorProperty($editor, 'piecePlacement'))->toBeNull()
+        ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('./..', '.xx.')->not->toContain('PIECE');
+});
+
+it('fits the placement hint to narrow canvases and shows P:Piece in Normal mode', function () {
+    [$editor] = createPieceCanvasEditor();
+    expect(callEditorMethod($editor, 'getPiecePlacementHelp', 40))->toBeNull();
+    choosePieceByKeys($editor);
+
+    expect(callEditorMethod($editor, 'getPiecePlacementHelp', 46))->toBe('PIECE Bed  Enter:Stamp  Drag:Fill  Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 45))->toBe('PIECE Bed Enter:Stamp Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 33))->toBe('PIECE Bed Enter:Stamp Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 29))->toBe('PIECE Enter:Stamp Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 24))->toBe('Enter:Stamp Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 12))->toBe('Esc:Done');
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(renderEditorPlainFrame($editor, 200, 45))->toContain('i:Paint b/l/r/R/s:Tool L:Layer P:Piece');
+});
+
+it('stamps glyphs on the piece\'s layer and its tiles in one undo step', function () {
+    [$editor, $map, $root] = createPieceCanvasEditor();
+    $graphics = $map->directory . '/graphics';
+    $before = $map->captureLayerSnapshot();
+    $tilesBefore = $map->getTileLayerSources();
+    $disk = sourceHashTree($map->directory);
+    setEditorProperty($editor, 'selectedPaintColor', 'cyan');
+    expect(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:1');
+
+    choosePieceByKeys($editor);
+    setEditorProperty($editor, 'cursorX', 2);
+    callEditorMethod($editor, 'dispatchInput', "\n");
+
+    // The glyphs land on buildings, the layer being edited, and leave the
+    // terrain alone; a space glyph and a 0 tile leave their cells alone.
+    expect($map->getLayerSymbol('map:4', 2, 0))->toBe('=')
+        ->and($map->getLayerSymbol('map:4', 3, 0))->toBe(' ')
+        ->and($map->getLayerSymbol('map:4', 2, 1))->toBe('=')
+        ->and($map->getLayerSymbol('map:4', 3, 1))->toBe('H')
+        ->and($map->getLayerColor('map:4', 3, 1))->toBe('cyan')
+        ->and($map->getLayerSymbol('map:1', 2, 0))->toBe('.')
+        ->and(callEditorMethod($editor, 'getActiveCanvasLayer'))->toBe('map:4')
+        ->and($map->isDirty())->toBeTrue();
+    $tiles = $map->getTileLayerSources();
+    expect(readTileEntries($tiles[$graphics . '/02.decor.tiles.php']))->toBe([['0', '5', '5', '5'], ['0', '0', '6', '5']])
+        ->and(readTileEntries($tiles[$graphics . '/03.furniture.tiles.php']))->toBe([['0', '0', '7', '0'], ['0', '0', '0', '8']])
+        ->and($tiles[$graphics . '/01.floor.tiles.php'])->toBe($tilesBefore[$graphics . '/01.floor.tiles.php']);
+
+    // Placement stays for the next stamp; one undo takes the whole stamp back.
+    expect(getEditorProperty($editor, 'piecePlacement'))->not->toBeNull();
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->captureLayerSnapshot())->toBe($before)
+        ->and($map->getTileLayerSources())->toBe($tilesBefore)
+        ->and($map->isDirty())->toBeFalse();
+    callEditorMethod($editor, 'dispatchInput', "\x19");
+    expect($map->getTileLayerSources())->toBe($tiles)
+        ->and($map->getLayerSymbol('map:4', 3, 1))->toBe('H');
+
+    // Saving writes the changed terminal layer and tile layers, and nothing else.
+    callEditorMethod($editor, 'dispatchInput', "\x13");
+    expect(array_keys(array_diff_assoc(sourceHashTree($map->directory), $disk)))->toBe([
+        'graphics/02.decor.tiles.php', 'graphics/03.furniture.tiles.php', 'layers/04.buildings.map.php',
+    ])
+        ->and(file_get_contents($graphics . '/02.decor.tiles.php'))->toBe("and(file_get_contents($graphics . '/03.furniture.tiles.php'))->toBe("and(array_map(static fn($layer): string => $layer->name, MapGraphics::loadFromDirectory(
+            $map->directory, 'test-map', 'home', loadLayeredMap($root)->getLayerSet(), $root . '/assets')->layers))
+        ->toBe(['floor', 'decor', 'furniture']);
+
+    // Undoing after the save and saving again restores every authored byte.
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    callEditorMethod($editor, 'dispatchInput', "\x13");
+    expect(sourceHashTree($map->directory))->toBe($disk);
+});
+
+it('fills the area between two corners with whole copies of the piece in one undo step', function () {
+    [$editor, $map] = createPieceCanvasEditor();
+    $before = $map->captureLayerSnapshot();
+    $tilesBefore = $map->getTileLayerSources();
+    choosePieceByKeys($editor);
+
+    // Enter stamps at the cursor and anchors there; the next Enter fills the area to the cursor.
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 0, 'y' => 0])
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 120))->toContain('Enter:Fill', 'Esc:Unanchor');
+    setEditorProperty($editor, 'cursorX', 3);
+    setEditorProperty($editor, 'cursorY', 1);
+    expect(array_keys(callEditorMethod($editor, 'getPiecePreviewCells')[1]))->toBe([0, 1, 2, 3]);
+    callEditorMethod($editor, 'dispatchInput', "\n");
+
+    foreach ([0, 2] as $x) {
+        expect($map->getLayerSymbol('map:4', $x, 0))->toBe('=')
+            ->and($map->getLayerSymbol('map:4', $x + 1, 1))->toBe('H');
+    }
+    // The stamp and the fill are two steps; undoing both restores the map.
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->getLayerSymbol('map:4', 2, 0))->not->toBe('=')
+        ->and($map->getLayerSymbol('map:4', 0, 0))->toBe('=');
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->captureLayerSnapshot())->toBe($before)
+        ->and($map->getTileLayerSources())->toBe($tilesBefore);
+
+    // Esc drops the anchor, so Enter stamps a single copy again.
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(getEditorProperty($editor, 'piecePlacement'))->not->toBeNull()
+        ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull();
+});
+
+it('draws a piece area with a mouse drag, and a click stamps one copy where it lands', function () {
+    [$editor, $map] = createPieceCanvasEditor();
+    $before = $map->captureLayerSnapshot();
+    choosePieceByKeys($editor);
+    $bounds = callEditorMethod($editor, 'getCanvasPreviewBounds');
+    $at = static fn(string $kind, int $x, int $y): string => sprintf("\033[<%d;%d;%d%s", $kind === 'move' ? 32 : 0,
+        $bounds['left'] + $x, $bounds['top'] + $y, $kind === 'release' ? 'm' : 'M');
+
+    callEditorMethod($editor, 'dispatchInput', $at('press', 0, 0));
+    callEditorMethod($editor, 'dispatchInput', $at('move', 3, 1));
+    expect(array_keys(callEditorMethod($editor, 'getPiecePreviewCells')[1]))->toBe([0, 1, 2, 3])
+        ->and($map->isDirty())->toBeFalse();
+    callEditorMethod($editor, 'dispatchInput', $at('release', 3, 1));
+
+    expect($map->getLayerSymbol('map:4', 0, 0))->toBe('=')
+        ->and($map->getLayerSymbol('map:4', 2, 0))->toBe('=')
+        ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull();
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->captureLayerSnapshot())->toBe($before);
+
+    callEditorMethod($editor, 'dispatchInput', $at('press', 2, 0));
+    callEditorMethod($editor, 'dispatchInput', $at('release', 2, 0));
+    expect($map->getLayerSymbol('map:4', 2, 0))->toBe('=')
+        ->and($map->getLayerSymbol('map:4', 0, 0))->not->toBe('=');
+});
+
+it('stamps a piece\'s own colours byte for byte and the brush colour where it has none', function () {
+    $root = mapGraphicsProject();
+    // A small tree: two leaves and a trunk in their own colours, and a plain cell that takes the brush.
+    writeTestTileset($root, pieces: ['tree' => ['name' => 'Tree', 'layer' => 'buildings',
+        'glyphs' => ['^^', '|=']]]);
+    [$editor, $map] = layeredCanvasEditor($root);
+    setEditorProperty($editor, 'selectedPaintColor', 'cyan');
+    selectPieceLayer($editor, 'buildings');
+    callEditorMethod($editor, 'dispatchInput', 'P');
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    callEditorMethod($editor, 'dispatchInput', "\n");
+
+    expect($map->getLayerCellStyle('map:4', 0, 0))->toBe(['prefix' => '', 'suffix' => ''])
+        ->and($map->getLayerCellStyle('map:4', 1, 0))->toBe(['prefix' => '', 'suffix' => ''])
+        ->and($map->getLayerCellStyle('map:4', 0, 1))->toBe(['prefix' => '', 'suffix' => ''])
+        ->and($map->getLayerSymbol('map:4', 0, 1))->toBe('|')
+        ->and($map->getLayerColor('map:4', 1, 1))->toBe('cyan');
+    callEditorMethod($editor, 'dispatchInput', "\x13");
+    expect((string) file_get_contents($map->directory . '/layers/04.buildings.map.php'))
+        ->toContain('^^', '|')->not->toContain("\033");
+});
+
+it('creates graphics/ for a map whose tileset pieces name tile layers it lacks', function () {
+    $root = layeredMapProject();
+    writeTestTileset($root, pieces: buildTestPieces());
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", (string) file_get_contents($data)));
+    [$editor, $map] = layeredCanvasEditor($root);
+
+    choosePieceByKeys($editor, 'rug');
+    callEditorMethod($editor, 'dispatchInput', "\033[B");
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    callEditorMethod($editor, 'dispatchInput', "\x13");
+
+    expect($map->getLayerSymbol('map:1', 0, 1))->toBe('~')
+        ->and(file_get_contents($map->directory . '/graphics/00.floor.tiles.php'))
+        ->toBe("and(MapGraphics::loadFromDirectory($map->directory, 'test-map', 'home', loadLayeredMap($root)->getLayerSet(), $root . '/assets'))
+        ->not->toBeNull();
+});
+
+it('orders a new tile layer by the gameplay layer it belongs to', function () {
+    [$editor, $map, $root] = createPieceCanvasEditor();
+    // floor belongs to Terrain (the rug writes it) and decor to Buildings (the bed).
+    writeTestTileset($root, pieces: [...buildTestPieces(),
+        'grass' => ['name' => 'Grass', 'layer' => 'terrain', 'glyphs' => [';'], 'tiles' => ['ground' => ['2864']]]]);
+    $graphics = $map->directory . '/graphics';
+
+    // A Terrain layer goes under Buildings' decor; with no free order below
+    // decor, decor and every later layer move up one.
+    callEditorMethod($editor, 'choosePiece', 'grass');
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(array_map(basename(...), array_keys($map->getTileLayerSources())))
+        ->toBe(['01.floor.tiles.php', '02.ground.tiles.php', '03.decor.tiles.php']);
+
+    // A Buildings layer goes after every layer of Buildings and below.
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    callEditorMethod($editor, 'choosePiece', 'bed');
+    callEditorMethod($editor, 'dispatchInput', "\033[C");
+    callEditorMethod($editor, 'dispatchInput', "\033[C");
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(array_map(basename(...), array_keys($map->getTileLayerSources())))
+        ->toBe(['01.floor.tiles.php', '02.ground.tiles.php', '03.decor.tiles.php', '04.furniture.tiles.php']);
+
+    // Saving writes the renumbered layer and removes its old file; undo restores the order.
+    callEditorMethod($editor, 'dispatchInput', "\x13");
+    expect(array_map(basename(...), glob($graphics . '/*.tiles.php')))
+        ->toBe(['01.floor.tiles.php', '02.ground.tiles.php', '03.decor.tiles.php', '04.furniture.tiles.php']);
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect(array_map(basename(...), array_keys($map->getTileLayerSources())))->toBe(['01.floor.tiles.php', '02.decor.tiles.php']);
+});
+
+it('puts a new layer in the free order just below a higher layer without renumbering', function () {
+    [$editor, $map, $root] = createPieceCanvasEditor();
+    rename($map->directory . '/graphics/01.floor.tiles.php', $map->directory . '/graphics/00.floor.tiles.php');
+    rename($map->directory . '/graphics/02.decor.tiles.php', $map->directory . '/graphics/05.decor.tiles.php');
+    writeTestTileset($root, pieces: [...buildTestPieces(),
+        'grass' => ['name' => 'Grass', 'layer' => 'terrain', 'glyphs' => [';'], 'tiles' => ['ground' => ['2864']]]]);
+    [$editor, $map] = layeredCanvasEditor($root);
+
+    callEditorMethod($editor, 'choosePiece', 'grass');
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(array_map(basename(...), array_keys($map->getTileLayerSources())))
+        ->toBe(['00.floor.tiles.php', '04.ground.tiles.php', '05.decor.tiles.php']);
+});
+
+it('refuses a stamp that cannot be made whole and changes nothing', function (string $case, string $message) {
+    [$editor, $map, $root] = createPieceCanvasEditor($case === 'ragged');
+    if ($case === 'unreadable tile layer' || $case === 'half-tile layer') {
+        writeTileLayer($map->directory, '02.decor.tiles.php', $case === 'half-tile layer' ? "0 5L 5R 0\n0 0 0 5" : "0 5 0 0\n0 0 0 x");
+        [$editor, $map] = layeredCanvasEditor($root);
+    }
+    $before = $map->captureLayerSnapshot();
+    $tiles = $map->getTileLayerSources();
+    if ($case === 'missing layer') {
+        callEditorMethod($editor, 'choosePiece', 'lamp');
+    } else {
+        choosePieceByKeys($editor);
+    }
+    setEditorProperty($editor, 'cursorX', match ($case) { 'off the edge' => 3, 'ragged' => 1, default => 0 });
+    getEditorProperty($editor, 'toasts')->clear();
+    callEditorMethod($editor, 'dispatchInput', "\n");
+
+    expect(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN)
+        ->and(getCurrentToast($editor)?->message)->toContain($message)
+        ->and(strtolower((string) getCurrentToast($editor)?->message))->toContain('nothing was changed.')
+        ->and($map->captureLayerSnapshot())->toBe($before)
+        ->and($map->getTileLayerSources())->toBe($tiles)
+        ->and($map->isDirty())->toBeFalse();
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->captureLayerSnapshot())->toBe($before);
+})->with([
+    'missing layer' => ['missing layer', 'Lamp goes on the fixtures layer, which this map does not have.'],
+    'off the edge' => ['off the edge', 'Bed (2 x 2) does not fit at (3, 0): the map has no cell at (4, 0).'],
+    'ragged' => ['ragged', 'Bed (2 x 2) does not fit at (1, 0): the map has no cell at (2, 1).'],
+    'unreadable tile layer' => ['unreadable tile layer', "'x' is not an RPG Maker tile identity"],
+    'half-tile layer' => ['half-tile layer', "'5L' names half of tile 5, but each field cell holds one whole tile"],
+]);
+
+it('ends placement when the mode, map, layer or tool changes', function (string $key) {
+    $root = mapGraphicsProject();
+    writeTestTileset($root, pieces: buildTestPieces());
+    loadLayeredMap($root)->duplicateTo($root . '/assets/Maps/zz-copy', 'zz-copy', 'Copy');
+    [$editor, $map] = layeredCanvasEditor($root);
+    choosePieceByKeys($editor);
+    $before = $map->captureLayerSnapshot();
+    if ($key === 'asset') {
+        setEditorProperty($editor, 'focusedPane', 'assets');
+        callEditorMethod($editor, 'dispatchInput', 'j');
+        setEditorProperty($editor, 'focusedPane', 'canvas');
+    } elseif ($key === 'layer') {
+        callEditorMethod($editor, 'dispatchInput', 'L');
+        callEditorMethod($editor, 'dispatchInput', "\n");
+    } else {
+        callEditorMethod($editor, 'dispatchInput', $key);
+    }
+    expect(getEditorProperty($editor, 'piecePlacement'))->toBeNull();
+    callEditorMethod($editor, 'applyPieceAtCursor');
+    expect($map->captureLayerSnapshot())->toBe($before);
+})->with(['e', 'n', 'i', 'b', 's', 'layer', 'asset']);
+
+it('switches to Map mode when a piece is chosen from Event mode', function () {
+    [$editor] = createPieceCanvasEditor();
+    callEditorMethod($editor, 'dispatchInput', 'e');
+    choosePieceByKeys($editor);
+
+    expect(getEditorProperty($editor, 'editingMode'))->toBe('map')
+        ->and(callEditorMethod($editor, 'getActivePiecePlacement')['piece']->id)->toBe('bed');
+});
+
+it('keeps P a glyph in Paint mode', function () {
+    [$editor, $map] = createPieceCanvasEditor();
+    foreach (['i', 'P'] as $key) {
+        callEditorMethod($editor, 'dispatchInput', $key);
+    }
+
+    expect($map->getLayerSymbol('map:1', 0, 0))->toBe('P')
+        ->and(getEditorProperty($editor, 'isEventOptionDialogOpen'))->toBeFalse()
+        ->and(getEditorProperty($editor, 'piecePlacement'))->toBeNull();
+});
+
+it('lists P in the help overlay', function () {
+    [$editor] = createPieceCanvasEditor();
+    $keys = array_column(getEditorProperty($editor, 'inputRouter')->describeBindings(), 'description', 'key');
+
+    expect($keys['P'] ?? null)->toContain('tileset piece');
+});
+
+/**
+ * The editor on a 6 x 5 map naming tileset `home`, for drawing walls: dots on
+ * terrain under a buildings layer holding the given rows (spaces by default),
+ * with a floor tile layer and no walls tile layer yet. A ragged map's last
+ * row is 3 cells wide.
+ *
+ * @param list|null $buildings The buildings layer's rows, as authored.
+ * @param array>|null $pieces The tileset's pieces, the test pieces by default.
+ * @return array{Editor, ProjectMap, string}
+ */
+function createWallCanvasEditor(?array $buildings = null, bool $ragged = false, ?array $pieces = null): array
+{
+    $root = layeredMapProject();
+    $directory = $root . '/assets/Maps/test-map';
+    $widths = $ragged ? [6, 6, 6, 6, 3] : [6, 6, 6, 6, 6];
+    $fill = static fn(string $cell): string => implode("\n", array_map(static fn(int $width): string => implode($cell === '2816' ? ' ' : '', array_fill(0, $width, $cell)), $widths));
+    $layers = ['01.terrain.map.php' => $fill('.'), '04.buildings.map.php' => $buildings === null ? $fill(' ') : implode("\n", $buildings), '07.detail.deco.php' => $fill(' ')];
+    foreach ($layers as $file => $text) {
+        file_put_contents($directory . '/layers/' . $file, Ichiloto\Engine\Field\MapGridSource::buildSource($text, 'AUTHORED', '// keep ' . $file . "\n"));
+    }
+    file_put_contents($directory . '/test-map.event.php', Ichiloto\Engine\Field\MapGridSource::buildSource($fill(' '), 'EVENTS'));
+    $data = $directory . '/test-map.data.php';
+    file_put_contents($data, str_replace("'events' => [],", "'events' => [], 'tileset' => 'home',", (string) file_get_contents($data)));
+    writeTestTileset($root, pieces: $pieces ?? buildTestPieces());
+    writeTileLayer($directory, '01.floor.tiles.php', $fill('2816'));
+
+    return layeredCanvasEditor($root);
+}
+
+/** Starts drawing the wall, the fourth piece in the picker. */
+function chooseWallByKeys(Editor $editor): void
+{
+    choosePieceByKeys($editor, 'wall');
+}
+
+/** Walks the canvas cursor to a cell with the arrow keys. */
+function moveCanvasCursorTo(Editor $editor, int $x, int $y): void
+{
+    foreach (['cursorX' => [$x, "\033[C", "\033[D"], 'cursorY' => [$y, "\033[B", "\033[A"]] as $property => [$target, $forward, $back]) {
+        for ($guard = 0; getEditorProperty($editor, $property) !== $target && $guard < 20; $guard++) {
+            callEditorMethod($editor, 'dispatchInput', getEditorProperty($editor, $property) < $target ? $forward : $back);
+        }
+    }
+}
+
+/** Presses Enter at each cell in turn, as a keyboard author draws. */
+function drawWallThrough(Editor $editor, array ...$cells): void
+{
+    foreach ($cells as [$x, $y]) {
+        moveCanvasCursorTo($editor, $x, $y);
+        callEditorMethod($editor, 'dispatchInput', "\n");
+    }
+}
+
+/** @return list The buildings layer's rows, one glyph per cell. */
+function readBuildingRows(ProjectMap $map): array
+{
+    $rows = [];
+    for ($y = 0; $y < $map->getHeight(); $y++) {
+        $row = '';
+        for ($x = 0; $map->hasLayerCell('map:4', $x, $y); $x++) {
+            $row .= $map->getLayerSymbol('map:4', $x, $y);
+        }
+        $rows[] = $row;
+    }
+
+    return $rows;
+}
+
+/** @return list>|null The walls tile layer's entries, or null when the map has none. */
+function readWallTiles(ProjectMap $map, string $file = '02.walls.tiles.php'): ?array
+{
+    $source = $map->getTileLayerSources()[$map->directory . '/graphics/' . $file] ?? null;
+
+    return $source === null ? null : readTileEntries($source);
+}
+
+/**
+ * The walls tile entries a map's wall glyphs should have: the test wall's
+ * tile for each shape glyph, `0` elsewhere.
+ *
+ * @param list $rows
+ * @return list>
+ */
+function buildWallTiles(array $rows): array
+{
+    return array_map(static fn(string $row): array => array_map(
+        static fn(string $glyph): string => ['-' => '10', '|' => '11', '+' => '12'][$glyph] ?? '0',
+        mb_str_split($row),
+    ), $rows);
+}
+
+it('draws a lone wall post at the cursor and anchors there', function () {
+    [$editor, $map] = createWallCanvasEditor();
+    chooseWallByKeys($editor);
+    expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull();
+
+    moveCanvasCursorTo($editor, 2, 1);
+    getEditorProperty($editor, 'toasts')->clear();
+    callEditorMethod($editor, 'dispatchInput', "\n");
+
+    $rows = ['      ', '  +   ', '      ', '      ', '      '];
+    expect(readBuildingRows($map))->toBe($rows)
+        ->and(readWallTiles($map))->toBe(buildWallTiles($rows))
+        ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 2, 'y' => 1])
+        ->and(getCurrentToast($editor)?->message)->toBe('Drew 1 wall cell. Anchor set; move and press Enter to draw a line or a room.')
+        ->and($map->getLayerSymbol('map:1', 2, 1))->toBe('.')
+        ->and($map->isDirty())->toBeTrue();
+});
+
+it('draws straight wall lines whose ends take the line\'s shape and turn where they meet, one undo step each', function () {
+    [$editor, $map] = createWallCanvasEditor();
+    $before = $map->captureLayerSnapshot();
+    $tilesBefore = $map->getTileLayerSources();
+    chooseWallByKeys($editor);
+
+    drawWallThrough($editor, [1, 1]);
+    $post = [$map->captureLayerSnapshot(), $map->getTileLayerSources()];
+    getEditorProperty($editor, 'toasts')->clear();
+    drawWallThrough($editor, [4, 1]);
+    $across = ['      ', ' ---- ', '      ', '      ', '      '];
+    expect(readBuildingRows($map))->toBe($across)
+        ->and(readWallTiles($map))->toBe(buildWallTiles($across))
+        ->and(getCurrentToast($editor)?->message)->toBe('Drew 4 wall cells. Enter draws on from here; Esc drops the anchor.')
+        ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 4, 'y' => 1]);
+    $line = [$map->captureLayerSnapshot(), $map->getTileLayerSources()];
+
+    // The next line starts at the anchor; the cell where the lines meet turns.
+    drawWallThrough($editor, [4, 3]);
+    $turned = ['      ', ' ---+ ', '    | ', '    | ', '      '];
+    expect(readBuildingRows($map))->toBe($turned)
+        ->and(readWallTiles($map))->toBe(buildWallTiles($turned));
+    $after = [$map->captureLayerSnapshot(), $map->getTileLayerSources()];
+
+    foreach ([$line, $post, [$before, $tilesBefore]] as [$layers, $tiles]) {
+        callEditorMethod($editor, 'dispatchInput', "\x1a");
+        expect($map->captureLayerSnapshot())->toBe($layers)
+            ->and($map->getTileLayerSources())->toBe($tiles);
+    }
+    expect($map->isDirty())->toBeFalse();
+    foreach ([$post, $line, $after] as [$layers, $tiles]) {
+        callEditorMethod($editor, 'dispatchInput', "\x19");
+        expect($map->captureLayerSnapshot())->toBe($layers)
+            ->and($map->getTileLayerSources())->toBe($tiles);
+    }
+});
+
+it('draws a room as the outline of the rectangle from the anchor to the cursor', function () {
+    [$editor, $map] = createWallCanvasEditor();
+    chooseWallByKeys($editor);
+    drawWallThrough($editor, [0, 0]);
+    getEditorProperty($editor, 'toasts')->clear();
+    drawWallThrough($editor, [3, 2]);
+
+    $room = ['+--+  ', '|  |  ', '+--+  ', '      ', '      '];
+    expect(readBuildingRows($map))->toBe($room)
+        ->and(readWallTiles($map))->toBe(buildWallTiles($room))
+        ->and(getCurrentToast($editor)?->message)->toBe('Drew 10 wall cells. Enter draws on from here; Esc drops the anchor.');
+});
+
+it('joins walls typed by hand, keeping their colour, and leaves other glyphs alone', function () {
+    [$editor, $map] = createWallCanvasEditor(['   |  ', '   |  ', '_  |  ', '      ', '      ']);
+    setEditorProperty($editor, 'selectedPaintColor', 'cyan');
+    chooseWallByKeys($editor);
+    drawWallThrough($editor, [0, 1], [2, 1]);
+
+    $rows = ['   |  ', '---+  ', '_  |  ', '      ', '      '];
+    expect(readBuildingRows($map))->toBe($rows)
+        // The hand-typed wall beside the line joins it and gets its tile;
+        // the walls further along are not touched.
+        ->and(readWallTiles($map))->toBe(buildWallTiles(['      ', '---+  ', '      ', '      ', '      ']))
+        ->and($map->getLayerColor('map:4', 3, 1))->toBe('red')
+        ->and($map->getLayerColor('map:4', 0, 1))->toBe('cyan')
+        ->and($map->getLayerSymbol('map:4', 0, 2))->toBe('_');
+});
+
+it('erases a wall cell with the erase keys and reshapes the walls beside it', function () {
+    [$editor, $map] = createWallCanvasEditor();
+    chooseWallByKeys($editor);
+    drawWallThrough($editor, [0, 0], [3, 2]);
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    $room = [$map->captureLayerSnapshot(), $map->getTileLayerSources()];
+
+    moveCanvasCursorTo($editor, 1, 0);
+    getEditorProperty($editor, 'toasts')->clear();
+    callEditorMethod($editor, 'dispatchInput', "\177");
+    $opened = ['| -+  ', '|  |  ', '+--+  ', '      ', '      '];
+    expect(readBuildingRows($map))->toBe($opened)
+        ->and(readWallTiles($map))->toBe(buildWallTiles($opened))
+        ->and(getCurrentToast($editor)?->message)->toBe('Erased the wall cell at (1, 0).')
+        ->and(getEditorProperty($editor, 'piecePlacement'))->not->toBeNull();
+
+    // One undo puts the cell and the reshaped walls back exactly.
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->captureLayerSnapshot())->toBe($room[0])
+        ->and($map->getTileLayerSources())->toBe($room[1]);
+
+    // Delete erases too; the corners it leaves straighten into lines.
+    moveCanvasCursorTo($editor, 3, 1);
+    callEditorMethod($editor, 'dispatchInput', "\033[3~");
+    $side = ['+---  ', '|     ', '+---  ', '      ', '      '];
+    expect(readBuildingRows($map))->toBe($side)
+        ->and(readWallTiles($map))->toBe(buildWallTiles($side));
+
+    // A cell that is not a wall is left alone, with a word why.
+    $layers = $map->captureLayerSnapshot();
+    $tiles = $map->getTileLayerSources();
+    moveCanvasCursorTo($editor, 1, 1);
+    getEditorProperty($editor, 'toasts')->clear();
+    callEditorMethod($editor, 'dispatchInput', "\177");
+    expect(getCurrentToast($editor)?->message)->toBe('(1, 1) is not part of a wall, so nothing was erased.')
+        ->and($map->captureLayerSnapshot())->toBe($layers)
+        ->and($map->getTileLayerSources())->toBe($tiles);
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($map->captureLayerSnapshot())->toBe($room[0]);
+});
+
+it('creates no tile layer for an erase that leaves only empty tiles', function () {
+    [$editor, $map] = createWallCanvasEditor(['      ', '  +   ', '      ', '      ', '      ']);
+    $tiles = $map->getTileLayerSources();
+    chooseWallByKeys($editor);
+    moveCanvasCursorTo($editor, 2, 1);
+    callEditorMethod($editor, 'dispatchInput', "\177");
+
+    expect(readBuildingRows($map)[1])->toBe('      ')
+        ->and($map->getTileLayerSources())->toBe($tiles)
+        ->and(readWallTiles($map))->toBeNull()
+        ->and($map->isDirty())->toBeTrue();
+});
+
+it('refuses a wall draw or erase that cannot be made whole and changes nothing', function () {
+    [$editor, $map] = createWallCanvasEditor(ragged: true);
+    chooseWallByKeys($editor);
+
+    // A cell past a short row is not on the map.
+    moveCanvasCursorTo($editor, 5, 4);
+    getEditorProperty($editor, 'toasts')->clear();
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(getCurrentToast($editor)?->level)->toBe(StatusLevel::WARN)
+        ->and(getCurrentToast($editor)?->message)->toBe('Wall cannot reach (5, 4): the map has no cell there. Nothing was changed.')
+        ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull()
+        ->and($map->isDirty())->toBeFalse();
+
+    // A room reaching past it is refused whole; the anchor stays put.
+    drawWallThrough($editor, [0, 3]);
+    $layers = $map->captureLayerSnapshot();
+    $tiles = $map->getTileLayerSources();
+    moveCanvasCursorTo($editor, 5, 4);
+    getEditorProperty($editor, 'toasts')->clear();
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(getCurrentToast($editor)?->message)->toBe('Wall cannot reach (3, 4): the map has no cell there. Nothing was changed.')
+        ->and($map->captureLayerSnapshot())->toBe($layers)
+        ->and($map->getTileLayerSources())->toBe($tiles)
+        ->and(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 0, 'y' => 3]);
+
+    // A wall whose gameplay layer the map lacks is never offered; placed
+    // some other way, it is refused as a stamp is.
+    $pieces = buildTestPieces();
+    $pieces['wall']['layer'] = 'fixtures';
+    [$editor, $map] = createWallCanvasEditor(pieces: $pieces);
+    $layers = $map->captureLayerSnapshot();
+    callEditorMethod($editor, 'choosePiece', 'wall');
+    foreach (["\n", "\177"] as $key) {
+        getEditorProperty($editor, 'toasts')->clear();
+        callEditorMethod($editor, 'dispatchInput', $key);
+        expect(getCurrentToast($editor)?->message)->toContain('Wall goes on the fixtures layer, which this map does not have.')
+            ->and($map->captureLayerSnapshot())->toBe($layers)
+            ->and($map->isDirty())->toBeFalse();
+    }
+});
+
+it('saves only the wall\'s changed files, rewriting its tile layer only when it changed', function () {
+    [$editor, $map, $root] = createWallCanvasEditor();
+    writeTileLayer($map->directory, '03.walls.tiles.php', implode("\n", array_fill(0, 5, '0  0  0  0  0  0')), "// Walls drawn in the TUI.\n");
+    [$editor, $map] = layeredCanvasEditor($root);
+    $disk = sourceHashTree($map->directory);
+    chooseWallByKeys($editor);
+    drawWallThrough($editor, [0, 0], [3, 2]);
+    callEditorMethod($editor, 'dispatchInput', "\x13");
+
+    expect(array_keys(array_diff_assoc(sourceHashTree($map->directory), $disk)))->toBe(['graphics/03.walls.tiles.php', 'layers/04.buildings.map.php'])
+        ->and(file_get_contents($map->directory . '/graphics/03.walls.tiles.php'))
+        ->toBe("and($map->isDirty())->toBeFalse();
+
+    // Undoing both draws and saving restores every authored byte.
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    callEditorMethod($editor, 'dispatchInput', "\x13");
+    expect(sourceHashTree($map->directory))->toBe($disk);
+});
+
+it('previews the cells Enter would draw with the shapes they would take', function () {
+    [$editor, $map] = createWallCanvasEditor(['      ', '      ', '    | ', '      ', '      ']);
+    chooseWallByKeys($editor);
+    moveCanvasCursorTo($editor, 1, 1);
+    expect(callEditorMethod($editor, 'getPiecePreviewCells'))->toBe([1 => [1 => '+']]);
+
+    drawWallThrough($editor, [1, 1]);
+    moveCanvasCursorTo($editor, 4, 1);
+    // The line's far end meets the wall below it, so it would turn there.
+    expect(callEditorMethod($editor, 'getPiecePreviewCells'))->toBe([1 => [1 => '-', 2 => '-', 3 => '-', 4 => '+']])
+        ->and(renderEditorPlainFrame($editor, 160, 45))->toContain('.---+.', 'PIECE Wall  Enter:Draw  Del:Erase  Esc:Unanchor')
+        ->and(readBuildingRows($map)[1])->toBe(' +    ');
+});
+
+it('drops the anchor with Esc before ending wall placement, and fits the hint to the canvas', function () {
+    [$editor] = createWallCanvasEditor();
+    chooseWallByKeys($editor);
+    expect(callEditorMethod($editor, 'getPiecePlacementHelp', 46))->toBe('PIECE Wall  Enter:Draw  Del:Erase  Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 45))->toBe('PIECE Wall Enter:Draw Del:Erase Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 42))->toBe('PIECE Enter:Draw Del:Erase Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 37))->toBe('Enter:Draw Del:Erase Esc:Done')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 31))->toBe('Esc:Done');
+
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBe(['x' => 0, 'y' => 0])
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 50))->toBe('PIECE Wall  Enter:Draw  Del:Erase  Esc:Unanchor')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 49))->toBe('PIECE Wall Enter:Draw Del:Erase Esc:Unanchor')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 46))->toBe('PIECE Enter:Draw Del:Erase Esc:Unanchor')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 41))->toBe('Enter:Draw Del:Erase Esc:Unanchor')
+        ->and(callEditorMethod($editor, 'getPiecePlacementHelp', 35))->toBe('Esc:Unanchor');
+
+    getEditorProperty($editor, 'toasts')->clear();
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(getEditorProperty($editor, 'piecePlacement')['anchor'])->toBeNull()
+        ->and(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('wall')
+        ->and(getCurrentToast($editor)?->message)->toBe('Anchor dropped. Enter draws Wall at the cursor; Esc when done.');
+
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(getEditorProperty($editor, 'piecePlacement'))->toBeNull();
+});
+
+it('keeps the erase keys erasing with the brush while an item piece is placed', function () {
+    [$editor, $map] = createPieceCanvasEditor();
+    choosePieceByKeys($editor);
+    setEditorProperty($editor, 'cursorX', 1);
+    callEditorMethod($editor, 'dispatchInput', "\177");
+
+    expect($map->getLayerSymbol('map:4', 1, 0))->toBe(' ')
+        ->and(getEditorProperty($editor, 'piecePlacement')['piece']->id)->toBe('bed');
+});
+
+/** @return list> A tile layer source's entries, read as the Engine reads them. */
+function readTileEntries(string $source): array
+{
+    return Ichiloto\Editor\Maps\TileLayerSource::readLayer($source, 'layer.tiles.php')->getEntries();
+}
+
+it('takes a covered glyph\'s tiles with it when a wall or a piece is drawn over it', function () {
+    [$editor, $map] = createWallCanvasEditor();
+    $tilesOf = static function (string $layer) use ($map): array {
+        $found = array_filter($map->getTileLayerSources(), static fn(string $path): bool => str_ends_with($path, ".{$layer}.tiles.php"), ARRAY_FILTER_USE_KEY);
+
+        return $found === [] ? [] : readTileEntries((string) reset($found));
+    };
+    choosePieceByKeys($editor);
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    callEditorMethod($editor, 'dispatchInput', "\033");
+    expect(readBuildingRows($map)[0])->toBe('=     ')
+        ->and($tilesOf('decor')[0])->toBe(['5', '5', '0', '0', '0', '0'])
+        ->and($tilesOf('furniture')[0][0])->toBe('7');
+
+    // The wall covers the bed's top cell, which takes its own tiles and the
+    // blank cell's beside it; the bed's lower cells keep theirs.
+    chooseWallByKeys($editor);
+    drawWallThrough($editor, [0, 0], [2, 0]);
+    expect(readBuildingRows($map)[0])->toBe('---   ')
+        ->and($tilesOf('decor'))->toBe([['0', '0', '0', '0', '0', '0'], ['6', '0', '0', '0', '0', '0'],
+            ...array_fill(0, 3, array_fill(0, 6, '0'))])
+        ->and($tilesOf('furniture')[0][0])->toBe('0')
+        ->and($tilesOf('furniture')[1][1])->toBe('8');
+});
diff --git a/tests/Unit/TilesetRecordsTest.php b/tests/Unit/TilesetRecordsTest.php
new file mode 100644
index 00000000..82fa9486
--- /dev/null
+++ b/tests/Unit/TilesetRecordsTest.php
@@ -0,0 +1,131 @@
+ 'Inside',
+      'sheets' => [
+        'A4' => 'Graphics/Tilesets/Inside/A4.png',
+        'B' => 'Graphics/Tilesets/Inside/B.png',
+      ],
+      'missingArt' => 255,
+      'shadows' => ['casters' => ['A4'], 'width' => 0.5, 'opacity' => 0.5],
+      'pieces' => [
+        // A bed stands two cells long.
+        'bed' => ['name' => 'Bed', 'layer' => 'fixtures', 'glyphs' => ['O', 'U'], 'tiles' => ['furniture' => ['1', '9']]],
+        'wall' => ['name' => 'Wall', 'layer' => 'buildings', 'connects' => 'lines',
+          'glyphs' => ['horizontal' => '-', 'vertical' => '|', 'corner' => '+'], 'tiles' => ['walls' => '5888']],
+      ],
+    ];
+
+    PHP);
+
+    return $root;
+}
+
+function loadTileset(string $root): Tileset
+{
+    return Tileset::load($root . '/assets', 'inside');
+}
+
+it('reads a tileset\'s sheets, flags, shadow and pieces as rows, a piece\'s tiles by layer', function () {
+    $tilesets = loadRecordDatabase(tilesetProject(), 'tilesets');
+    $fields = array_column(array_filter($tilesets->getFrameSettingsFields(0, []), static fn(array $field): bool => isset($field['field'])), 'value', 'field');
+
+    expect($tilesets->getEntryLabels())->toBe(['Inside'])
+        ->and($fields)->toMatchArray([
+            'sheets.A4' => 'Graphics/Tilesets/Inside/A4.png',
+            'missingArt' => '255',
+            'shadows.casters' => 'A4',
+            'piece0Id' => 'bed',
+            'piece0Tiles0Layer' => 'furniture',
+            'piece1Id' => 'wall',
+            'piece1Connects' => 'lines',
+            'piece1Glyphscorner' => '+',
+            'piece1Tiles0Tile' => '5888',
+        ])
+        ->and($tilesets->getRecordByIndex(0)?->get('pieces')['bed']['tiles'])->toBe(['furniture' => ['1', '9']]);
+});
+
+it('edits pieces in their own source, keeping comments, keys and order', function () {
+    $root = tilesetProject();
+    $path = $root . '/assets/Data/Tilesets/inside.php';
+    $source = (string) file_get_contents($path);
+    $tilesets = loadRecordDatabase($root, 'tilesets');
+
+    $tilesets->setField(0, 'piece0Tiles0Rows', "2\n10");
+    $tilesets->setField(0, 'piece1Tiles0Tile', 'horizontal: 5888, vertical: 5890, corner: 5892');
+    $tilesets->setField(0, 'above', '4736, 4784');
+    $tilesets->save();
+
+    $tileset = loadTileset($root);
+
+    $saved = (string) file_get_contents($path);
+
+    expect($saved)->toContain("// The rooms everything indoors is built from.", "// A bed stands two cells long.", "'tiles' => ['furniture' => ['2', '10']]]")
+        ->and(substr($saved, 0, (int) strpos($saved, "  'pieces'")))->toBe(substr($source, 0, (int) strpos($source, "  'pieces'")))
+        ->and($tileset->pieces['bed']->tiles['furniture'])->toBe([['2'], ['10']])
+        ->and($tileset->pieces['wall']->shapeTiles['walls']['vertical'])->toBe('5890')
+        ->and($tileset->above)->toBe([4736, 4784]);
+});
+
+it('adds pieces under ids of their own and refuses an id another piece has', function () {
+    $root = tilesetProject();
+    $tilesets = loadRecordDatabase($root, 'tilesets');
+    $authoring = new RecordAuthoring();
+
+    $authoring->addItem($tilesets, 0, [], 'piece1Name');
+    $authoring->addItem($tilesets, 0, [], 'piece2Name');
+
+    expect(array_keys($tilesets->getRecordByIndex(0)?->get('pieces') ?? []))->toBe(['bed', 'wall', 'new-piece', 'new-piece-2'])
+        ->and(fn() => $authoring->applyField($tilesets, 0, [], 'piece2Id', 'bed', 'Id'))->toThrow(Ichiloto\Editor\Database\RecordRefusal::class, 'Another piece already has id "bed".')
+        ->and(fn() => $authoring->applyField($tilesets, 0, [], 'piece2Id', ' ', 'Id'))->toThrow(Ichiloto\Editor\Database\RecordRefusal::class, 'A piece needs id.');
+
+    $authoring->applyField($tilesets, 0, [], 'piece2Id', 'lamp', 'Id');
+    $tilesets->save();
+
+    expect(array_keys(loadTileset($root)->pieces))->toBe(['bed', 'wall', 'lamp', 'new-piece-2']);
+});
+
+it('turns a stamped piece into a connected one without keeping its rows', function () {
+    $root = tilesetProject();
+    $tilesets = loadRecordDatabase($root, 'tilesets');
+
+    $tilesets->setField(0, 'piece0Connects', 'lines');
+    $bed = $tilesets->getRecordByIndex(0)?->get('pieces')['bed'];
+
+    expect($bed)->toBe(['name' => 'Bed', 'layer' => 'fixtures', 'connects' => 'lines']);
+});
+
+it('reports a tileset the Engine would refuse, by its file', function () {
+    $root = tilesetProject();
+    $tilesets = loadRecordDatabase($root, 'tilesets');
+    $tilesets->setField(0, 'sheets.A4', '');
+    $tilesets->setField(0, 'sheets.B', '');
+    $tilesets->save();
+
+    $issues = array_filter(
+        new Ichiloto\Editor\Validation\ProjectValidator()->validate(Ichiloto\Editor\ProjectWorkspace::fromProject($root)),
+        static fn(Issue $issue): bool => str_contains($issue->where, 'Tilesets'),
+    );
+
+    expect(array_map(static fn(Issue $issue): string => $issue->where . ': ' . $issue->message, array_values($issues)))
+        ->toBe(['assets/Data/Tilesets/inside.php: Tileset inside needs at least one sheet.']);
+});
diff --git a/tests/Unit/TroopMembersEditorTest.php b/tests/Unit/TroopMembersEditorTest.php
new file mode 100644
index 00000000..f33b1c2f
--- /dev/null
+++ b/tests/Unit/TroopMembersEditorTest.php
@@ -0,0 +1,70 @@
+getRecordDatabase('troops');
+    $picker = getEditorProperty($editor, 'referencePicker');
+
+    // Shift+O opens the enemies first; Escape adds nothing.
+    callEditorMethod($editor, 'dispatchInput', 'O');
+    expect($picker->isOpen())->toBeTrue()
+        ->and($troops->countSubItems(0))->toBe(2);
+    callEditorMethod($editor, 'dispatchInput', "\x1b");
+    expect($troops->countSubItems(0))->toBe(2);
+
+    // Typing narrows the enemies; Enter adds a member fighting the one chosen, with the cursor on it.
+    callEditorMethod($editor, 'dispatchInput', 'O');
+    foreach (str_split('Wisp') as $letter) {
+        callEditorMethod($editor, 'dispatchInput', $letter);
+    }
+    callEditorMethod($editor, 'dispatchInput', "\r");
+    expect(array_column($troops->getSubItems(0), 'enemy'))->toBe(['Regular Bat', 'Regular Bat', 'Marsh Wisp'])
+        ->and(callEditorMethod($editor, 'getSelectedDatabaseFieldId'))->toBe('member2Enemy');
+
+    callEditorMethod($editor, 'dispatchInput', 'N');
+    expect($troops->getEntryLabels())->toBe(['Regular Bat x 2, Marsh Wisp']);
+
+    callEditorMethod($editor, 'dispatchInput', 'C');
+    expect($troops->countSubItems(0))->toBe(0);
+
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($troops->countSubItems(0))->toBe(3);
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($troops->getEntryLabels())->toBe(['Pair']);
+    callEditorMethod($editor, 'dispatchInput', "\x1a");
+    expect($troops->countSubItems(0))->toBe(2);
+});
+
+it('adds a troop event page from its heading and its battle commands inside it, keeping the member picker for members', function (): void {
+    $root = troopPageProject();
+    $editor = deletionEditor($root);
+    openDatabaseCategory($editor, 'troops');
+    $troops = getEditorProperty($editor, 'workspace')->getRecordDatabase('troops');
+    $picker = getEditorProperty($editor, 'referencePicker');
+
+    expect(callEditorMethod($editor, 'selectDatabaseFieldById', 'pageList'))->not->toBeFalse();
+    callEditorMethod($editor, 'dispatchInput', 'O');
+    expect($picker->isOpen())->toBeFalse()
+        ->and($troops->getSubItems(0, 'events'))->toHaveCount(1)
+        ->and($troops->countSubItems(0))->toBe(2);
+
+    setEditorProperty($editor, 'databaseFocus', 'database_settings');
+    callEditorMethod($editor, 'selectDatabaseFieldById', 'page0Commands');
+    callEditorMethod($editor, 'dispatchInput', "\n");
+    expect(getEditorProperty($editor, 'databaseCommandFramePath'))->toBe(['events', 0, 'commands']);
+    callEditorMethod($editor, 'dispatchInput', 'O');
+    expect($picker->isOpen())->toBeFalse()
+        ->and($troops->getFrameCommands(0, ['events', 0, 'commands']))->toHaveCount(1)
+        ->and(callEditorMethod($editor, 'getSelectedDatabaseFieldId'))->toBe('command0Type');
+
+    callEditorMethod($editor, 'dispatchInput', "\x1b");
+    callEditorMethod($editor, 'selectDatabaseFieldById', 'member0Enemy');
+    callEditorMethod($editor, 'dispatchInput', 'O');
+    expect($picker->isOpen())->toBeTrue();
+});
diff --git a/tests/Unit/UnsavedReachabilityTest.php b/tests/Unit/UnsavedReachabilityTest.php
new file mode 100644
index 00000000..33da40e3
--- /dev/null
+++ b/tests/Unit/UnsavedReachabilityTest.php
@@ -0,0 +1,71 @@
+ CollisionType::SOLID, ' ' => CollisionType::NONE];\n");
+    file_put_contents($root . '/assets/Data/system.php', ' ['player' => [
+        'destinationMap' => 'test-map', 'spawnPoint' => ['x' => 1, 'y' => 1], 'spawnSprite' => ['South'],
+    ]]], true) . ';');
+    file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', " array_values(array_filter(array_column($session->validateProject(['test-map'])['issues'], 'message'),
+        static fn(string $message): bool => str_contains($message, 'can be reached')));
+    expect($unreachable())->toBe([]);
+
+    // A wall across the corridor, not yet saved, cuts the chest off.
+    $map = $session->readMap('test-map');
+    $session->paint('test-map', $map['revision'], $map['baseLayer'], [[3, 1]], '#');
+
+    expect($unreachable())->toBe(['No cell of event E (ChestEventTrigger) at (5, 1) can be reached, so it never fires.'])
+        // Judged in memory: nothing of the project is written or copied.
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+});
+
+it('judges floor left untiled by an unsaved edit before it is saved', function () {
+    $root = unsavedReachabilityProject();
+    writeTestTileset($root);
+    $data = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($data, str_replace("'triggers' => [],", "'triggers' => [], 'tileset' => 'home',", (string) file_get_contents($data)));
+    writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', implode("\n", array_fill(0, 3, implode(' ', array_fill(0, 7, '2816')))));
+    $session = EditorSession::open($root);
+    $untiled = static fn(): array => array_values(array_filter(array_column($session->validateProject(['test-map'])['issues'], 'message'),
+        static fn(string $message): bool => str_contains($message, 'shows the background')));
+    expect($untiled())->toBe([]);
+
+    $session->paintTiles('test-map', $session->readMap('test-map')['revision'], 'floor', [[2, 1]], 0);
+
+    expect($untiled())->toBe(['1 cell the player can reach has no tile, so the field shows the background there: (2, 1).']);
+});
+
+it('warns when the player meets a faced event from the side where it does not answer', function () {
+    $root = unsavedReachabilityProject();
+    // A room entered at its north-west corner; the solid chest at (3, 2) answers only to a player south of it, facing north.
+    file_put_contents($root . '/assets/Maps/test-map/test-map.map.php', " 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger',",
+        "'class' => 'Ichiloto\\Engine\\Events\\Triggers\\ChestEventTrigger', 'engagement' => 'facing', 'facing' => ['North'],",
+        (string) file_get_contents($data)));
+    $issues = array_values(array_filter(EditorSession::open($root)->validateProject(['test-map'])['issues'],
+        static fn(array $issue): bool => str_contains($issue['message'], 'walks')));
+
+    expect(array_map(static fn(array $issue): array => [$issue['severity'], $issue['message']], $issues))->toBe([[
+        'warning',
+        'Arriving from the starting position, the player first reaches event E (ChestEventTrigger) at (3, 2) on (3, 1), where it does not answer, and walks 2 more steps round to (3, 3) to use it.',
+    ]]);
+});
diff --git a/tests/Unit/Validation/SeeThroughGroundTest.php b/tests/Unit/Validation/SeeThroughGroundTest.php
new file mode 100644
index 00000000..90afabc5
--- /dev/null
+++ b/tests/Unit/Validation/SeeThroughGroundTest.php
@@ -0,0 +1,111 @@
+ pack('N', strlen($data)) . $type . $data . pack('N', crc32($type . $data));
+    file_put_contents($path, "\x89PNG\r\n\x1a\n" . $chunk('IHDR', pack('NNCCCCC', $width, $height, 8, 6, 0, 0, 0))
+        . $chunk('IDAT', (string) gzcompress(str_repeat("\0" . str_repeat("\0\0\0\0", $width), $height))) . $chunk('IEND', ''));
+}
+
+/** @return list The see-through ground warnings for the test map. */
+function findSeeThroughWarnings(string $root): array
+{
+    $map = ProjectWorkspace::fromProject($root)->getMapByIndex(0);
+
+    return array_values(array_map(static fn($issue): string => $issue->message, array_filter(MapGraphicsValidator::validateCoverage($map),
+        static fn($issue): bool => str_contains($issue->message, 'see-through') || str_contains($issue->message, 'could not be checked'))));
+}
+
+beforeEach(function () {
+    if (! function_exists('imagecreatefrompng')) {
+        $this->markTestSkipped('Tile coverage reads sheet pixels through the optional GD extension.');
+    }
+});
+
+it('warns for every cell whose ground tile leaves the background showing, naming the first and counting the rest', function () {
+    $root = mapGraphicsProject();
+    writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24);
+
+    expect(findSeeThroughWarnings($root))->toBe(['8 cells can show the background through see-through ground tiles in some animation frame,'
+        . ' a dark seam in the game: (0, 0), (1, 0), (2, 0) and 5 more.']);
+});
+
+it('is satisfied once ground is authored beneath', function () {
+    $root = mapGraphicsProject();
+    writeTilesetTestPng($root . '/assets/Graphics/Tilesets/Home_A5.png', 16, 32);
+    writeTestTileset($root, sheets: ['A2' => 'Graphics/Tilesets/Home_A2.png', 'A5' => 'Graphics/Tilesets/Home_A5.png',
+        'B' => 'Graphics/Tilesets/Home_B.png']);
+    writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24);
+    writeTileLayer($root . '/assets/Maps/test-map', '00.base.tiles.php', "1536 1536 1536 1536\n1536 1536 1536 1536");
+
+    expect(findSeeThroughWarnings($root))->toBe([]);
+});
+
+it('never judges upper-sheet props, which are see-through by design', function () {
+    $root = mapGraphicsProject();
+    writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_B.png', 32, 32);
+    writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "0 0 0 0\n0 0 0 0");
+
+    // The decor layer's see-through B tiles stand on cells with no ground at all.
+    expect(findSeeThroughWarnings($root))->toBe([]);
+});
+
+it('reads the artwork as it is now, so replacing an opaque sheet with a see-through one is noticed', function () {
+    $root = mapGraphicsProject();
+
+    expect(findSeeThroughWarnings($root))->toBe([]);
+    writeTransparentTestPng($root . '/assets/Graphics/Tilesets/Home_A2.png', 32, 24);
+    expect(findSeeThroughWarnings($root))->toHaveCount(1);
+});
+
+/** Adds a corner-edged pond kind on the `water` tile layer, its corner sheet one still phase of 2-pixel tiles. */
+function writeCornerPondTileset(string $root, bool $usable = true): void
+{
+    $path = $root . '/assets/Data/Tilesets/home.php';
+    $data = require $path;
+    $data['kinds'] = ['pond' => ['name' => 'Pond', 'layer' => 'terrain', 'glyph' => '~', 'passage' => 'solid', 'atlas' => 'water',
+        'art' => ['layer' => 'water', 'tile' => 2864, 'corners' => 'Graphics/Tilesets/Pond.corners.png']]];
+    file_put_contents($path, "toBe([]);
+
+    // Its own opaque autotile would have hidden the hole; its see-through corner tiles show the background there and beside it.
+    writeTileLayer($root . '/assets/Maps/test-map', '01.floor.tiles.php', "0 0 2816 2816\n0 2816 2816 2816");
+    expect(findSeeThroughWarnings($root))->toBe(['3 cells can show the background through see-through ground tiles in some animation frame,'
+        . ' a dark seam in the game: (0, 0), (1, 0), (0, 1).']);
+});
+
+it('warns when a corner-edged kind the map shows cannot be drawn from its corner sheet', function () {
+    $root = mapGraphicsProject();
+    writeCornerPondTileset($root, usable: false);
+    writeTileLayer($root . '/assets/Maps/test-map', '03.water.tiles.php', "2864 0 0 0\n0 0 0 0");
+    $messages = array_map(static fn($issue): string => $issue->message,
+        MapGraphicsValidator::validate(ProjectWorkspace::fromProject($root)->getMapByIndex(0)));
+
+    expect(array_values(array_filter($messages, static fn(string $message): bool => str_contains($message, 'corner sheet'))))
+        ->toHaveCount(1)
+        ->and(implode(' ', $messages))->toContain('Tileset home kind pond corner sheet is unusable: Graphics/Tilesets/Pond.corners.png is 6x8')
+        // Its cells show their glyphs, never a hole, so see-through ground does not report them.
+        ->and(findSeeThroughWarnings($root))->toBe([]);
+});
diff --git a/tests/Unit/Validation/WorldObjectGlyphCoverageTest.php b/tests/Unit/Validation/WorldObjectGlyphCoverageTest.php
new file mode 100644
index 00000000..0756ba12
--- /dev/null
+++ b/tests/Unit/Validation/WorldObjectGlyphCoverageTest.php
@@ -0,0 +1,53 @@
+getMapByIndex(0))[0]
+        ?? throw new RuntimeException('The fixture shows no glyph to cover.');
+    writeTilesetTestPng($root . '/assets/Graphics/Props/Counter.png', 12, 16);
+    is_dir($root . '/assets/Data/Presentation') || mkdir($root . '/assets/Data/Presentation', 0o777, true);
+    file_put_contents($root . '/assets/Data/Presentation/field.php', " ['counter' => ['name' => 'Counter',"
+        . " 'pivot' => ['x' => 0.5, 'y' => 1.0], 'sprites2d' => ['asset' => 'Graphics/Props/Counter.png']]]];\n");
+    $file = $root . '/assets/Maps/test-map/test-map.data.php';
+    file_put_contents($file, str_replace("'events' => [],", sprintf("'events' => [], 'worldObjects' => [['id' => 'counter-1', 'resource' => 'counter',"
+        . " 'anchor' => ['x' => %d, 'y' => %d], 'covers' => ['layer' => '%s', 'cells' => [[%d, %d]], 'tileLayers' => []]]],",
+        $cell['x'], $cell['y'], $cell['layer'], $cell['x'], $cell['y']), (string) file_get_contents($file)));
+
+    return [$root, $cell];
+}
+
+/** @return list */
+function listShownCells(string $root): array
+{
+    return array_map(static fn(array $cell): array => [$cell['x'], $cell['y'], $cell['layer']],
+        MapGraphicsValidator::listShownGlyphCells(ProjectWorkspace::fromProject($root)->getMapByIndex(0)));
+}
+
+it('stops reporting a glyph a drawn world object covers', function () {
+    [$root, $cell] = createCoveredGlyphProject();
+
+    expect(listShownCells($root))->not->toContain([$cell['x'], $cell['y'], $cell['layer']]);
+});
+
+it('keeps reporting a covered glyph while the object\'s art cannot be drawn, as the field shows it', function () {
+    [$root, $cell] = createCoveredGlyphProject();
+    unlink($root . '/assets/Graphics/Props/Counter.png');
+
+    expect(listShownCells($root))->toContain([$cell['x'], $cell['y'], $cell['layer']]);
+});
diff --git a/tests/Unit/WordFormsTest.php b/tests/Unit/WordFormsTest.php
new file mode 100644
index 00000000..3ca06585
--- /dev/null
+++ b/tests/Unit/WordFormsTest.php
@@ -0,0 +1,81 @@
+ The validator's findings about word forms. */
+function wordFormIssueMessages(string $root): array
+{
+    $issues = new ProjectValidator()->validate(ProjectWorkspace::fromProject($root));
+
+    return array_values(array_map(static fn(Issue $issue): string => $issue->message,
+        array_filter($issues, static fn(Issue $issue): bool => $issue->where === 'assets/' . WordForms::FILE)));
+}
+
+it('writes nothing while a project without word forms is browsed and validated', function (): void {
+    $root = makeTemporaryProject();
+    $before = sourceHashTree($root . '/assets');
+
+    $editor = deletionEditor($root);
+    openDatabaseCategory($editor, WordFormFields::CATEGORY);
+    callEditorMethod($editor, 'saveActiveDatabase');
+
+    expect(wordFormIssueMessages($root))->toBe([])
+        ->and(is_file($root . '/assets/' . WordForms::FILE))->toBeFalse()
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+});
+
+it('authors a word the game then keeps from the English rules', function (): void {
+    $root = makeTemporaryProject();
+
+    $database = loadRecordDatabase($root, WordFormFields::CATEGORY);
+    $index = $database->addRecord();
+    $database->setField($index, 'word', 'T-Vexmana');
+    $database->setField($index, 'singular', 'T-Vexmana');
+    $database->setField($index, 'plural', 'T-Vexmana');
+    $database->save();
+
+    $forms = WordForms::load($root . '/assets');
+    expect($forms->words)->toBe(['T-Vexmana' => ['singular' => 'T-Vexmana', 'plural' => 'T-Vexmana']])
+        ->and(wordFormIssueMessages($root))->toBe([]);
+
+    expect(new Text('T-Vexmana')->getSingularForm())->toBe('T-Vexmanum');
+    $forms->registerExceptions();
+    expect(new Text('T-Vexmana')->getSingularForm())->toBe('T-Vexmana')
+        ->and(new Text('T-Vexmana')->getPluralForm())->toBe('T-Vexmana')
+        ->and(new Text('Lanternfish')->getPluralForm())->toBe('Lanternfishes');
+});
+
+it('authors a plural of its own beside the word', function (): void {
+    $root = makeTemporaryProject();
+
+    $database = loadRecordDatabase($root, WordFormFields::CATEGORY);
+    $index = $database->addRecord();
+    $database->setField($index, 'word', 'Duskstratum');
+    $database->setField($index, 'singular', 'Duskstratum');
+    $database->setField($index, 'plural', 'Duskstrata');
+    $database->save();
+
+    expect(WordForms::load($root . '/assets')->words)->toBe(['Duskstratum' => ['singular' => 'Duskstratum', 'plural' => 'Duskstrata']]);
+});
+
+it('refuses a blank or padded form as it is typed', function (string $field, string $value): void {
+    expect(fn() => WordFormFields::checkEdit(['word' => 'Mana', 'singular' => 'Mana', 'plural' => 'Mana', $field => $value], $field))
+        ->toThrow(RecordRefusal::class);
+})->with([['word', ' Mana'], ['singular', ''], ['plural', 'Mana ']]);
+
+it('reports word forms the game could not read as errors', function (): void {
+    $root = makeTemporaryProject();
+    file_put_contents($root . '/assets/' . WordForms::FILE, " '" . WordForms::SCHEMA
+        . "', 'words' => ['Mana' => ['singular' => 'Mana']]];");
+
+    expect(wordFormIssueMessages($root))->toHaveCount(1)
+        ->and(wordFormIssueMessages($root)[0])->toContain('plural');
+});
diff --git a/tests/Unit/WorldStateLineTest.php b/tests/Unit/WorldStateLineTest.php
new file mode 100644
index 00000000..7ee218ed
--- /dev/null
+++ b/tests/Unit/WorldStateLineTest.php
@@ -0,0 +1,96 @@
+ ConditionCodec::decodeAllStrictly('event:a & !event:b'))
+        ->toThrow(InvalidArgumentException::class,
+            'Condition "event:a & !event:b" cannot be read: the event condition takes nothing after its name, not "b"');
+});
+
+it('refuses the malformed line through the NPC session whole, leaving the NPC conditions and the file as they were', function () {
+    $conditions = [['type' => 'event', 'name' => 'harvest-done']];
+    $root = npcSessionProject([['id' => 'keeper', 'name' => 'Keeper', 'sprite' => '@', 'x' => 2, 'y' => 1, 'conditions' => $conditions]]);
+    $before = sourceHashTree($root . '/assets');
+    $session = EditorSession::open($root);
+    $read = $session->readNpc('test-map', 0);
+
+    expect(fn() => $session->applyNpc('test-map', $read['revision'], 0, npcSessionRow($read, 'conditions')['key'], 'event:a & !event:b'))
+        ->toThrow(SessionRefusal::class, 'cannot be read')
+        ->and(getSessionMap($session)->getNpcs()->all()[0]->toArray()['conditions'])->toBe($conditions)
+        ->and($session->listUnsavedChanges())->toBe([])
+        ->and(sourceHashTree($root . '/assets'))->toBe($before);
+});
+
+it('round-trips every condition type through the line, names with colons, semicolons and backslashes included', function () {
+    $conditions = [
+        ['type' => 'quest', 'name' => 'harvest', 'status' => 'active'],
+        ['type' => 'quest', 'name' => 'harvest', 'status' => 'completed', 'negate' => true],
+        ['type' => 'switch', 'name' => 'gate:open', 'value' => false],
+        ['type' => 'event', 'name' => 'deckers:won'],
+        ['type' => 'event', 'name' => 'a & !event; b \\ c'],
+        ['type' => 'map_event', 'name' => 'town/square', 'marker' => 'B'],
+        ['type' => 'variable', 'name' => 'gold', 'op' => '>=', 'value' => 5],
+        ['type' => 'variable', 'name' => 'mood', 'op' => '!=', 'value' => 'calm'],
+        ['type' => 'item', 'name' => 'item.tonic', 'quantity' => 3],
+        ['type' => 'key_item', 'name' => 'item.key'],
+    ];
+    $line = ConditionCodec::encodeAll($conditions);
+
+    expect(ConditionCodec::decodeAllStrictly($line))->toBe($conditions)
+        ->and(ConditionCodec::decodeAll($line))->toBe($conditions)
+        ->and(ConditionCodec::encode(['type' => 'event', 'name' => 'deckers:won']))->toBe('event:deckers\\:won')
+        ->and(ConditionCodec::decodeAllStrictly('event:deckers\\:won'))->toBe([['type' => 'event', 'name' => 'deckers:won']])
+        ->and(ConditionCodec::findCarryIssue($conditions))->toBeNull();
+    // What the line yields is what the Engine accepts.
+    WorldConditionEvaluator::validateAll(ConditionCodec::decodeAllStrictly($line));
+});
+
+it('refuses a condition value the Engine would refuse instead of rewriting it', function (string $line, string $reason) {
+    expect(fn() => ConditionCodec::decodeAllStrictly($line))->toThrow(InvalidArgumentException::class, $reason)
+        ->and(ConditionCodec::decodeAll($line))->toBe([]);
+})->with([
+    'a quest status' => ['quest:harvest:failed', 'a quest status is completed or active, not "failed"'],
+    'a switch value' => ['switch:gate:maybe', 'a switch is true or false, not "maybe"'],
+    'a zero quantity' => ['item:item.tonic:0', 'a quantity is a whole number above zero, not "0"'],
+    'a word quantity' => ['item:item.tonic:many', 'a quantity is a whole number above zero, not "many"'],
+    'a variable operator' => ['variable:gold:~:3', 'a variable compares with == != > >= < <=, not "~"'],
+    'a map event marker' => ['map_event:town/square', 'needs its map and its event marker'],
+    'a fourth part' => ['quest:harvest:active:twice', 'takes at most a status'],
+    'an unknown type' => ['flag:open', 'with a type of'],
+    'a double negation' => ['!!switch:gate', 'with a type of'],
+    'an unknown escape' => ['event:a\\b', '"\\b" escapes nothing'],
+    'a trailing backslash' => ['event:a\\', 'escapes nothing'],
+]);
+
+it('round-trips every world write type through the line and refuses parts and values it cannot be', function () {
+    $sets = [
+        ['type' => 'switch', 'name' => 'gate:open', 'value' => true],
+        ['type' => 'event', 'name' => 'deckers:won'],
+        ['type' => 'variable', 'name' => 'gold', 'op' => 'add', 'value' => 5],
+        ['type' => 'quest', 'name' => 'harvest', 'confirm' => false],
+    ];
+    $line = WorldWriteCodec::encodeAll($sets);
+
+    expect(WorldWriteCodec::decodeAllStrictly($line))->toBe($sets)
+        ->and(WorldWriteCodec::findCarryIssue($sets))->toBeNull()
+        ->and(fn() => WorldWriteCodec::decodeAllStrictly('event:a & event:b'))->toThrow(InvalidArgumentException::class, 'the event write takes nothing after its name, not "b"')
+        ->and(fn() => WorldWriteCodec::decodeAllStrictly('switch:gate:maybe'))->toThrow(InvalidArgumentException::class, 'a switch is set true or false, not "maybe"')
+        ->and(fn() => WorldWriteCodec::decodeAllStrictly('variable:gold:mul:2'))->toThrow(InvalidArgumentException::class, 'a variable write is set or add, not "mul"')
+        ->and(fn() => WorldWriteCodec::decodeAllStrictly('quest:harvest:offer'))->toThrow(InvalidArgumentException::class, 'a quest write is confirm or grant, not "offer"');
+});
+
+it('encodes a colon-bearing name for a graphical editor and reads it back whole', function () {
+    $session = EditorSession::open(makeTemporaryProject());
+
+    $encoded = $session->encodeWorldState('conditions', [['type' => 'event', 'name' => 'deckers:won'], ['type' => 'switch', 'name' => 'lamp', 'value' => true]]);
+
+    expect($encoded['line'])->toBe('event:deckers\\:won; switch:lamp')
+        ->and(ConditionCodec::decodeAllStrictly($encoded['line']))->toBe([['type' => 'event', 'name' => 'deckers:won'], ['type' => 'switch', 'name' => 'lamp']]);
+});
diff --git a/tests/__snapshots__/main_shell_100x30.ansi b/tests/__snapshots__/main_shell_100x30.ansi
index 2154c7ca..98525486 100644
--- a/tests/__snapshots__/main_shell_100x30.ansi
+++ b/tests/__snapshots__/main_shell_100x30.ansi
@@ -1 +1 @@
-[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project                                                                        │└─?:Help  Ctrl+P:Palette  Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps                         ││ > test-map                   ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              │└─/:Filter  Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map          ││ Test Map |  | 12 x 5 | vie ││ ############               ││ #  ~~~     #               ││ #          #               ││ #          #               ││ ############               ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            │└─i:Paint  m:Map  e:Event────┘┌─Inspector──────────────────────┐│   Name: Test Map               ││   Region:                      ││   Description: A tiny fixture  ││                map.            ││   Size                         ││     X: 12                      ││     Y: 5                       ││   Events · 1                   ││   Triggers · 0                 ││   Audio                        ││     Background Music: (None)   ││     Music Variants · None      ││   Encounters · off             ││     Troops · 0                 ││                                ││                                ││                                ││                                ││                                │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1                             ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready.                                                     │└─Ctrl+P:Palette  Tab:Pane  Ctrl+S:Save  Ctrl+A:Save All  Ctrl+Z:Undo  Ctrl+Y:Redo  Ctrl+Q:Quit──┘[?25l[?25l
\ No newline at end of file
+[?25l┌─Ichiloto Editor────────────────────────────────────────────────────────────────────────────────┐│ Project: Sample Project                                                                        │└─?:Help  Ctrl+P:Palette  Ctrl+D / F2:Database───────────────────────────────────────────────────┘┌─Assets [Focus]───────────────┐│ Maps                         ││ > test-map                   ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              ││                              │└─/:Filter  Del:Delete─────────┘┌─Canvas─────────────────────┐│ Preview: test-map          ││ Test Map |  | 12 x 5 | vie ││ ############               ││ #  ~~~     #               ││ #          #               ││ #          #               ││ ############               ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            ││                            │└─i:Paint L:Layer ?:Help─────┘┌─Inspector──────────────────────┐│   Name: Test Map               ││   Region:                      ││   Kind: Not set                ││   Description: A tiny fixture  ││                map.            ││   Size                         ││     X: 12                      ││     Y: 5                       ││   Events · 1                   ││   Triggers · 0                 ││   Audio                        ││     Background Music: (None)   ││     Music Variants · None      ││   Encounters · off             ││     Troops · 0                 ││   Atlas                        ││     Scope: Local               ││     Terminal View: Floorplan   ││     Parent Entry: (none)       │└─Enter:Edit─────────────────────┘[?25l┌─Status─────────────────────────────────────────────────────────────────────────────────────────┐│ Selected map: test-map | Focus: Assets | Mode: Map | Tool: Brush 1                             ││ Cursor: (0, 0) | Viewport: (0, 0) | Ready.                                                     │└─Ctrl+P:Palette  Tab:Pane  Ctrl+S:Save  Ctrl+A:Save All  Ctrl+Z:Undo  Ctrl+Y:Redo  Ctrl+Q:Quit──┘[?25l[?25l
\ No newline at end of file
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 00000000..3ff42265
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,13 @@
+addPsr4('Ichiloto\\Engine\\', $source, prepend: true);
+}
diff --git a/tests/phpstan-engine.php b/tests/phpstan-engine.php
new file mode 100644
index 00000000..df48e2ad
--- /dev/null
+++ b/tests/phpstan-engine.php
@@ -0,0 +1,14 @@
+ [dirname(__DIR__) . '/phpstan.neon'],
+    'parameters' => ['scanDirectories' => [$source]],
+];